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

No files matched your search

-299
View File
@@ -1,299 +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: |
base="${TAG#v}"; base="${base%%-*}"
notes=$(awk -v v="$base" '
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
on' CHANGELOG.md)
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 }}
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" \
-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 }}
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"
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 }}
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" \
-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/ build/
.qtcreator/ .qtcreator/
.DS_Store .DS_Store
/doc/adr/
CMakeLists.txt.user* CMakeLists.txt.user*
stage/ stage/
dist/ dist/
build-tests/
build-*/
+3 -5
View File
@@ -15,9 +15,8 @@ Dialog {
padding: 0 padding: 0
width: 880 width: 880
height: 600 height: 600
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle { background: Rectangle {
color: "#1e1e1e" color: "#1e1e1e"
@@ -315,8 +314,7 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: buildRow.index === buildsDialog.backend.activeBuildIndex visible: buildRow.index === buildsDialog.backend.activeBuildIndex
text: Loc.t.build.label.active text: Loc.t.build.label.active
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered) color: "#91B315"
? "#ffffff" : "#91B315"
font.pixelSize: 10 font.pixelSize: 10
} }
Image { Image {
+50 -11
View File
@@ -1,12 +1,51 @@
# Changelog # First Level Heading
## [1.0.0] — 2026-10-07 Paragraph.
### Добавлено
- Настройки запуска Minecraft ## Second Level Heading
- Автоматическая загрузка версий java из предложенного списка
- Система хранения сборок, автоматического скачивания версий игры из меню и модлоадеры к каждой версии Paragraph.
- Сохранение сборок в zip формате и отображение в списке
- Система сезонных сборок: полная автоматизация в один клик - bullet
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов + other bullet
### Исправлено * another bullet
- UI фиксы * 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
+23 -18
View File
@@ -1,8 +1,6 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C) project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
set(LAUNCHER_VERSION_SUFFIX "-dev")
set(LAUNCHER_VERSION "${PROJECT_VERSION}${LAUNCHER_VERSION_SUFFIX}")
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(QT_NO_PRIVATE_MODULE_WARNING ON) set(QT_NO_PRIVATE_MODULE_WARNING ON)
@@ -21,6 +19,15 @@ endif()
qt_standard_project_setup(REQUIRES 6.8) 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") set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
if(WIN32) if(WIN32)
@@ -40,6 +47,10 @@ qt_add_executable(BiLauncher
${LAUNCHER_APP_ICON} ${LAUNCHER_APP_ICON}
) )
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
qt_add_resources(BiLauncher "appicon" qt_add_resources(BiLauncher "appicon"
PREFIX "/icons" PREFIX "/icons"
BASE "icons" BASE "icons"
@@ -59,14 +70,8 @@ qt_add_resources(BiLauncher "appicon"
icons/icon_512x512@2x.png 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 qt_add_qml_module(BiLauncher
URI BiLauncher URI BiLauncher
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
SOURCES SOURCES
localization.h localization.h
localization.cpp localization.cpp
@@ -105,8 +110,6 @@ qt_add_qml_module(BiLauncher
buildarchiveworker.cpp buildarchiveworker.cpp
buildswitcher.h buildswitcher.h
buildswitcher.cpp buildswitcher.cpp
gamedirimport.h
gamedirimport.cpp
seasonalbuildservice.h seasonalbuildservice.h
seasonalbuildservice.cpp seasonalbuildservice.cpp
seasonalpackdownloader.h seasonalpackdownloader.h
@@ -124,9 +127,12 @@ qt_add_qml_module(BiLauncher
${LAUNCHER_MSA_QML_FILES} ${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/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/launcherBackground.png
RESOURCES images/launcherBackground2static.jpg RESOURCES images/launcherBackground2.mp4 images/launcherBackground2static.jpg
RESOURCES CHANGELOG.md RESOURCES CHANGELOG.md
RESOURCES LICENSE RESOURCES LICENSE
RESOURCES icons/icon.rc
RESOURCES icons/icon.rc.in
RESOURCES icons/icon.rc
RESOURCES README.md RESOURCES README.md
) )
@@ -137,6 +143,8 @@ set_target_properties(BiLauncher PROPERTIES
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
MACOSX_BUNDLE_ICON_FILE icon.icns MACOSX_BUNDLE_ICON_FILE icon.icns
MACOSX_BUNDLE TRUE MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE TRUE WIN32_EXECUTABLE TRUE
@@ -147,7 +155,7 @@ target_link_libraries(BiLauncher
) )
target_compile_definitions(BiLauncher PRIVATE target_compile_definitions(BiLauncher PRIVATE
LAUNCHER_VERSION="${LAUNCHER_VERSION}" LAUNCHER_VERSION="${PROJECT_VERSION}"
) )
if(Qt6WebEngineQuick_FOUND) if(Qt6WebEngineQuick_FOUND)
@@ -197,6 +205,8 @@ if(UNIX AND NOT APPLE)
) )
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR}) 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) 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" install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
@@ -232,8 +242,3 @@ qt_generate_deploy_qml_app_script(
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
DEPLOY_TOOL_OPTIONS ${deploy_opts}) DEPLOY_TOOL_OPTIONS ${deploy_opts})
install(SCRIPT ${deploy_script}) 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
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
// Комбобокс в общем тёмном стиле окна.
ComboBox { ComboBox {
id: darkCombo id: darkCombo
// Та же стрелка, что у комбобоксов профиля и версии на главном окне,
// вместо двойного шеврона Basic-стиля.
indicator: Image { indicator: Image {
width: 10; height: 10 width: 10; height: 10
x: darkCombo.width - width - 12 x: darkCombo.width - width - 12
+20 -1
View File
@@ -4,16 +4,22 @@ import QtQuick
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Layouts 2.15 import QtQuick.Layouts 2.15
// Выбор сборки Java отдельным окном: слева типы, справа сами версии с поиском.
// Устроено так же, как выбор версии Minecraft, но выбранное здесь ещё и
// качается — в отличие от версий игры, где загрузку начинает сама сборка.
Dialog { Dialog {
id: picker id: picker
required property var backend required property var backend
// Сборка, с которой окно открылось: по «Отмене» выбор к ней и возвращается.
property string selectedId: "" property string selectedId: ""
property string category: "java" property string category: "java"
property string filterText: "" property string filterText: ""
property bool installedOnly: false property bool installedOnly: false
// Java, ниже которой выбранной версии игры не запуститься. 0 — версия игры
// не выбрана, предупреждать не о чем.
property int requiredMajor: 0 property int requiredMajor: 0
signal runtimeChosen(string runtimeId) signal runtimeChosen(string runtimeId)
@@ -37,6 +43,8 @@ Dialog {
{ key: "jre", title: Loc.t.common.java.kind.jre, hint: Loc.t.java.filter.temurinJre } { key: "jre", title: Loc.t.common.java.kind.jre, hint: Loc.t.java.filter.temurinJre }
] ]
// Каталог приходит из C++ уже отсортированным (новые сверху, скачанные
// первыми), поэтому здесь только отбор — порядок не трогаем.
readonly property var visibleEntries: { readonly property var visibleEntries: {
const query = picker.filterText.trim().toLowerCase() const query = picker.filterText.trim().toLowerCase()
const source = picker.backend.javaCatalog const source = picker.backend.javaCatalog
@@ -99,6 +107,7 @@ Dialog {
runtimeList.positionViewAtIndex(index, ListView.Center) runtimeList.positionViewAtIndex(index, ListView.Center)
} }
// «Выбрать» и скачивает: сборка, которой нет на диске, запуску не поможет.
function acceptSelection() { function acceptSelection() {
const entry = picker.selectedEntry const entry = picker.selectedEntry
if (!entry) if (!entry)
@@ -129,6 +138,7 @@ Dialog {
contentItem: RowLayout { contentItem: RowLayout {
spacing: 0 spacing: 0
// ── Типы сборок ─────────────────────────────────────────────────
Rectangle { Rectangle {
Layout.preferredWidth: 170 Layout.preferredWidth: 170
Layout.fillHeight: true Layout.fillHeight: true
@@ -185,6 +195,8 @@ Dialog {
Item { width: 1; height: 8 } Item { width: 1; height: 8 }
// Требование выбранной сборки — чтобы не пришлось держать в
// голове, какая Java нужна какой версии игры.
Text { Text {
width: parent.width - 4 width: parent.width - 4
visible: picker.requiredMajor > 0 visible: picker.requiredMajor > 0
@@ -203,6 +215,7 @@ Dialog {
color: "#333333" color: "#333333"
} }
// ── Версии ──────────────────────────────────────────────────────
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -304,7 +317,8 @@ Dialog {
required property int index required property int index
readonly property bool chosen: picker.selectedId === runtimeRow.modelData.id readonly property bool chosen: picker.selectedId === runtimeRow.modelData.id
// Сборка ниже требования игры выбирается, но помечается: она
// может пригодиться для другой сборки.
readonly property bool tooOld: picker.requiredMajor > 0 readonly property bool tooOld: picker.requiredMajor > 0
&& runtimeRow.modelData.major < picker.requiredMajor && runtimeRow.modelData.major < picker.requiredMajor
@@ -339,6 +353,8 @@ Dialog {
} }
Text { Text {
// Колонка растянута якорями, поэтому её ширина
// задана, а не выведена из детей — цикла тут нет.
width: parent.width width: parent.width
elide: Text.ElideRight elide: Text.ElideRight
color: runtimeRow.chosen ? "#1e1e1e" color: runtimeRow.chosen ? "#1e1e1e"
@@ -372,6 +388,8 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
// Сборки весят по двести мегабайт — удалять их нужно
// прямо здесь, иначе папка лаунчера растёт молча.
Image { Image {
visible: runtimeRow.modelData.installed visible: runtimeRow.modelData.installed
source: "images/Trash.svg" source: "images/Trash.svg"
@@ -412,6 +430,7 @@ Dialog {
} }
} }
// Пустая категория, ничего не нашлось или каталог ещё не пришёл.
Text { Text {
anchors.centerIn: runtimeList anchors.centerIn: runtimeList
width: runtimeList.width - 20 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
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
// Подпись + поле ввода одной колонкой — используется в настройках.
Column { Column {
id: labelled id: labelled
@@ -9,6 +10,7 @@ Column {
property string label: "" property string label: ""
property string placeholder: "" property string placeholder: ""
// Правка закончена: поле потеряло фокус или пользователь нажал Enter.
signal editingFinished() signal editingFinished()
spacing: 3 spacing: 3
+28
View File
@@ -1,6 +1,13 @@
import QtQuick import QtQuick
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
// Один модлоадер в карточке сборки: чекбокс и, когда он отмечен, список версий
// именно под выбранную версию Minecraft.
//
// Совместимость проверять не нужно: backend.loaderVersions() отдаёт список уже
// под конкретную версию игры, поэтому несовместимой строки в нём не бывает.
// Пустой список — это «лоадер не поддерживает эту версию игры», и он тоже
// показывается словами, а не пустым выпадающим списком.
Column { Column {
id: loaderRow id: loaderRow
@@ -8,13 +15,21 @@ Column {
required property string loaderKey required property string loaderKey
required property string title required property string title
// Версия Minecraft, выбранная в карточке. Её смена сбрасывает выбор.
property string gameVersion: "" property string gameVersion: ""
property alias checked: box.checked property alias checked: box.checked
property string selectedVersion: "" property string selectedVersion: ""
property var entries: [] property var entries: []
// Пользователь щёлкнул чекбокс — карточка снимет отметки с остальных
// лоадеров: одновременно в .minecraft может жить только один.
signal userChecked() signal userChecked()
// Отметка или версия изменились — карточке пора сохранить сборку.
signal changed() signal changed()
// Ставится, пока карточка заполняет строку данными сборки: в это время
// обработчики не должны считать изменения пользовательскими.
property bool applying: false property bool applying: false
spacing: 4 spacing: 4
@@ -27,9 +42,16 @@ Column {
return -1 return -1
} }
// Единственное место, где меняются entries и selectedVersion. Через него
// проходят все три пути — кэш, ответ сети и заполнение из сборки, — потому
// что выбранная версия обязана существовать в списке под текущую версию
// игры. Раньше проверял только путь из сети, и версия лоадера от прошлой
// версии игры доживала до сохранения сборки.
function applyEntries(list) { function applyEntries(list) {
loaderRow.entries = list loaderRow.entries = list
// Список отсортирован новыми вперёд, поэтому максимально доступная
// версия для этой версии игры — всегда первая строка.
if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) { if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) {
const fallback = list.length > 0 ? list[0].version : "" const fallback = list.length > 0 ? list[0].version : ""
const changedValue = loaderRow.selectedVersion !== fallback const changedValue = loaderRow.selectedVersion !== fallback
@@ -46,20 +68,26 @@ Column {
loaderRow.applyEntries([]) loaderRow.applyEntries([])
return return
} }
// Сначала из кэша — список появляется мгновенно, — потом обновление сети.
loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey, loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey,
loaderRow.gameVersion)) loaderRow.gameVersion))
loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion) loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion)
} }
// Заполнение из сохранённой сборки: без сигналов changed().
function applyBuild(loader, loaderVersion) { function applyBuild(loader, loaderVersion) {
loaderRow.applying = true loaderRow.applying = true
box.checked = loader === loaderRow.loaderKey box.checked = loader === loaderRow.loaderKey
// Версию из сборки берём как пожелание: если под выбранную версию игры
// её нет, applyEntries() заменит её на максимально доступную.
loaderRow.selectedVersion = box.checked ? loaderVersion : "" loaderRow.selectedVersion = box.checked ? loaderVersion : ""
loaderRow.reload() loaderRow.reload()
loaderRow.applying = false loaderRow.applying = false
} }
onGameVersionChanged: { onGameVersionChanged: {
// Сборка лоадера привязана к версии игры: под новой версией прежний
// выбор недействителен, и список перезапрашивается заново.
loaderRow.selectedVersion = "" loaderRow.selectedVersion = ""
loaderRow.reload() loaderRow.reload()
} }
+2 -18
View File
@@ -39,9 +39,6 @@ Window {
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000) onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex) onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
onGameOutput: (line) => console.log(line) onGameOutput: (line) => console.log(line)
onGameDirImported: (buildName) => {
window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000)
}
onSeasonalInstallFinished: (seasonalId, buildName) => { onSeasonalInstallFinished: (seasonalId, buildName) => {
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName), window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
"#4b7a1f", 8000) "#4b7a1f", 8000)
@@ -92,15 +89,6 @@ Window {
id: image id: image
anchors.fill: parent anchors.fill: parent
source: "images/launcherBackground.png" 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
}
} }
// ── Status toast ─────────────────────────────────────────────────────── // ── Status toast ───────────────────────────────────────────────────────
@@ -204,10 +192,6 @@ Window {
rotation: 0 rotation: 0
editable: false editable: false
hoverEnabled: true hoverEnabled: true
// Пока идёт загрузка или установка, профиль и сборку не меняем:
// единственное доступное действие — отмена на панели загрузки.
enabled: !backend.busy && !backend.gameRunning
opacity: enabled ? 1.0 : 0.45
model: backend.profileNames model: backend.profileNames
@@ -393,6 +377,8 @@ Window {
} }
// ── Активная сборка ──────────────────────────────────────────────────── // ── Активная сборка ────────────────────────────────────────────────────
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
// и её содержимое правятся теперь в модальном окне.
Button { Button {
id: buildButton id: buildButton
x: 624 x: 624
@@ -405,7 +391,6 @@ Window {
anchors.bottomMargin: -218 anchors.bottomMargin: -218
hoverEnabled: true hoverEnabled: true
enabled: !backend.gameRunning && !backend.switching enabled: !backend.gameRunning && !backend.switching
opacity: enabled ? 1.0 : 0.45
contentItem: Text { contentItem: Text {
text: backend.activeBuildName !== "" ? backend.activeBuildName text: backend.activeBuildName !== "" ? backend.activeBuildName
@@ -479,7 +464,6 @@ Window {
anchors.verticalCenter: folderButton.verticalCenter anchors.verticalCenter: folderButton.verticalCenter
anchors.leftMargin: 8 anchors.leftMargin: 8
hoverEnabled: true hoverEnabled: true
enabled: !backend.busy && !backend.gameRunning
background: Image { background: Image {
anchors.fill: parent anchors.fill: parent
+12
View File
@@ -4,12 +4,19 @@ import QtQuick
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtWebEngine import QtWebEngine
// Вход в аккаунт Microsoft — то же самое, что делает официальный лаунчер:
// страница входа показывается внутри приложения, а лаунчер ждёт, когда браузер
// уйдёт на redirect_uri с кодом авторизации в адресе.
Dialog { Dialog {
id: msLogin id: msLogin
required property var backend required property var backend
// Окно ничего не знает про тосты главного окна: о неудаче сообщает сигналом.
signal failed(string message) signal failed(string message)
// Код уже отдан backend — второй раз по тому же адресу не реагируем:
// WebEngineView успевает сообщить об urlChanged несколько раз.
property bool codeTaken: false property bool codeTaken: false
anchors.centerIn: parent anchors.centerIn: parent
@@ -25,6 +32,8 @@ Dialog {
open() open()
} }
// Разбор адреса живёт в C++: правила совпадения должны совпадать с теми,
// по которым сервис строит сам redirect_uri.
function handleUrl(url) { function handleUrl(url) {
if (codeTaken) if (codeTaken)
return return
@@ -67,6 +76,9 @@ Dialog {
} }
contentItem: Item { contentItem: Item {
// Профиль без storageName — значит без диска: куки живут только пока
// работает лаунчер и в общий браузер не попадают. За выбор аккаунта в
// пределах сессии отвечает prompt=select_account в адресе входа.
WebEngineProfilePrototype { WebEngineProfilePrototype {
id: msProfile id: msProfile
} }
+4
View File
@@ -1,12 +1,16 @@
import QtQuick import QtQuick
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
// Плашка хода долгой операции в левом нижнем углу: не задевает кнопку запуска,
// сообщение по центру и кнопки папки с настройками.
Rectangle { Rectangle {
id: panel id: panel
property string title: "" property string title: ""
property string status: "" property string status: ""
// -1 — итог ещё неизвестен, показываем «…» вместо процентов.
property double fraction: -1 property double fraction: -1
// Необязательная вторая строка: мегабайты у загрузки, путь у архивации.
property string detail: "" property string detail: ""
property bool cancellable: true property bool cancellable: true
+1 -32
View File
@@ -1,34 +1,3 @@
# BiLauncher # BiLauncher
Самописный простой лаунчер для Minecraft без рекламы и лишнего мусора. Самописный простой лаунчер для 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>
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
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
// Готовые сезонные сборки с сервера лаунчера: таблица со всем, что нужно
// решить, ставить её или нет, и одна кнопка, которая делает всё остальное —
// заводит сборку, ставит версию игры, модлоадер, Java и раскладывает файлы.
//
// Таблица собрана из Row фиксированных колонок, а не из TableView: в проекте
// нет ни одной модели QAbstractItemModel, а строки приходят готовыми
// QVariantMap — заводить ради семи колонок отдельную модель незачем.
Dialog { Dialog {
id: seasons id: seasons
required property var backend required property var backend
// Выбранная строка. Хранится по id, а не по индексу: список обновляется
// под руками, и индекс после обновления указывал бы на другую сборку.
property string selectedId: "" property string selectedId: ""
// Ширины колонок в одном месте: их повторяют и шапка, и делегат.
readonly property var columns: [ readonly property var columns: [
{ key: "name", title: Loc.t.common.header.name, width: 250, align: Text.AlignLeft }, { 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 }, { key: "minecraftVersion", title: Loc.t.common.header.version, width: 90, align: Text.AlignLeft },
@@ -22,9 +34,11 @@ Dialog {
padding: 0 padding: 0
width: 960 width: 960
height: 560 height: 560
// Закрывается и во время установки: отменить её можно только на панели // Пока идёт установка, окно закрывается только кнопкой: случайный щелчок
// загрузки главного окна, а модальное окно эту панель перекрывает. // мимо не должен спрятать единственную видимую отмену.
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: seasons.backend.seasonalInstalling
? Popup.NoAutoClose
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
background: Rectangle { background: Rectangle {
color: "#1e1e1e" color: "#1e1e1e"
@@ -33,6 +47,8 @@ Dialog {
border.width: 1 border.width: 1
} }
// Строки приходят из C++ уже отсортированными и сведёнными с локальными
// записями — здесь только показ.
readonly property var entries: seasons.backend.seasonalCatalog readonly property var entries: seasons.backend.seasonalCatalog
readonly property var selectedEntry: { readonly property var selectedEntry: {
@@ -63,6 +79,7 @@ Dialog {
return String(entry[key]) return String(entry[key])
} }
// Свежий кэш отвечает без сети, поэтому дёргаем при каждом открытии.
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false) onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
header: Item { header: Item {
@@ -84,6 +101,7 @@ Dialog {
contentItem: Item { contentItem: Item {
// ── Шапка таблицы ───────────────────────────────────────────────
Row { Row {
id: tableHeader id: tableHeader
anchors.top: parent.top anchors.top: parent.top
@@ -121,6 +139,9 @@ Dialog {
color: "#333333" color: "#333333"
} }
// ── Сообщение вместо таблицы ────────────────────────────────────
// Пустой список и ошибка выглядят одинаково пустыми, поэтому причина
// показывается прямо на месте строк.
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - 80 width: parent.width - 80
@@ -136,6 +157,7 @@ Dialog {
: seasons.backend.seasonalCatalogError) : seasons.backend.seasonalCatalogError)
} }
// ── Строки ──────────────────────────────────────────────────────
ListView { ListView {
id: buildList id: buildList
anchors.top: headerLine.bottom anchors.top: headerLine.bottom
@@ -221,6 +243,8 @@ Dialog {
color: "#333333" color: "#333333"
} }
// Описание и размер выбранной сборки: они длинные и в таблицу не
// помещаются, а решение принимается по ним.
Column { Column {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 18 anchors.leftMargin: 18
@@ -290,6 +314,8 @@ Dialog {
id: installButton id: installButton
width: 150 width: 150
height: 36 height: 36
// Установка занимает и панель загрузки, и .minecraft целиком:
// пока лаунчер занят чем угодно, вторую начинать нельзя.
enabled: seasons.selectedEntry !== null && !seasons.backend.busy enabled: seasons.selectedEntry !== null && !seasons.backend.busy
contentItem: Text { contentItem: Text {
text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable
+22
View File
@@ -4,16 +4,25 @@ import QtQuick
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Layouts 2.15 import QtQuick.Layouts 2.15
// Выбор версии Minecraft отдельным окном: слева категории, справа сами версии
// с поиском сверху.
//
// Раньше это был выпадающий список на всю тысячу версий: найти в нём бету 1.7
// можно было только поиском по точному номеру. Категории делят список на
// обозримые части, а поиск работает внутри выбранной.
Dialog { Dialog {
id: picker id: picker
required property var backend required property var backend
// Версия, с которой окно открылось: по «Отмене» выбор к ней и возвращается.
property string selectedId: "" property string selectedId: ""
property string category: "release" property string category: "release"
property string filterText: "" property string filterText: ""
property bool installedOnly: false property bool installedOnly: false
// Выбранная версия подтверждена кнопкой или двойным щелчком. Имя не
// accepted(): такой сигнал у Dialog уже есть, и переопределить его нельзя.
signal versionChosen(string versionId) signal versionChosen(string versionId)
modal: true modal: true
@@ -37,6 +46,8 @@ Dialog {
{ key: "other", title: Loc.t.version.filter.other } { key: "other", title: Loc.t.version.filter.other }
] ]
// Каталог приходит из C++ уже отсортированным (новые сверху), поэтому здесь
// только отбор — порядок не трогаем.
readonly property var visibleEntries: { readonly property var visibleEntries: {
const query = picker.filterText.trim().toLowerCase() const query = picker.filterText.trim().toLowerCase()
const source = picker.backend.versionCatalog const source = picker.backend.versionCatalog
@@ -58,6 +69,7 @@ Dialog {
picker.selectedId = versionId picker.selectedId = versionId
picker.filterText = "" picker.filterText = ""
searchField.text = "" searchField.text = ""
// Открываемся на категории текущей версии, а не всегда на релизах.
picker.category = picker.categoryOf(versionId) picker.category = picker.categoryOf(versionId)
picker.backend.refreshVersionCatalog() picker.backend.refreshVersionCatalog()
picker.open() picker.open()
@@ -105,6 +117,9 @@ Dialog {
return false return false
} }
// Спрашиваем до удаления: версию придётся качать заново, а профили
// модлоадеров поверх неё — переустанавливать. Id запоминаем здесь:
// к моменту ответа строка под курсором может быть уже другой.
function askRemove(versionId) { function askRemove(versionId) {
const info = picker.backend.versionRemovalInfo(versionId) const info = picker.backend.versionRemovalInfo(versionId)
if (!info || info.installed !== true) if (!info || info.installed !== true)
@@ -118,6 +133,8 @@ Dialog {
function performRemove(versionId) { function performRemove(versionId) {
picker.backend.removeVersion(versionId) picker.backend.removeVersion(versionId)
// Профиль модлоадера был в каталоге только потому, что установлен:
// после удаления строка исчезает совсем, и выбор надо снять.
if (picker.selectedId === versionId && !picker.catalogHas(versionId)) if (picker.selectedId === versionId && !picker.catalogHas(versionId))
picker.selectedId = "" picker.selectedId = ""
picker.revealSelected() picker.revealSelected()
@@ -288,6 +305,7 @@ Dialog {
contentItem: RowLayout { contentItem: RowLayout {
spacing: 0 spacing: 0
// ── Категории ───────────────────────────────────────────────────
Rectangle { Rectangle {
Layout.preferredWidth: 150 Layout.preferredWidth: 150
Layout.fillHeight: true Layout.fillHeight: true
@@ -340,6 +358,7 @@ Dialog {
color: "#333333" color: "#333333"
} }
// ── Версии ──────────────────────────────────────────────────────
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -474,6 +493,8 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
// Версия весит от десятков мегабайт, а галочка рядом —
// единственное место, где видно, что именно скачано.
Image { Image {
visible: versionRow.modelData.installed visible: versionRow.modelData.installed
source: "images/Trash.svg" source: "images/Trash.svg"
@@ -510,6 +531,7 @@ Dialog {
} }
} }
// Пустая категория, ничего не нашлось или манифест ещё не пришёл.
Text { Text {
anchors.centerIn: versionList anchors.centerIn: versionList
width: versionList.width - 20 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 на месте."
BIN
View File
Binary file not shown.
-192
View File
@@ -1,192 +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-проект.
Версия приложения задаётся в начале `CMakeLists.txt` двумя значениями:
- `project(BiLauncher VERSION x.y.z)` — только числа. Отсюда `PROJECT_VERSION` идёт в ресурс версии
`.exe`, в `Info.plist` и в установщик: все три принимают только числовую версию.
- `LAUNCHER_VERSION_SUFFIX` — метка пре-релиза, например `-dev` или `-rc1`. Для релиза пустая.
Полная строка `LAUNCHER_VERSION` = `PROJECT_VERSION` + суффикс (`1.0.1-dev`) уходит дефайном в код:
в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в подпись «Версия: …» в левом
верхнем углу окна. Почему так, см. [ADR-0002](adr/0002-launcher-version-display.md).
## Что собирается
**`BiLauncher`** — исполняемый файл и QML-модуль с URI `BiLauncher`. В модуль входят все `.cpp`,
`.qml`, картинки из `images/`, `i18n/translations.json`, а также `CHANGELOG.md`, `LICENSE` и
`README.md`. Последние три сейчас никто из кода не читает, они просто лежат в ресурсах.
QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIRECTORY`). Если оставить по
умолчанию, на Linux папка `BiLauncher` совпадёт по имени с исполняемым файлом (у него нет
расширения), и линковка упадёт с «Is a directory».
**`launcher_zlib_reference`** — только на Linux. Это обычный zlib 1.3.1 из каталога `zlib/`,
выходной файл `libz.so.1` в `<build>/zlib/`. С ним лаунчер не линкуется. Библиотека нужна только
установщикам Forge и NeoForge на системах с zlib-ng, см. [zlibreference.h](cpp/zlibreference.md).
Исходники zlib — чужой код из upstream, документацией он не покрыт.
## Иконки
Все размеры собираются из одного файла `icons/master/icon_master.png` (1024×1024, рисунок до краёв
холста) скриптом `icons/generate_icons.sh`. Для него нужны ImageMagick и `iconutil`, то есть
запускается он на macOS. Правки вносятся в мастер, после чего скрипт перезапускается. Отдельные
размеры руками не правятся.
Системы по-разному добавляют поля вокруг иконки, поэтому один и тот же рисунок кладётся
по-разному:
- **macOS** сама поля не добавляет. По сетке Apple рисунок занимает 824 из 1024 точек, углы
скруглены радиусом 185.4. Результат — `icon.icns`, кладётся в `Contents/Resources` бандла.
- **Windows** ждёт картинку на весь холст. `icon.ico` содержит кадры 16–256, включая 20, 40 и 96
для масштабов 125/150/200%. Без явного списка ImageMagick положил бы в `.ico` только 1024×1024,
и система размывала бы иконку при уменьшении. Вкомпилирована через `icon.rc.in`, где заодно
ресурс версии.
- **Linux**: PNG 16–512 ставятся в тему `hicolor` под именем `bilauncher.png`, плюс
`bilauncher.desktop`. Окружения рабочего стола ищут иконку по имени из `Icon=`, а не по имени
файла.
Иконка окна задаётся в `main.cpp` из ресурса `:/icons/` (на всех системах, кроме macOS).
## Установка (`cmake --install`)
- исполняемый файл в `bin/` (на macOS — `BiLauncher.app`);
- `qt_generate_deploy_qml_app_script` раскладывает рядом Qt, плагины и QML-модули
(windeployqt/macdeployqt под капотом; на macOS с `-hardened-runtime`);
- `LICENSE`, `CHANGELOG.md`, `packaging/THIRD_PARTY_LICENSES.txt` в `share/doc/BiLauncher/` (кроме
macOS);
- на Linux ещё `libz.so.1` в `lib/BiLauncher/`, иконки и `.desktop`.
На Linux ставьте с `-DCMAKE_INSTALL_LIBDIR=lib`. Лаунчер ищет эталонный zlib в
`<bin>/../lib/BiLauncher`, а на Ubuntu по умолчанию `LIBDIR` был бы `lib/x86_64-linux-gnu`.
## Релизы
Релизы собирает Gitea Actions, `.gitea/workflows/release.yml`.
### Как выпустить версию
0. Запустить workflow `tests` вручную (Run workflow) и дождаться зелёного прогона на всех трёх
системах, включая дымовую проверку установленного дерева. `release.yml` от тестов не зависит,
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
3. Запустить релиз одним из способов:
- **Пуш в `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
`authservice.h`, `authservice.cpp` ## Обзор класса
Вход через Ely.by, офлайн-профиль и загрузка `authlib-injector`. Вход через Microsoft живёт `Minecraft_launcher` поддерживает три способа входа: офлайн-профиль без пароля, учётную запись
отдельно, в [MsaAuthService](MsaAuthService.md), но отдаёт результат в той же структуре Ely.by и учётную запись Microsoft. `AuthService` закрывает первые два: это Yggdrasil-клиент Ely.by
`AuthResult`. Поэтому запуск игры дальше идёт одинаково, как бы игрок ни вошёл. плюс офлайн-режим. За третий отвечает [MsaAuthService](MsaAuthService.md).
Экземпляр создаёт и держит [LauncherBackend](LauncherBackend.md). Сервис ничего не пишет на диск, Результат любого способа — структура `AuthResult`, объявленная в этом же заголовке. Она содержит
кроме скачанного jar: токены между запусками сохраняет бэкенд в `profiles.json`. ровно то, что подставляется в аргументы запуска вида `${auth_*}`, поэтому дальше запуск игры идёт
по общему пути независимо от того, как пользователь вошёл.
## AuthResult Класс также умеет скачивать `authlib-injector` — библиотеку, которая перенаправляет обращения игры
к серверу авторизации на Ely.by.
Каждое поле соответствует подстановке в аргументах запуска. ## Место в проекте и зависимости
| Поле | Подстановка / смысл | Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); других владельцев нет.
|------|---------------------| Заголовок подключает [MsaAuthService](MsaAuthService.md) — ради общей структуры `AuthResult` — и
| `ok` | вход удался | [GameLauncher](GameLauncher.md) через поля `LaunchOptions`, которые заполняются из результата
| `twoFactorRequired` | Ely.by требует одноразовый код | авторизации.
| `licenseMissing` | Microsoft: вход прошёл, но игры на аккаунте нет. Отдельный флаг, потому что повторный вход тут не поможет |
| `error` | текст ошибки, если `ok == false` |
| `playerName` | `${auth_player_name}` |
| `uuid` | `${auth_uuid}`, hex без дефисов |
| `accessToken` | `${auth_access_token}` |
| `clientToken` | `${clientid}` |
| `userType` | `${user_type}`: `legacy` (офлайн), `ELYBY`, `msa` |
| `refreshToken` | только Microsoft: продление сессии без пароля |
| `xuid` | только Microsoft: `${auth_xuid}` |
| `expiresAt` | только Microsoft: когда истекает `accessToken`, UTC |
## Офлайн Требования сборки: `Qt6::Core` (`QDateTime`, `QJsonObject`, `QString`) и `Qt6::Network`
(`QNetworkAccessManager`).
`static AuthResult offline(nickname)` работает без сети. UUID считается так же, как в самом ## Иерархия и роль
Minecraft: MD5 от `OfflinePlayer:<ник>`, после чего выставляются биты версии 3 и варианта RFC 4122.
Значит, у одного ника всегда один UUID, и на офлайн-сервере игрок не теряет инвентарь. В
`accessToken` и `clientToken` записывается `"0"`.
Пустой ник — ошибка. Наследует `QObject`: даёт мета-объектную систему, сигнал `progress` и владение по родителю.
Виртуальных методов базового класса не переопределяет.
## Ely.by ## Публичные структуры
Сервер — `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. | `ok` | `bool` | Авторизация удалась |
4. Если и это не вышло, `/auth/authenticate` с паролем. | `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 не сообщает отдельным кодом. Сервис ищет в тексте ошибки подстроку `AuthService::Callback` — `std::function<void(const AuthResult &)>`. Все сетевые методы
`two factor` и выставляет `twoFactorRequired`. Тогда бэкенд спрашивает код у игрока и вызывает асинхронные: колбэк вызывается ровно один раз и всегда в потоке GUI.
`loginElyByWithTotp(login, password, totp, clientToken, callback)`, который отправляет пароль в виде
`пароль:код`, как требует Ely.by.
`clientToken` генерируется один раз на профиль (`generateClientToken()`, случайный UUID) и хранится ## Публичные методы
вместе с ним: Yggdrasil привязывает выданный `accessToken` к этому значению.
По ходу работы испускается `progress(message)`. Бэкенд пересылает его в `launchProgress`. #### explicit AuthService(QObject \*parent = nullptr)
## authlib-injector Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
Без него игра пойдёт проверять сессию на серверы Mojang, и токен Ely.by окажется недействительным. #### static AuthResult offline(const QString &nickname)
Jar подключается к JVM через `-javaagent` (см. [GameLauncher](GameLauncher.md)).
`ensureAuthlibInjector(targetDir, callback)`: Готовит результат для офлайн-профиля без единого сетевого запроса. UUID выводится из ника так же,
как это делает сам Minecraft в офлайне: `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name)
.getBytes(UTF_8))`. Благодаря этому один и тот же ник всегда даёт один и тот же UUID, и прогресс
на сервере не теряется.
- если `<targetDir>/authlib-injector.jar` уже есть, сразу отдаёт путь. Версия не проверяется, и #### static QString generateClientToken()
обновить файл можно, только удалив его;
- иначе читает `https://authlib-injector.yushi.moe/artifact/latest.json`, скачивает jar по
`download_url` и, если в манифесте есть `checksums.sha256`, сверяет хеш.
Колбэк получает либо путь, либо текст ошибки. `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
`buildarchiveworker.h`, `buildarchiveworker.cpp` ## Обзор класса
Файловые операции над `.minecraft`: упаковать, очистить, распаковать, раскатать пак. В Смена сборки в лаунчере — это перекладывание содержимого `.minecraft`: текущее упаковывается в
`.minecraft` могут лежать гигабайты модов и миров, поэтому это единственная часть проекта, архив, папка вычищается, на её место распаковывается архив другой сборки. Речь о гигабайтах модов,
которая работает не в GUI-потоке. конфигов и миров, и гонять их в потоке GUI нельзя — окно замерзало бы на всё время смены.
Объект создаёт [BuildSwitcher](BuildSwitcher.md) без родителя, переносит в свой `QThread` и вызывает `BuildArchiveWorker` — исполнитель этих операций в отдельном потоке. Он умеет четыре вещи:
слоты через `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`. Работник не знает ни про упаковать, вычистить, распаковать и докатить пак сезонной сборки поверх уже разложенного
сборки, ни про журнал. Он получает пути и отвечает сигналами. содержимого.
ZIP читается и пишется через `QZipReader` и `QZipWriter` из `Qt6::CorePrivate`. Класс намеренно ничего не знает ни о сборках, ни о путях лаунчера: он принимает готовые пути и
списки. Порядок шагов и восстановление после сбоя — дело [BuildSwitcher](BuildSwitcher.md).
## Слоты ## Место в проекте и зависимости
Каждый слот заканчивается одним `finished(ok, error)`. По ходу работы испускается Создаётся и целиком управляется [BuildSwitcher](BuildSwitcher.md), который же и переносит его в
`progress(done, total, currentPath)` на каждый файл. собственный `QThread`. Больше к классу никто не обращается.
### archive(gameDir, tempZipPath, excludeTop) Требования сборки: `Qt6::Core` и `Qt6::CorePrivate` — последний нужен ради `QZipReader` и
`QZipWriter`, которыми читаются и пишутся архивы сборок.
Упаковывает всё содержимое `gameDir`, кроме папок и файлов верхнего уровня из `excludeTop`. ## Иерархия и роль
Символьные ссылки пропускаются. Сжатие `AutoCompress`: Qt сам не сжимает то, что уже сжато.
При ошибке или отмене недописанный архив удаляется.
### clear(gameDir, keepTop) Наследует `QObject`: мета-объектная система, слоты, сигналы и возможность жить в отдельном потоке
через `moveToThread()`. Виртуальных методов базового класса не переопределяет.
Удаляет всё на верхнем уровне `gameDir`, кроме `keepTop`. Символьная ссылка удаляется как файл, Объект живёт в своём `QThread` и вызывается только через очередь сигналов — прямых вызовов его
то, на что она указывает, не трогается. Если `gameDir` нет, это успех. Отменить нельзя. слотов из потока GUI быть не должно.
### restore(zipPath, gameDir) ## Публичные методы
Распаковывает архив в `gameDir`. Пути с `..`, абсолютные, с `\` или `:` останавливают распаковку. #### explicit BuildArchiveWorker(QObject \*parent = nullptr)
### applyPack(zipPath, gameDir, removeRelative, forbiddenTop) Создаёт исполнителя. Конструктор помечен `explicit`.
1. Сначала проверяет весь архив. Небезопасный путь или файл внутри общих папок (`forbiddenTop`: #### void requestCancel()
`versions`, `libraries` и т. д.) — отказ до любых изменений. Пак не может подменить jar игры
или библиотеки.
2. Удаляет файлы из `removeRelative`. Опасные пути и пути в общих папках молча пропускаются. После
этого удаляются папки, которые стали пустыми.
3. Распаковывает пак, перезаписывая существующие файлы.
4. Испускает `packEntries(список файлов пака)`, потом `finished(true)`.
## Отмена Просит прервать текущую операцию. Вызывается из потока GUI прямо во время работы — это
единственный метод класса, предназначенный для вызова снаружи рабочего потока.
`requestCancel()` и `clearCancel()` выставляют атомарный флаг, его можно трогать из любого потока. Отмена не прерывает операцию мгновенно: признак проверяется между файлами. Хранится он в
Флаг проверяется перед каждым файлом в `archive`, `restore` и `applyPack`. `QAtomicInt`, поэтому запись из одного потока и чтение из другого безопасны без блокировок.
Файлы распаковываются через `QFile`, а не `QSaveFile`. Если операцию прервать, последний файл #### void clearCancel()
может остаться недописанным. Поэтому `BuildSwitcher` и не даёт отменить шаги после архивации.
Сбрасывает признак отмены перед началом новой операции.
## Публичные слоты
Все четыре слота вызываются через очередь сигналов и выполняются в рабочем потоке. Каждый
завершается сигналом `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
`buildswitcher.h`, `buildswitcher.cpp` ## Обзор класса
Смена активной сборки и раскатка пака сезонной сборки. Файлы двигает `BuildSwitcher` меняет активную сборку: содержимое `.minecraft` уезжает в архив своей сборки,
[BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке, а этот класс решает, в каком порядке папка чистится, на её место разворачивается архив выбранной. Он же докатывает пак сезонной
это делать, и ведёт журнал, по которому можно продолжить после падения. сборки поверх уже разложенного содержимого.
Экземпляр держит [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` Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
- `libraries` (`buildStorageDir()`, `buildDir()`).
- `versions`
- `launcher_profiles.json`
- `runtime`
Всё остальное (моды, конфиги, миры, скриншоты, `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()` и #### QString stage() const
`lastPackNote()` содержат список разложенных файлов и `note`. Бэкенд забирает их в обработчике
`finished` и пишет `season.json`.
`gameDirIntact` при ошибке раскатки равен `true`, только если работник не успел обработать ни одного Текущий этап словами — заголовок панели прогресса.
файла.
## После падения #### QString status() const
При старте бэкенд вызывает `interruptedSwitchWarning()`. Если в `index.json` осталось Строка состояния: что обрабатывается сейчас.
`switching` или `applying`, метод возвращает текст для пользователя, а `resumeInterrupted(gameDir)`
продолжает операцию:
- `switching` — архив прежней сборки уже записан целиком, поэтому начинаем с очистки и #### double fraction() const
распаковываем сборку `to`. Начинать с архивации нельзя: заархивировалась бы полуразобранная
`.minecraft`;
- `applying` — раскатка повторяется с тем же паком и тем же списком на удаление. Если файла пака
уже нет, журнал просто сбрасывается в `idle`.
Если программа упала во время самой архивации, в журнале ещё ничего нет, и продолжать нечего: Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.
`.minecraft` цела, а временный архив удалится при следующем старте (`sweepTempArchives()` в
конструкторе).
## Прочее #### static QStringList sharedTopLevel()
- `forgetBuild(id)` — удаляет запись о сборке из `index.json` и папку `builds/<id>` целиком. Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
- `hasArchive(id)` — есть ли у сборки архив. лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
- `isRunning()`, `stage()`, `status()` (текущий файл), `fraction()` — для панели прогресса. как `keepTop` при очистке.
Сигнал `progressChanged()` не ограничен по частоте и приходит на каждый обработанный файл.
### index.json #### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)
`<root>/builds/index.json`: Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
сборки.
```json Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.
{
"state": "idle", #### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject &note, const QString &gameDir)
"active": 3,
"builds": { Докатывает пак сезонной сборки поверх содержимого `.minecraft`. Вызывается только для уже активной
"2": { "archive": "2026-10-09_Vanilla.zip", "name": "Vanilla", "savedAt": "..." } сборки: пак ложится на то, что сейчас разложено, а не внутрь чужого архива.
}
Параметр `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
`gamelauncher.h`, `gamelauncher.cpp` ## Обзор класса
Собирает командную строку, распаковывает нативные библиотеки и запускает JVM с игрой. К этому `GameLauncher` — то, ради чего существует весь остальной лаунчер: он готовит и запускает JVM с
моменту версия уже разобрана ([minecraftversion.h](minecraftversion.md)), файлы скачаны, а игрок Minecraft. К моменту его вызова уже известно всё — версия разобрана, файлы скачаны, пользователь
вошёл в аккаунт. Всё остальное приходит в структуре `LaunchOptions`. авторизован, — и класс превращает это в командную строку и дочерний процесс.
Экземпляр один, его держит [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. Если игра уже идёт, отказ. Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md), который заполняет
2. `missingFiles()`: если чего-то не хватает, отказ со списком. `LaunchOptions` из настроек, выбранной сборки и результата авторизации.
3. Распаковка нативных библиотек в `<gameDir>/versions/<id>/natives`.
4. `JavaLocator::select()` с `version.javaMajor` и `options.javaPath`. На Windows вместо
`java.exe` запускается `javaw.exe`, чтобы не было консольного окна.
5. `QProcess` с рабочим каталогом `gameDir` и объединёнными stdout и stderr.
6. Ожидание старта до 15 секунд. Если процесс не стартовал, отказ.
Если всё прошло, метод возвращает `true` и испускает `gameStarted(commandLine)`. В этой строке Требования сборки: `Qt6::Core` (`QProcess`, `QStringList`) и `Qt6::CorePrivate` — последний нужен
значение после `--accessToken` заменено на `<скрыт>`, чтобы токен не попал в журнал. Других ради `QZipReader`, которым распаковываются нативные библиотеки LWJGL. Зависимость от приватного
мест, где мог бы оказаться токен (например, в `${auth_session}`), замена не касается. модуля привязывает проект к конкретной версии Qt; в `CMakeLists.txt` это осознанный выбор, и
предупреждение о нём отключено.
Перед шагами 2–4 испускается `progress(message)`. ## Иерархия и роль
Вывод игры читается кусками и испускается построчно в `output(line)`. Когда процесс завершится, Наследует `QObject`: мета-объектная система, четыре сигнала и владение по родителю. Виртуальных
придёт `gameFinished(exitCode, crashed)`, где `crashed` значит `QProcess::CrashExit`. методов базового класса не переопределяет.
`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` (отсюда зависимость от #### explicit GameLauncher(QObject \*parent = nullptr)
`Qt6::CorePrivate`) и вынимает файлы `.dll`, `.so`, `.so.N`, `.dylib`, `.jnilib`. Пропускаются
`META-INF/` и пути из `extract.exclude` описания версии. Все файлы кладутся в одну плоскую папку.
Если файл такого же размера уже лежит, его не переписывают.
## 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` #### static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12)
(`token:<токен>:<uuid>`), `auth_xuid` (пусто → `0`), `clientid` (пусто → `0`), `user_type`,
`user_properties` (`{}`), `version_name`, `version_type`, `game_directory`, `assets_root`,
`assets_index_name`, `game_assets`, `natives_directory`, `library_directory`, `classpath`,
`classpath_separator`, `primary_jar`, `launcher_name`, `launcher_version`, `resolution_width`,
`resolution_height`.
Аргумент, в котором после подстановки остался `${`, выбрасывается целиком. Так отсекаются Проверяет `.minecraft` на комплектность и возвращает описания недостающих файлов. Пустой список
переменные, о которых лаунчер не знает (например, связанные с демо-режимом). означает, что всё на месте и сборку можно запускать.
У версий со старыми ресурсами (`legacy`, `pre-1.6`) `game_assets` указывает на Параметр `limit` ограничивает длину списка: перечислять все отсутствующие файлы у неустановленной
`assets/virtual/legacy`. версии бессмысленно, важен сам факт и пара примеров.
Classpath — это все не-нативные библиотеки и в конце клиентский jar. Метод статический, ничего не меняет и вызывается интерфейсом для строки состояния сборки.
### Forge 1.17+ и `-DignoreList` #### static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir)
Forge собирает слой модулей из classpath и пропускает файлы, имя которых начинается с записи в Собирает аргументы ровно в том порядке, в котором их ждёт JVM: сначала аргументы JVM, затем главный
`-DignoreList`. В профиле Forge там записан `${version_name}.jar`. Официальный лаунчер кладёт в класс, затем аргументы игры. Подстановки вида `${...}` из версии заменяются значениями из
classpath jar самого профиля, а у нас своего jar у профиля нет, и в classpath попадает параметров запуска.
родительский, например `1.20.1.jar`. Если его имени нет в списке, он становится вторым модулем с
классами Minecraft, и игра падает с ошибкой вида `Modules minecraft and _1._20._1 export package Метод статический и не имеет побочных эффектов, поэтому годится и для показа собранной командной
...`. Поэтому к `-DignoreList` дописывается имя клиентского jar, если его там ещё нет. строки без запуска.
#### 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
`javainstaller.h`, `javainstaller.cpp` ## Обзор класса
Ставит одну сборку Java из каталога [JavaRuntimeService](JavaRuntimeService.md) в `JavaInstaller` ставит сборку Java в `<root>/java/<id>`. Как и
`<root>/java/<id>/`. Сборки Temurin приходят архивом, сборки Mojang — деревом отдельных файлов, [ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути.
поэтому путей установки два.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса такие же, как у других **Temurin** отдаёт один архив: лаунчер качает его, сверяет sha256 и распаковывает. **Mojang**
установщиков: `label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`, плюс отдаёт манифест с деревом файлов: лаунчер качает файлы по отдельности, как это делает официальный
`runtimeId`. Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс), лаунчер.
`finished(runtimeId, javaPath)`, `failed(label, message)`, `canceled(label)`.
## install(entry) Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md): панель загрузки в
интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
Если установка уже идёт, вызов молча игнорируется. Иначе: ## Место в проекте и зависимости
1. проверяется папка лаунчера; Подключает [javaruntime.h](javaruntime.md): на вход установщик принимает запись каталога
2. **папка `<root>/java/<id>` удаляется целиком**, если она была, и создаётся заново. Повторная `JavaRuntimeEntry`, а результат записывает через `JavaRuntimeStore`.
установка — это всегда установка с нуля;
3. дальше одна из двух веток.
### Temurin (`zip`, `tar.gz`) Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят
от [JavaRuntimeService](JavaRuntimeService.md).
1. Архив качается в `<root>/cache/java/<имя файла>` через `QSaveFile`, sha256 считается на ходу и Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`),
сверяется с каталогом. `Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`.
2. `zip` распаковывается через `QZipReader` по 24 записи за такт таймера, чтобы окно не
подвисало. Права из архива переносятся. Записи, которые ведут за пределы папки сборки,
останавливают установку.
3. `tar.gz` распаковывается системным `tar -xzf`. Он есть на Linux, macOS и Windows 10 и новее.
`QZipReader` tar не умеет, а держать ради этого ещё одну библиотеку незачем.
4. Архив удаляется.
Прогресс при загрузке считается в байтах, при распаковке 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 ему ставится бит исполнения. Один файл рантайма Mojang.
`runtime.json` записывается последним: пока его нет, `JavaRuntimeStore` сборку не видит. Поэтому
недокачанная сборка в списке не появится, даже если лаунчер упал на середине.
При ошибке и при отмене папка сборки и скачанный архив удаляются. | Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `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
`javaruntimeservice.h`, `javaruntimeservice.cpp` ## Обзор класса
Каталог сборок Java, которые лаунчер может скачать. Устроен как `JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в
[VersionManifestService](VersionManifestService.md), но кэш хранится сутки и лежит в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как
`<root>/cache/java_runtimes.json`. [VersionManifestService](VersionManifestService.md).
Источников два: Источников два, и они дополняют друг друга. **Mojang** (категория «Java») — ровно тот рантайм,
которым запускает игру официальный лаунчер: версий немного, зато они заведомо совместимы.
**Eclipse Temurin** (категории JDK и JRE) — свежие сборки всех мажорных версий, включая те, до
которых Mojang ещё не дошёл.
- **Mojang** (в интерфейсе категория «Java») — те же рантаймы, с которыми игру запускает Отдаются только сборки под текущие операционную систему и архитектуру: выбрать заведомо
официальный лаунчер. Их немного, зато с игрой они точно совместимы. неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 сборок `aarch64` нет
- **Eclipse Temurin** (JDK и JRE) — свежие сборки всех мажорных версий, в том числе тех, которых у вовсе и приходится брать `x64`, который работает через Rosetta.
Mojang ещё нет.
В каталог попадают только сборки под текущую ОС и архитектуру. ## Место в проекте и зависимости
Экземпляр держит [LauncherBackend](LauncherBackend.md), ставит сборки Подключает [javaruntime.h](javaruntime.md): перечисление `JavaRuntimeKind` и структура
[JavaInstaller](JavaInstaller.md). `JavaRuntimeEntry` приходят оттуда.
## Как обновляется каталог Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записями каталога
пользуется [JavaInstaller](JavaInstaller.md).
Запросы идут параллельно. Каталог заменяется целиком, когда ответят все. Путь к файлу кэша даёт `LauncherPaths::javaCatalogFile()` из [launcherpaths.h](launcherpaths.md).
1. `api.adoptium.net/v3/info/available_releases` — какие мажорные версии есть и какие из них LTS. Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
2. Для каждой мажорной версии `assets/feature_releases/<major>/ga` с фильтрами: ОС, архитектура, (`QNetworkAccessManager`).
HotSpot, обычная куча, vendor `eclipse`, три последних релиза. Из каждого берутся бинарники с
`image_type` = `jdk` или `jre` в виде `.zip` или `.tar.gz` по `https`.
3. `piston-meta.mojang.com/.../java-runtime/.../all.json` — раздел под текущую платформу
(`windows-x64`, `mac-os-arm64`, `linux` и т. п.), по одному варианту на компонент. Компонент
`minecraft-java-exe` пропускается: это не Java.
**Нет сборки под aarch64.** У Temurin под ARM нет Java 8 и 16. Если для aarch64 ничего не нашлось, ## Иерархия и роль
запрос повторяется для `x64`. На Mac с Apple Silicon такая сборка работает через Rosetta. Сейчас
повтор делается на любой ОС с aarch64, хотя комментарий в заголовке говорит только про macOS.
Id строк: `temurin-<jdk|jre>-<версия>-<арх>` и `mojang-<компонент>`, пропущенные через Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
`JavaRuntimeStore::sanitizeId()`. методов базового класса не переопределяет.
Версия 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)` — как у манифеста версий. #### void ensureLoaded(Callback callback, bool forceRefresh = false)
- `entries(kind)` — строки одного вида, отсортированы по мажорной версии, потом по дате выхода,
новые сверху.
- `find(id)`.
- `bestFor(major)` — что скачать, если игре нужна Java `major`, а подходящей нет. Выбирается
наименьшая мажорная версия не ниже `major`. При равенстве порядок такой: Temurin JDK, потом
Mojang, потом Temurin JRE. JDK идёт первым, потому что установщикам Forge и NeoForge иногда нужны
инструменты из полного комплекта. Если и здесь равенство, берётся более свежая.
- `isRefreshing()`, `hasData()`.
Сигналы: `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
`launcherbackend.h`, `launcherbackend.cpp` ## Обзор класса
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает `LauncherBackend` — единственный класс проекта, видимый из QML, и центр всего приложения. Интерфейс
методы и слушает сигналы, а к сервисам напрямую не обращается. лаунчера ничего не знает ни о сети, ни о файлах, ни о процессах: он читает свойства этого класса,
вызывает его методы и слушает его сигналы.
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки, Сам бэкенд почти ничего не делает руками. Он владеет двенадцатью сервисами — авторизация, запуск
создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время игры, каталоги версий, модлоадеров, Java и сезонных сборок, три установщика, загрузчик паков и
смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера. переключатель сборок — и отвечает за то, чтобы они работали в правильном порядке. Кроме того, он
Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать. хранит состояние лаунчера: профили игрока, пользовательские сборки и настройки запуска, которые
читает и пишет в файлы папки лаунчера.
Экземпляр один, его объявляет [Main.qml](../qml/Main.md). Во вложенные окна он передаётся через Ещё одна его задача — приводить данные к виду, удобному QML. Каталоги отдаются в интерфейс уже
`required property var backend`. Тип зарегистрирован через `QML_ELEMENT` в модуле `BiLauncher`; сведёнными с локальным состоянием: строка версии знает, скачана ли она, строка сезонной сборки —
синглтоном он не сделан. установлена ли и не устарела ли. Окно показывает статус, не считая ничего само.
## Сервисы ## Место в проекте и зависимости
Все создаются в конструкторе с бэкендом в роли родителя. Единственный экземпляр создаётся декларативно в [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_msa` | [MsaAuthService](MsaAuthService.md) | вход через Microsoft |
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM | | `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM с игрой |
| `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang | | `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang |
| `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры | | `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры |
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | версии модлоадеров | | `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | списки версий модлоадеров |
| `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера | | `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера |
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки, раскатка пака | | `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки |
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог Java | | `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог сборок Java |
| `m_javaInstaller` | [JavaInstaller](JavaInstaller.md) | установка Java | | `m_javaInstaller` | [JavaInstaller](JavaInstaller.md) | установка Java |
| `m_seasonalMeta` | [SeasonalBuildService](SeasonalBuildService.md) | каталог сезонных сборок | | `m_seasonalMeta` | [SeasonalBuildService](SeasonalBuildService.md) | каталог сезонных сборок |
| `m_packDownloader` | [SeasonalPackDownloader](SeasonalPackDownloader.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()` создаёт папку лаунчера и проверяет, что в неё можно писать. Наследует `QObject`: мета-объектная система, свойства, сигналы и владение по родителю. Объявлен
2. `loadSettings()` заполняет настройки значениями по умолчанию и накладывает сверху виртуальный деструктор. Виртуальных методов базового класса не переопределяет.
`settings.json`.
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
4. `loadData()` читает профили и сборки и сортирует их по `id`.
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
(см. ниже).
6. `ensureDataFiles()` создаёт недостающие файлы.
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
8. Подписка на сигналы сервисов.
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок), ## Свойства Q_PROPERTY
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
моменту QML уже подписан на сигнал.
### Сборка из папки игры ### Профили и сборки
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни | Свойство | Тип | READ | WRITE | NOTIFY | Описание |
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а |----------|-----|------|-------|--------|----------|
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение | `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`. | Свойство | Тип | READ | WRITE | NOTIFY | Описание |
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках |----------|-----|------|-------|--------|----------|
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку. | `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` — объект с настройками; ### Каталог Java
- `profiles.json` — массив профилей;
- `customBuilds.json` — массив сборок.
Если файла нет, это первый запуск. Если он не читается как JSON или в нём не тот тип (объект вместо | Свойство | Тип | READ | WRITE | NOTIFY | Описание |
массива и наоборот), файл переименовывается в `<имя>.bak`, а на его месте создаётся пустой. |----------|-----|------|-------|--------|----------|
Данные из `.bak` можно потом достать руками. | `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`.
### Настройки ### Настройки
| Ключ | По умолчанию | Что это | #### QVariantMap settings() const
|------|--------------|---------|
| `gameDir` | пусто | папка игры; пусто — `LauncherPaths::defaultMinecraftDir()` |
| `javaPath` | пусто | путь к java, заданный руками |
| `javaRuntime` | пусто | id сборки Java из `<root>/java`; важнее `javaPath` |
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx` |
| `jvmArgs` | пусто | дополнительные аргументы JVM одной строкой, делятся по пробелам |
| `windowWidth`, `windowHeight` | 0, 0 | размер окна игры; 0 — не передавать |
| `fullscreen` | `false` | |
| `language` | `system` | язык интерфейса, см. [Localization](Localization.md) |
| `activeBuildId` | 0 | `id` активной сборки |
| `seasonalBaseUrl` | `https://launcher-builds.galeonworx.com` | сервер сезонных сборок |
`seasonalBaseUrl` в интерфейсе нет. Он нужен, чтобы проверять установку против локального Настройки запуска одной картой: `gameDir`, `javaPath`, `javaRuntime`, `minMemoryMb`,
HTTP-сервера. Хвостовые слэши отрезаются. `maxMemoryMb`, `jvmArgs`, `windowWidth`, `windowHeight`, `fullscreen`, `language` и вычисленный
`resolvedGameDir`.
Имя и версия лаунчера для `${launcher_name}` и `${launcher_version}` в настройках не хранятся: они #### void updateSettings(const QVariantMap &values)
берутся из `QCoreApplication`, то есть из `project(... VERSION)` в CMake. Ключи `launcherName` и
`launcherVersion`, оставшиеся в старых `settings.json`, при загрузке выбрасываются и пропадают из
файла при следующем сохранении.
`settings()` возвращает всю карту и добавляет к ней вычисленный `resolvedGameDir`. Записывает настройки и сохраняет их на диск. Испускает `settingsChanged`.
`updateSettings()` вливает присланные ключи, сохраняет файл и испускает `settingsChanged`. Если
сменился язык, после записи вызывается `Localization::setLanguage()`: так выбор сохранится, даже если
лаунчер упадёт сразу после переключения. Если сменилась папка игры, перестраивается каталог версий.
## Профили Смену `language` после записи пробрасывает в [Localization](Localization.md) — порядок
«сохранили → переключили» гарантирует, что выбранный язык переживёт падение сразу после
переключения. Сам `Localization` в `settings.json` не пишет: файл ведёт только бэкенд.
Профиль хранит имя, логин, пароль, тип входа (`offline`, `elyby`, `microsoft`; всё прочее читается #### QStringList detectedJava() const
как `offline`) и полученные при входе данные: токены, UUID, ник с сервера, `xuid`, срок жизни токена.
- `addProfile(name, login, password, authType)` — добавить. Пути ко всем java, найденным в системе. Показывается справочной строкой в диалоге настроек.
- `updateProfile(index, ...)` — перезаписать. Если у профиля Ely.by поменялись логин или пароль,
сохранённый токен сбрасывается. Если поменялся тип входа, сбрасывается всё, что было получено
при входе. Переименование профиля Microsoft сессию не трогает.
- `profileAt(index)` — `name`, `login`, `password`, `authType`, `resolvedName` и
`hasMicrosoftSession` (есть ли refresh-токен).
- `removeProfile(index)` — удалить.
Все испускают `profilesChanged`. Свойство `profileNames` отдаёт имена в порядке `id`. ### Сборки Java
## Сборки #### void refreshJavaCatalog(bool force = false)
Сборка — это имя, адрес сервера для автоподключения, версия Minecraft и, если выбран, модлоадер с Обновляет каталог сборок Java. Вызывается при открытии окна выбора: свежий кэш отвечает без сети.
версией. После установки модлоадера в `resolvedVersionId` записывается id профиля в `versions/`,
который на самом деле запускается. У сезонных сборок есть ещё `seasonalId` и `seasonalRevision`.
Какую папку из `versions/` запускать, решает `launchVersionIdOf()`: сначала `resolvedVersionId`, #### void installJavaRuntime(const QString &runtimeId)
потом `minecraftVersion`, а если и её нет, то имя сборки. Последний вариант остался от старых
записей, у которых версия была записана в имени.
- `addCustomBuild(name, serverUrl, minecraftVersion)` — создать и сразу начать ставить версию, если Скачивает и распаковывает сборку Java. По завершении испускается `javaRuntimeInstalled`.
она указана и её нет на диске. Если версия пустая (так создаёт сборку кнопка «+ Новая сборка»),
ничего не ставится.
- `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)` — список того, чего не хватает для запуска; пустой список значит, что
всё на месте.
### Активная сборка #### void removeJavaRuntime(const QString &runtimeId)
`activeBuildIndex` хранится в настройках как `activeBuildId`, а не как индекс. Если этот `id` Удаляет скачанную сборку Java из папки лаунчера.
пропал, активной считается первая сборка.
Запись в свойство запускает `BuildSwitcher::switchTo()`: содержимое `.minecraft` уезжает в архив #### QVariantMap javaRuntimeInfo(const QString &runtimeId) const
прежней сборки, на его место распаковывается архив новой. Смена отклоняется, если уже идёт другая
смена, запущена игра или работает любой установщик. `activeBuildId` меняется только в обработчике
`BuildSwitcher::finished`, поэтому после неудачи активной остаётся прежняя сборка.
Пока идёт смена, выставлено `switching`, а ход виден в `switchProgress` (от 0 до 1, или −1, пока Описание установленной сборки: подпись, версия, путь к java и признак установки. Пустая карта
объём неизвестен), `switchStage` и `switchStatus`. означает, что сборки с таким идентификатором в папке лаунчера нет.
## Версии игры Метод не является привязкой и сам не пересчитывается, когда сборка докачается, — диалог настроек
обновляет его по сигналу `javaRuntimeInstalled`.
- `versionCatalog` — плоский список строк `{id, type, category, label, installed, search}` в порядке #### int requiredJavaMajor(int buildIndex) const
манифеста, новые сверху. `category` — `release`, `snapshot`, `old_beta`, `old_alpha` или `other`.
В конец добавлены установленные версии, которых нет в манифесте (профили модлоадеров), по
алфавиту, с категорией `other`. Разделы и поиск делает
[VersionPickerDialog](../qml/VersionPickerDialog.md).
- `catalogLoading` — идёт запрос манифеста.
- `refreshVersionCatalog(force)` — сбросить кэш строк и попросить манифест. Свежий кэш манифеста
отвечает без сети.
- `installedVersions`, `isVersionInstalled(id)` — что лежит в `versions/`.
- `installVersion(id)` — поставить; если эта версия уже ставится или стоит в очереди, ничего не
делает.
- `versionRemovalInfo(id)` — `id`, `installed`, `sizeMb`, `dependents` (профили модлоадеров поверх
этой версии), `builds` (имена сборок, которые на неё ссылаются).
- `removeVersion(id)` — удалить папку версии. Библиотеки и ресурсы общие, их не трогает. Пока
лаунчер занят или запущена игра, отказывает. Сборки, ссылающиеся на версию, не меняются:
ссылка на неустановленную версию — нормальное состояние.
Список строк каталога строится лениво и кэшируется: QML читает свойство много раз за кадр, пока Минимальная мажорная версия Java для версии игры выбранной сборки; `0` — версия не выбрана.
открыт список. То же сделано для каталогов Java и сезонных сборок. Передаётся в окно выбора Java, чтобы пометить слишком старые сборки.
## Модлоадеры ### Сезонные сборки
- `loaderVersions(loaderKey, gameVersion)` — список `{version, label, recommended, stable}` для этой #### void refreshSeasonalCatalog(bool force = false)
версии игры, новые сверху. Несовместимых строк в нём не бывает. Пустой список означает, что
модлоадер эту версию не поддерживает.
- `refreshLoaderVersions(loaderKey, gameVersion, force)` — обновить список. По окончании всегда
приходит `loaderVersionsChanged(loaderKey, gameVersion)`, даже если ответил кэш. Обработчик должен
сверить пару со своей: сигнал может относиться к другому модлоадеру или к прошлой версии игры.
- `loaderVersionsLoading(loaderKey, gameVersion)` — помогает отличить «ещё грузится» от «не
поддерживается».
Ключи: `forge`, `fabric`, `neoforge`, `quilt`. Обновляет каталог сезонных сборок.
Установщику модлоадера передаётся та же Java, что пойдёт в игру: сначала скачанная лаунчером #### void installSeasonalBuild(const QString &seasonalId)
сборка, потом `javaPath`.
## Java Ставит или обновляет сборку целиком одной цепочкой: запись сборки, версия игры, модлоадер, Java и
файлы.
- `javaCatalog` — строки `{id, kind, major, version, label, detail, coverage, sizeMb, installed, lts, Порядок шагов жёсткий: сначала сборка делается активной, затем ставится Java — она нужна
downloadable, search}`. Сначала то, что уже скачано в `<root>/java`, потом каталог Mojang, Temurin установщику Forge, — затем модлоадер, затем качается пак и только в конце его файлы раскатываются
JDK и Temurin JRE. поверх `.minecraft`. Раскатывать файлы имеет смысл только когда всё остальное на месте.
- `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 — /путь`, для справки в
настройках.
## Запуск игры Строка каталога копируется на момент старта: обновление списка посреди установки не должно менять
то, что ставится. По завершении испускается `seasonalInstallFinished`.
`launchGame(profileIndex, buildIndex)` сначала проверяет всё, что можно проверить синхронно: #### void cancelSeasonalInstall()
- не работает ни один установщик, не идёт другой запуск и игра не запущена; Отменяет установку сезонной сборки.
- профиль и сборка выбраны, папка игры существует;
- версия сборки есть в `versions/`;
- описание версии читается (`VersionLoader::load`), и все нужные файлы на месте
(`GameLauncher::missingFiles`).
После этого выставляется `busy`, а сборка запоминается по `id`, а не по индексу: пока идёт вход, ### Папки
её могут удалить в другом окне.
Дальше зависит от типа профиля: #### void openMinecraftFolder()
- **офлайн** — ник из логина или имени профиля, UUID считается из ника; Открывает папку модов Minecraft в файловом менеджере системы.
- **Ely.by** — сначала докачивается `authlib-injector` в `<root>/runtime`, потом вход. Если Ely.by
просит второй фактор, `busy` снимается и приходит `twoFactorRequired(profileName)`. Окно должно
вызвать `submitTwoFactorCode(code)` или `cancelPendingLaunch()`, иначе запуск так и останется
в ожидании;
- **Microsoft** — если refresh-токена нет, запуск прерывается с `microsoftReloginRequired(index)`.
Если сохранённый токен Minecraft живёт ещё дольше двух минут, сеть не трогается. Иначе сессия
продлевается по refresh-токену. Если продлить не вышло, тоже `microsoftReloginRequired`. Если у
аккаунта нет лицензии, просто ошибка.
Полученные при входе ник, UUID и токены сохраняются в профиль. Java выбирается так: скачанная #### void openGameFolder()
лаунчером сборка из `javaRuntime`, иначе `javaPath`, иначе её ищет `GameLauncher`. Если
`javaRuntime` задан, но такой сборки на диске нет, об этом будет сообщение в `launchProgress`.
Ход виден по сигналам `launchProgress`, `launched(profileName, buildName, serverUrl)` и Открывает корневую папку игры в файловом менеджере системы.
`launchError`. `stopGame()` завершает процесс. Пока игра работает, `gameRunning` равно `true`;
вывод процесса приходит построчно в `gameOutput`, конец — в `gameFinished(exitCode, crashed)`.
## Вход через Microsoft из окна профиля ## Сигналы
1. `startMicrosoftLogin(profileIndex)` испускает `microsoftLoginUrlReady(url)`. `-1` значит, что ### Сигналы уведомления свойств
профиля ещё нет и его нужно создать по нику из аккаунта. Без WebEngine сразу приходит
`microsoftLoginFailed`.
2. [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) открывает страницу и на каждую смену
адреса вызывает `inspectMicrosoftRedirect(url)`. Ответ: `{matched, code, error}`. Разбор сделан
в C++, чтобы правила совпадали с тем, как `MsaAuthService` строит `redirect_uri`.
3. Код уходит в `finishMicrosoftLogin(code)`. Итог приходит в `microsoftLoginSucceeded(playerName)`
или `microsoftLoginFailed(message)`.
`cancelMicrosoftLogin()` забывает начатый вход. Если окно закрыли, пока шёл обмен кода, результат Эти сигналы объявлены как `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` — идёт установка.
Установка — цепочка шагов. Каждый шаг либо запускает асинхронную работу и ждёт сигнала, либо #### seasonalInstallFinished(const QString &seasonalId, const QString &buildName)
решает, что делать нечего, и сразу передаёт ход следующему (`seasonAdvance()` со сквозными `case`).
1. **Запись.** Если сборки с таким `seasonalId` нет, она создаётся. Если есть, в неё записываются Сезонная сборка установлена и активна — можно запускать игру. Обработчик показывает сообщение
новые версия, модлоадер и адрес сервера. Ревизию пока не трогаем: её запишем, только когда файлы пользователю.
лягут на диск, иначе оборванная установка выглядела бы законченной.
2. **Switching.** Сборка становится активной: пак раскатывается поверх текущей `.minecraft`.
3. **Java.** Нужная версия берётся из `javaMajor` каталога или из таблицы требований. Если
подходящая java уже есть (своя, системная или скачанная раньше), шаг пропускается. Иначе
загружается каталог Java и ставится лучшая сборка для этой версии. Java ставится раньше
модлоадера, потому что она нужна установщику Forge.
4. **Loader.** Для ванильной сборки ставится версия игры, для остальных — модлоадер. Если всё уже
установлено и укомплектовано, шаг пропускается.
5. **Downloading.** Архив качается в `builds/<id>/.pack-r<ревизия>.zip`. Точка в начале имени нужна,
чтобы `BuildSwitcher` не принял файл за архив сборки и не стёр его.
6. **Applying.** `BuildSwitcher::applyPack()` удаляет всё, что принесла прошлая ревизия (список из
`season.json`), и распаковывает новую. Миры и скриншоты в этот список не попадают, потому что
пак их не приносил.
После раскатки `recordSeasonalPack()` пишет `builds/<id>/season.json` (`seasonalId`, `revision`, #### javaRuntimeInstalled(const QString &runtimeId)
`sha256`, `installedAt`, `entries`) и проставляет ревизию в запись сборки. Это делается и тогда,
когда раскатка была доиграна после перезапуска лаунчера. Конец цепочки — `seasonalInstallFinished
(seasonalId, buildName)`.
При ошибке на любом шаге скачанный архив удаляется, и приходит `launchError`. На шагах Switching и Сборка Java установлена. Диалог настроек по этому сигналу обновляет подпись выбранной сборки, не
Applying отменить нельзя: `.minecraft` уже разобрана, операцию нужно довести до конца. переоткрываясь.
Строка каталога копируется в `m_season.entry` при старте. Если каталог обновится во время установки, ### Запуск игры
ставиться будет всё равно то, что выбрали.
## Панель загрузки #### launchProgress(const QString &message)
Источников загрузки четыре: установщик версий, установщик модлоадера, установщик Java и загрузчик Описание текущего шага запуска. Обработчик показывает сообщение без таймаута: шаг может занять
пака. Наружу они видны через одну группу свойств: `downloading`, `downloadProgress`, заметное время, и сообщение должно держаться до следующего.
`downloadVersion` (что качается), `downloadStatus` (`этап — файл`), `downloadBytesDone`,
`downloadBytesTotal`. Все обновляются одним сигналом `downloadChanged`.
Чьи числа показывать, решается так. Пак качается, только когда остальные уже закончили. Java не #### launched(const QString &profileName, const QString &buildName, const QString &serverUrl)
качается одновременно с версией или модлоадером. Во время установки модлоадера доля берётся у
`VersionInstaller`, если он сейчас качает файлы версии, потому что его оценка точнее.
`cancelDownload()` останавливает первое, что работает, в порядке: модлоадер (он сам остановит Игра запущена. Обработчик сообщает пользователю, какой профиль и какая сборка стартовали.
вложенную установку версии), версия, Java, пак.
`busy` равно `true`, пока идёт вход или запуск, работает любой установщик, идёт смена сборки или #### launchError(const QString &message)
качается пак. На это время в интерфейсе гаснет кнопка запуска.
## Папки Запуск не удался либо произошла ошибка, о которой нужно сказать пользователю. Через этот же сигнал
сообщается о проблемах записи файлов лаунчера.
- `openMinecraftFolder()` открывает `<gameDir>/mods` (создаёт, если её нет). #### twoFactorRequired(const QString &profileName)
- `openGameFolder()` открывает саму папку игры.
## Потоки Ely.by отклонил пароль с пометкой two factor. Обработчик открывает диалог ввода кода и передаёт
введённое значение в `submitTwoFactorCode()`; отказ должен вызвать `cancelPendingLaunch()`, иначе
запуск останется висеть в ожидании.
Весь класс работает в GUI-потоке. В отдельном потоке выполняются только файловые операции #### gameOutput(const QString &line)
`BuildSwitcher`, и этот поток скрыт внутри него.
## Пример Строка вывода процесса игры. Обработчик пишет её в журнал.
#### 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 ```qml
import QtQuick import QtQuick
import BiLauncher import Minecraft_launcher
Window { Window {
id: window
visible: true visible: true
LauncherBackend { LauncherBackend {
id: backend id: backend
onLaunchError: (message) => console.warn(message) onLaunchError: (message) => console.warn(message)
onTwoFactorRequired: (profileName) => twoFactorDialog.open() onTwoFactorRequired: (profileName) => twoFactorDialog.open()
onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url)
} }
Button { Button {
+94 -103
View File
@@ -1,15 +1,17 @@
# Localization # Localization
`localization.h`, `localization.cpp` `localization.h` / `localization.cpp`
Все тексты интерфейса на всех языках лежат в одном файле, `i18n/translations.json`, и читаются Единственный источник всех текстов интерфейса. Каталог — [`i18n/translations.json`](#каталог),
через этот класс. Стандартные `.ts`/`.qm`, `lupdate`, `lrelease` и Linguist в проекте не он лежит в ресурсах и правится руками. Штатных `.ts`/`.qm` в проекте нет намеренно: `lupdate`,
используются. Язык переключается без перезапуска. `lrelease` и Linguist не нужны, а все переводы видны в одном файле.
## Из QML Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.
Синглтон `Loc` (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) доступен в любом файле модуля без ## Доступ из QML
импорта.
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
в любом файле модуля без импорта.
```qml ```qml
Text { text: Loc.t.settings.title } 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 } DarkCombo { model: Loc.t.profile.authTypes }
``` ```
`Loc.t` — дерево JS-объектов, собранное из плоских ключей каталога разбиением по точке. Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
плоских ключей каталога разбиением по точке.
Почему `t` сделано свойством, а не методом `Loc.t("ключ")`. Вызов `Q_INVOKABLE` не регистрирует **Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
зависимость привязки, и текст не обновился бы при смене языка. Чтение `Q_PROPERTY` с `NOTIFY` `text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
регистрирует, поэтому `languageChanged` перевычисляет все привязки, где читали `Loc.t`. Дальнейшие `NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
`.settings.title` — обычные обращения к полям JS-объекта. Следить за ними не нужно, потому что при `Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
смене языка дерево заменяется целиком. потому что при смене языка дерево заменяется целиком.
Тип свойства — `QJSValue`. С `QVariantMap` движок собирал бы новый JS-объект при каждом чтении, а Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
привязок в проекте около полутора сотен. `QJSValue` строится один раз на каждую смену языка. при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
языка.
Кроме `t` есть `language` (что выбрано: `system`, `ru`, `en`), `resolvedLanguage` (во что это Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
превратилось, всегда `ru` или `en`) и `availableLanguages()`. Последний сейчас не используется:
список языков в настройках [Main.qml](../qml/Main.md) задан руками (`stLanguage`).
## Из 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 ```cpp
#include "localization.h" #include "localization.h"
@@ -42,34 +53,29 @@ emit launchProgress(Loc::text("java.progress.downloading").arg(label));
const QStringList kinds = Loc::list("profile.authTypes"); const QStringList kinds = Loc::list("profile.authTypes");
``` ```
Это свободные функции, потому что их вызывают и из пространств имён без `QObject` Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
([launcherpaths.h](launcherpaths.md), [javalocator.h](javalocator.md), `launch.error.noProfile` в интерфейсе сразу бросается в глаза.
[zlibreference.h](zlibreference.md)).
Если ключа нет, возвращается сам ключ, а в отладочной сборке ещё пишется `qWarning`. В интерфейсе
такую строку легко заметить.
### Потоки ### Потоки
`Loc::text()` и `Loc::list()` можно вызывать из любого потока. Каталог заполняется один раз в `Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
`load()`, до того как [BuildSwitcher](BuildSwitcher.md) запустит свой поток, и дальше только в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
читается. На ходу меняется лишь индекс текущего языка, а он `QAtomicInt`. В худшем случае строка, создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
собранная в момент переключения, окажется на прежнем языке. себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
`setLanguage()` вызывается только из GUI-потока, это проверяет `Q_ASSERT`. `setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
## Каталог ## Каталог
Файл попадает в ресурсы через `RESOURCES` в `qt_add_qml_module` и читается из `i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
`:/qt/qml/BiLauncher/i18n/translations.json`. поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
```json ```json
{ {
"_meta": { "_meta": {
"languages": ["ru", "en"], "languages": ["ru", "en"],
"displayNames": { "ru": "Русский", "en": "English" }, "displayNames": { "ru": "Русский", "en": "English" }
"_readme": ["..."],
"_glossary": { "сборка": "build", "...": "..." }
}, },
"strings": { "strings": {
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" }, "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`, Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
`seasonal`, `version`, `java`, `loader`, `auth` (`auth.ely.*`, `auth.msa.*`), `launch`, `game`, QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
`switch`, `storage`, `zlib`. отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
- **Вид**: `title`, `label`, `button`, `placeholder`, `hint`, `header`, `tooltip`; для сообщений строки, которой оно требуется.
`error`, `progress`, `status`, `warning`.
- **Имя** описывает ситуацию, а не формулировку: `gameRunning`, `downloadFailed`,
`checksumMismatch`. Тогда при правке перевода ключ не придётся переименовывать.
Строка, которая нужна в двух и более местах, кладётся в `common.*`. ## Как добавить строку
`%1`, `%2` пишутся как есть: их одинаково понимают `QString::arg()` и `String.arg()` в QML. Набор 1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
`%N` в `ru` и `en` должен совпадать, порядок может быть разным. Множественного числа формат не 2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
поддерживает, и пока ни одна строка его не требует. 3. Прогнать `python3 tools/check_translations.py`.
## Добавить строку ## Как добавить язык
1. Запись в `strings` сразу с `ru` и `en`. 1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
2. `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++. 2. Добавить колонку с этим кодом в каждую запись `strings`.
3. `python3 tools/check_translations.py`. 3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
одинаково во всех колонках).
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
русский для русской системной локали и английский во всех остальных случаях.
## Добавить язык ## Выбор языка и его хранение
1. Код в `_meta.languages`, название в `_meta.displayNames`. Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
2. Значение для этого кода в каждой записи `strings`. Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
3. Ключ `settings.language.<код>` с названием языка на нём самом, одинаковым во всех колонках. диалога «Настройки запуска», применяется по кнопке «Сохранить».
4. Пункт в модели `stLanguage` и код в `stLanguage.codes` в [Main.qml](../qml/Main.md).
5. Если нужно, поправить `systemLanguage()`: сейчас при русской системной локали выбирается
русский, при любой другой — английский.
## Где хранится выбранный язык Круг замкнут в одну сторону, циклической зависимости нет:
Ключ `language` в `settings.json`, по умолчанию `system`. Меняется в окне настроек и применяется
по кнопке «Сохранить».
``` ```
main.cpp ──► Localization::load() ──► читает settings.json (один раз) main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage() LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
``` ```
`settings.json` записывает только [LauncherBackend](LauncherBackend.md). Localization его только `Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
читает, и только при старте: язык нужен раньше первой привязки QML, а бэкенд создаётся вместе с по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
движком. Обратной зависимости от бэкенда у Localization нет. чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
Неизвестный язык в файле (если его правили руками) заменяется на `system` с предупреждением. Неизвестное значение (файл правили руками) откатывается на `"system"` с предупреждением.
Отсутствующий или испорченный каталог — ошибка на старте: `main()` пишет причину и возвращает
`-1`. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер,
у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему
не запустился.
Если каталог не найден или не разбирается, это ошибка сборки, потому что файл вкомпилирован в **Известное ограничение.** Уже сложенные в поля C++ строки не перепереводятся:
бинарник. `main()` пишет причину в `qCritical` и возвращает `-1`.
### Что не переводится при смене языка
Строки, которые уже собраны и лежат в полях C++, остаются на старом языке:
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у `LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
[BuildSwitcher](BuildSwitcher.md) обновятся только на следующем шаге. Всё это короткоживущие [BuildSwitcher](BuildSwitcher.md) заменяются на следующем тике прогресса. Все они
диагностические сообщения. диагностические и короткоживущие.
Отдельно: `GameLauncher` прячет токен в строке запуска за `<скрыт>`, и эта строка не берётся из ## Проверка
каталога.
## Проверка каталога `tools/check_translations.py` — только чтение, ненулевой код возврата при любой ошибке:
`tools/check_translations.py` ничего не меняет и возвращает ненулевой код при любой ошибке. - каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
Проверяет: - каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
- у всех языков одинаковый набор ключей и типы, пустых значений нет, длины списков равны; - наборы `%N` совпадают по языкам, длины списков равны;
- набор `%N` совпадает по языкам; - не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
- каждый `Loc::text("…")` и `Loc::list("…")` в C++ есть в каталоге с нужным типом; - ключи, на которые никто не ссылается, — предупреждением.
- каждый `Loc.t.a.b.c` в QML ведёт к существующему ключу (последний сегмент может быть методом
JS, например `.arg`);
- нигде не осталось `tr(`, `qsTr(`, `QCoreApplication::translate`.
Ключи, на которые никто не ссылается, выводятся предупреждением.
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
будет поправить.
---
При создании этого документа использовался ИИ.
+167 -54
View File
@@ -1,77 +1,190 @@
# ModLoaderInstaller # ModLoaderInstaller
`modloaderinstaller.h`, `modloaderinstaller.cpp` ## Обзор класса
Ставит модлоадер в `.minecraft`. Fabric и Quilt ставятся иначе, чем Forge и NeoForge, но снаружи у `ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно
них одинаковый интерфейс. разных пути установки.
- **Fabric и Quilt** отдают готовое описание версии. Его достаточно положить в **Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в
`versions/<id>/<id>.json`, а остальное докачает [VersionInstaller](VersionInstaller.md). `versions/<id>/<id>.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по
- **Forge и NeoForge** при установке патчат клиентский jar, поэтому запускается их официальный полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера.
`installer.jar` без окна. После него проверяется, какой профиль появился в `versions/`.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса устроены так же, как у **Forge и NeoForge** так не умеют: их установка — это патч клиентского jar. Поэтому лаунчер
`VersionInstaller` (`label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`), и запускает официальный `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` Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md).
(у Quilt `meta.quiltmc.org/v3/...`). Проверяется, что в нём есть безопасный `id` и что
`inheritsFrom` совпадает с выбранной версией игры. Файл сохраняется, и `VersionInstaller` получает
этот `id`.
### 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 минут процесс убивается.
После успешного завершения новый профиль ищется как разница списков до и после. Угадывать имя по Наследует `QObject`: мета-объектная система, шесть сигналов и владение по родителю. Объявлен
строке нельзя: у Forge в разные годы оно составлялось по-разному. Если новых папок нет (установщик виртуальный деструктор — класс владеет процессом установщика и открытым файлом журнала.
переписал существующую), ищется профиль, который наследует базовую версию и содержит в имени версию
модлоадера. Если новых папок несколько, берётся та, где есть версия модлоадера.
Найденный профиль передаётся `VersionInstaller`, который докачивает библиотеки, не положенные ## Публичные методы
установщиком. Когда он отчитается, приходит `finished(loaderKey, gameVersion, loaderVersion,
producedVersionId)`. Бэкенд записывает `producedVersionId` в `resolvedVersionId` сборки.
### Если установщик упал #### ModLoaderInstaller(ModLoaderVersionService \*meta, VersionInstaller \*versionInstaller, QObject \*parent = nullptr)
Все папки, которые он успел создать в `versions/`, удаляются. В текст ошибки идут код выхода, Создаёт установщик поверх двух сервисов. Ни один из них не переходит во владение установщика —
хвост журнала и путь к полному журналу. оба обязаны пережить его.
Если подменить zlib не удалось, а в журнале есть `Processor failed` или `invalid outputs`, к #### bool isRunning() const
сообщению добавляется прямая подсказка про zlib-ng. Почти наверняка дело в нём.
## cancel() Идёт ли установка прямо сейчас.
Обрывает загрузку, убивает процесс установщика и удаляет то, что он создал. Если ход уже передан #### QString label() const
`VersionInstaller`, отменяется и он, а `canceled` придёт после его отчёта.
Деструктор убивает процесс установщика, если тот ещё работает. Подпись текущей установки для интерфейса — название лоадера с версиями.
#### QString stage() const
Текущий этап словами.
#### QString currentFile() const
Файл, который обрабатывается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено.
#### qint64 bytesTotal() const
Ожидаемый общий объём; `0` — неизвестен.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. У пути с `installer.jar` доля
почти всё время равна `-1`: сколько работы осталось внутри чужого процесса, лаунчер не знает.
#### void install(const QString &gameDir, ModLoader loader, const QString &gameVersion, const QString &loaderVersion, const QString &javaPreference)
Ставит модлоадер. Параметр `javaPreference` — путь к java, указанный пользователем в настройках;
он проверяется первым, а при пустом или неподходящем значении java ищется сама. Java нужна даже на
пути Fabric и Quilt, потому что перед установкой лоадера скачивается базовая версия игры.
Перед запуском `installer.jar` установщик записывает заглушку `launcher_profiles.json`: без этого
файла официальные установщики Forge и NeoForge отказываются работать.
#### void cancel()
Отменяет установку. Если `installer.jar` уже успел отработать, за появившиеся в `versions` папки
отвечает лаунчер, и при отмене они убираются.
## Сигналы
#### started(const QString &label)
Установка началась. Обработчик показывает панель прогресса.
#### progressChanged()
Изменились числа прогресса. Обработчик перечитывает геттеры — тот же набор, что у
[VersionInstaller](VersionInstaller.md).
#### finished(const QString &loaderKey, const QString &gameVersion, const QString &loaderVersion, const QString &producedVersionId)
Модлоадер установлен. Последний параметр — идентификатор появившегося профиля `versions/<id>`.
Он важен именно для Forge: заранее этот идентификатор неизвестен, поэтому новый профиль ищется
разницей между списком версий до и после запуска установщика, а не угадыванием строки —
идентификаторы Forge отличаются по эпохам.
Обработчик записывает `producedVersionId` в сборку: именно этот профиль будет запускаться.
#### failed(const QString &label, const QString &message)
Установка не удалась. Текст ошибки по возможности объясняет причину: отдельно распознаётся случай,
когда в системе стоит zlib-ng, а подменить его было нечем — это почти наверняка причина
расхождения sha1 у Forge, и о ней стоит сказать прямым текстом.
#### canceled(const QString &label)
Установка отменена пользователем.
#### log(const QString &line)
Строка вывода процесса `installer.jar`. Обработчик пишет её в журнал; вывод также сохраняется в
файл, чтобы разбираться с неудачной установкой после закрытия лаунчера.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Указатели на `ModLoaderVersionService` и `VersionInstaller`, переданные в конструктор, **не
принадлежат** установщику: оба сервиса создаются раньше и живут дольше.
`QNetworkAccessManager` создаётся в конструкторе с установщиком в роли родителя; сетевой ответ,
процесс установщика и файл журнала создаются по ходу работы и закрываются в деструкторе.
Установка асинхронна и состоит из вложенных продолжений: `ensureBaseVersion()` принимает функцию,
которая будет вызвана после появления базовой версии. Уничтожение установщика посреди этой цепочки
обрывает её.
## Потокобезопасность
Только поток GUI. Дочерний процесс `installer.jar` работает параллельно, но общение с ним идёт
через сигналы `QProcess`, которые приходят в поток GUI.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `install()` при установке сборки с модлоадером и переправляет сигналы
прогресса в те же свойства, что и у `VersionInstaller`, — панель загрузки не различает, кто
работает. По сигналу `finished` бэкенд записывает `producedVersionId` в поле сборки
`resolvedVersionId`, которое до установки пусто и означает запуск на чистой ванили.
[ModLoaderVersionService](ModLoaderVersionService.md) даёт адрес `installer.jar`,
[VersionInstaller](VersionInstaller.md) ставит базовую версию до начала работы и докачивает
недостающие библиотеки после неё, [ZlibReference](zlibreference.md) готовит окружение процесса.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Загрузка `installer.jar` по HTTPS для Forge и NeoForge. Для Fabric и
Quilt сеть используется только через сервис версий и `VersionInstaller`.
**Дочерний процесс.** `installer.jar` запускается java в headless-режиме через `QProcess`.
Окружение процесса готовится `ZlibReference::applyTo()`: на Linux с zlib-ng туда дописывается
`LD_PRELOAD` с эталонной библиотекой, иначе установщик Forge падает с сообщением «Processor
failed, invalid outputs» — он сверяет sha1 собранных им же jar-файлов с эталоном, посчитанным на
обычном zlib.
Вывод процесса читается построчно, отдаётся сигналом `log` и параллельно пишется в файл. Результат
установки определяется не кодом выхода, а появлением новой папки в `versions`.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *loaderInstaller = new ModLoaderInstaller(loaderVersions, versionInstaller, this);
connect(loaderInstaller, &ModLoaderInstaller::finished, this,
[this](const QString &key, const QString &game,
const QString &loaderVersion, const QString &producedVersionId) {
m_build.resolvedVersionId = producedVersionId;
saveBuilds();
});
connect(loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &, const QString &message) { emit launchError(message); });
loaderInstaller->install(gameDir, ModLoader::Forge,
QStringLiteral("1.20.1"), QStringLiteral("47.4.0"),
settings.javaPath);
```
--- ---
+129 -48
View File
@@ -1,71 +1,152 @@
# ModLoaderVersionService # ModLoaderVersionService
`modloaderversionservice.h`, `modloaderversionservice.cpp` ## Обзор класса
Списки версий Forge, Fabric, NeoForge и Quilt. Устроен так же, как Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService`
[VersionManifestService](VersionManifestService.md): качает, хранит копию в приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока
`<root>/cache/loader_<ключ>.json` и отвечает из неё, пока она моложе 6 часов. тот не устарел. Устроен так же, как [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** спрашиваются отдельно под каждую версию игры: Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
`meta.fabricmc.net/v2/versions/loader/<игра>` и `meta.quiltmc.org/v3/versions/loader/<игра>`. [ModLoaderInstaller](ModLoaderInstaller.md) — из записи списка он берёт адрес `installer.jar`.
Признак стабильности берётся из ответа, а если его нет, угадывается по имени.
**Forge** — один `maven-metadata.xml` на все версии игры. Строка `1.20.1-47.4.0` делится по первому Путь к файлу кэша даёт `LauncherPaths::loaderCacheFile()` из [launcherpaths.h](launcherpaths.md).
дефису. Параллельно запрашивается `promotions_slim.json`, оттуда берутся отметки recommended. Если
он не ответил, список всё равно сохраняется, только без отметок.
**NeoForge** тоже из `maven-metadata.xml`, но версия игры в номере не записана. Её приходится Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
выводить: `21.1.248` → `1.21.1`, `20.4.80` → `1.20.4`, `21.0.x` → `1.21`. Для четырёхчастных (`QNetworkAccessManager`).
номеров вроде `25.1.0.1` правило другое: `25.1.0.1` → `25.1`. Вторым запросом читается старый
репозиторий `net/neoforged/forge` с первыми сборками NeoForge под 1.20.1, которые выходили под
именем артефакта `forge` и номерами в стиле Forge.
Ссылка на установщик собирается по схеме maven: ## Иерархия и роль
`<репозиторий>/<версия>/<артефакт>-<версия>-installer.jar`.
Версии внутри каждой версии игры отсортированы от новых к старым. Числа сравниваются как числа, Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
поэтому `47.10` новее `47.9`. методов базового класса не переопределяет.
## ensureLoaded(loader, gameVersion, callback, forceRefresh) ## Псевдонимы типов
Логика та же, что у манифеста версий. Отличие в том, что свежесть у Fabric и Quilt считается для `ModLoaderVersionService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
каждой версии игры отдельно, а у Forge и NeoForge для модлоадера целиком. По этому же ключу Как и у сервиса манифеста, `ok == true` с непустым `warning` означает, что данные отдали из
объединяются одновременные запросы. устаревшего кэша.
- Пустая `gameVersion` — сразу `ok` и пустой список. ## Публичные методы
- При первом обращении к модлоадеру читается кэш, и если он есть, испускается
`versionsChanged(key, gameVersion)`.
- Если сеть не ответила, но какие-то данные по модлоадеру есть, колбэк получит `ok = true` и
`warning`.
- Если ответ не разобрался, хороший кэш не затирается.
`versionsChanged(loaderKey, gameVersion)` испускается после каждого успешного обновления. #### explicit ModLoaderVersionService(QObject \*parent = nullptr)
`refreshingChanged()` — в начале и в конце запроса. `isRefreshing(loader, gameVersion)` отвечает,
идёт ли запрос по этому ключу.
## Формат кэша Создаёт сервис и его `QNetworkAccessManager`. Кэши читаются лениво, при первом обращении к
конкретному лоадеру. Конструктор помечен `explicit`.
```json #### void ensureLoaded(ModLoader loader, const QString &gameVersion, Callback callback, bool forceRefresh = false)
{
"fetchedAt": "...", Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
"games": { один сетевой запрос на всех, кто успел попросить.
"1.20.1": { "fetchedAt": "...", "entries": [ { "loaderVersion": "47.4.0", ... } ] }
Свежесть считается по-разному в зависимости от лоадера: 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
`msaauthservice.h`, `msaauthservice.cpp` ## Обзор класса
Вход через аккаунт Microsoft, то есть с лицензионной копией игры. Цепочка такая же, как у `MsaAuthService` — авторизация через учётную запись Microsoft, то есть вход с лицензионной копией
официального лаунчера: OAuth2 → Xbox Live → XSTS → Minecraft Services → проверка лицензии → игры. Это та же цепочка, что и в официальном лаунчере: OAuth2 → Xbox Live → XSTS → Minecraft
профиль. Результат приходит в `AuthResult` из [AuthService](AuthService.md). Services → проверка лицензии.
Окно входа сервис не показывает. Страницу Microsoft открывает Результат отдаётся тем же `AuthResult`, что и [AuthService](AuthService.md) для Ely.by и офлайна,
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), а сервис даёт адрес этой страницы и умеет поэтому запуск игры дальше идёт по общему пути и ничего не знает о способе входа.
узнать адрес возврата. Класс компилируется всегда, но без Qt WebEngine показать страницу нечем, и
[LauncherBackend](LauncherBackend.md) сразу отвечает ошибкой.
## Чьё это приложение Класс не показывает окно входа сам: страницу Microsoft открывает
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) на стороне QML, а сервис даёт ему адрес
страницы и разбирает адрес возврата.
Сейчас используется `client_id` официального лаунчера Mojang (`00000000402b5328`). Это старое ## Место в проекте и зависимости
приложение Live Connect, поэтому и адреса от `login.live.com`:
| Константа | Значение | Подключает `authservice.h` — ради общей структуры `AuthResult`. Экземпляр создаётся и принадлежит
|-----------|----------| [LauncherBackend](LauncherBackend.md).
| `kAuthorizeUrl` | `https://login.live.com/oauth20_authorize.srf` |
| `kTokenUrl` | `https://login.live.com/oauth20_token.srf` |
| `kRedirectUri` | `https://login.live.com/oauth20_desktop.srf` |
| `kScope` | `service::user.auth.xboxlive.com::MBI_SSL` |
| `kRpsTicketPrefix` | пусто |
Это временное решение. Чтобы перейти на своё приложение в Azure (с включённым «Allow public client Требования сборки: `Qt6::Core` (`QJsonObject`, `QString`, `QUrl`) и `Qt6::Network`
flows»), меняются эти шесть констант. Что именно подставить, написано в комментарии в начале (`QNetworkAccessManager`).
`msaauthservice.cpp`. Главное, что легко пропустить: у нового приложения в `RpsTicket` нужна
приставка `d=`.
`clientId()` отдаёт этот id наружу. Бэкенд подставляет его в `${clientid}`, потому что официальный Сам класс собирается всегда и не зависит от Qt WebEngine — от наличия WebEngine зависит только
лаунчер передаёт туда id приложения, а не случайный токен. окно, в котором показывается страница входа. Поэтому в сборке без WebEngine сервис существует, но
воспользоваться им нельзя: показать страницу нечем.
## Окно входа ## Иерархия и роль
`authorizationUrl()` — адрес страницы входа с `client_id`, `response_type=code`, `redirect_uri`, Наследует `QObject`: мета-объектная система, сигнал `progress`, владение по родителю. Виртуальных
`scope` и `prompt=select_account`. Последний параметр нужен, чтобы при повторном входе можно было методов базового класса не переопределяет.
выбрать другой аккаунт.
`matchRedirect(url, &code, &error)` возвращает `true`, только если хост и путь совпадают с ## Псевдонимы типов
`kRedirectUri`. Код или ошибка ищутся сначала в query, потом во фрагменте. В `error` попадает
`error_description`, а если его нет, то `error`. Эта проверка сделана в C++, а не в QML, чтобы она
всегда совпадала с тем адресом, который сервис сам передаёт в `redirect_uri`.
## Цепочка `MsaAuthService::Callback` — `std::function<void(const AuthResult &)>`. Колбэк вызывается ровно
один раз и в потоке GUI.
`loginWithCode(code, callback)` меняет код на токен (`grant_type=authorization_code`). ## Публичные методы
`loginWithRefreshToken(refreshToken, callback)` делает то же по refresh-токену. Дальше путь общий:
1. **Токен Microsoft.** Форма на `kTokenUrl`. Из ответа берутся `access_token` и `refresh_token`. #### explicit MsaAuthService(QObject \*parent = nullptr)
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. Если игра куплена, значит, игрок ещё не выбрал
ник. Если не куплена, значит, лицензии нет.
`userType` = `msa`, `clientToken` = id приложения. Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
Каждый шаг испускает `progress(message)` до отправки запроса. Таймаут на каждый запрос 20 секунд, #### static QString clientId()
повторных попыток нет. Сетевая ошибка и отказ сервера дают разные тексты.
Refresh-токен Microsoft живёт намного дольше суточного токена Minecraft, поэтому при обычном Идентификатор приложения лаунчера в Microsoft. Игра ждёт его в `${clientid}`: официальный лаунчер
запуске окно входа не нужно: бэкенд вызывает `loginWithRefreshToken()`. Если Microsoft не вернул подставляет туда именно идентификатор приложения, а не случайный токен сессии.
новый refresh-токен, бэкенд оставляет старый.
## Колбэки #### 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
`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` в Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записи каталога
`settings.json`, например чтобы проверить установку на локальном HTTP-сервере. Бэкенд передаёт используются [SeasonalPackDownloader](SeasonalPackDownloader.md) — оттуда берётся адрес архива и
адрес через `setBaseUrl()` при старте и перед каждым `refreshSeasonalCatalog()`. его контрольная сумма.
Если адрес сменился, накопленные данные сбрасываются, а кэш с другим `baseUrl` не читается: ссылки Путь к файлу кэша даёт `LauncherPaths::seasonalCatalogFile()` из
в нём ведут на старый сервер. Пустой адрес или адрес без хоста — сразу ошибка. [launcherpaths.h](launcherpaths.md).
## Формат index.json Требования сборки: `Qt6::Core` (`QDate`, `QDateTime`, `QHash`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
Это контракт с теми, кто публикует сборки. ## Иерархия и роль
```json Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
{ методов базового класса не переопределяет.
"formatVersion": 1,
"builds": [ ## Публичные структуры
{
"id": "season-5", ### SeasonalBuildEntry
"name": "Сезон 5: Пустоши",
"revision": 3, Одна готовая сборка с сервера сезонных сборок.
"minecraftVersion": "1.20.1",
"loader": "forge", | Поле | Тип | По умолчанию | Описание |
"loaderVersion": "47.4.0", |------|-----|--------------|----------|
"modCount": 142, | `id` | `QString` | — | Идентификатор вида `season-5`; он же ключ, по которому сборка узнаётся среди локальных записей |
"seasonStart": "2026-09-01", | `name` | `QString` | — | Название для интерфейса, например «Сезон 5: Пустоши» |
"seasonEnd": "", | `revision` | `int` | `0` | Растёт при каждой публикации; сравнение с установленной ревизией даёт признак доступного обновления |
"serverUrl": "play.example.com:25565", | `minecraftVersion` | `QString` | — | Версия игры, например `1.20.1` |
"javaMajor": 17, | `loader` | `QString` | — | Ключ модлоадера: пустая строка (чистая ваниль), `forge`, `fabric`, `neoforge` или `quilt` |
"description": "…", | `loaderVersion` | `QString` | — | Версия модлоадера |
"archive": { | `modCount` | `int` | `0` | Число модов в сборке; показывается колонкой в таблице |
"url": "https://launcher-builds.galeonworx.com/season-5/r3.zip", | `seasonStart` | `QDate` | — | Начало сезона |
"size": 734003200, | `seasonEnd` | `QDate` | — | Конец сезона; невалидная дата означает, что сезон ещё не закончен |
"sha256": "…64 hex…" | `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
`seasonalpackdownloader.h`, `seasonalpackdownloader.cpp` ## Обзор класса
Качает один архив сезонной сборки в файл и проверяет sha256. Пак весит сотни мегабайт, поэтому `SeasonalPackDownloader` скачивает один архив сезонной сборки в файл. Задача узкая и отдельная по
пишется потоком через `QSaveFile`, а не собирается в памяти. Битый архив распаковывать поверх двум причинам: пак — это сотни мегабайт, поэтому он пишется потоком, а не держится в памяти; и его
рабочей `.minecraft` нельзя, поэтому файл появляется на диске только после совпадения хеша. sha256 обязательно сверяется, потому что распаковывать битую загрузку поверх рабочей `.minecraft`
нельзя.
Распаковывает пак [BuildSwitcher](BuildSwitcher.md). Экземпляр держит Распаковкой класс не занимается — это делает [BuildSwitcher](BuildSwitcher.md) в отдельном потоке
[LauncherBackend](LauncherBackend.md), который и вызывает загрузку на шаге Downloading установки вместе с остальными операциями над содержимым папки игры.
сезонной сборки.
## 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)`, Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QTimer`) и `Qt6::Network`
`failed(label, message)`, `canceled(label)`. Геттеры прогресса такие же, как у других (`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
`versioninstaller.h`, `versioninstaller.cpp` ## Обзор класса
Ставит версию игры в `.minecraft`: описание версии, клиентский jar, библиотеки, индекс ресурсов, Установить версию Minecraft — значит положить в `.minecraft` пять групп файлов: описание версии,
сами ресурсы и конфиг логирования. Одновременно ставится одна версия, остальные ждут в очереди. клиентский jar, библиотеки, индекс ресурсов и сами ресурсы. Последних — десятки тысяч мелких
Всё работает в GUI-потоке на асинхронной сети. файлов.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Ещё им пользуется `VersionInstaller` делает это фоном, не блокируя интерфейс: складывает всё нужное в очередь
[ModLoaderInstaller](ModLoaderInstaller.md): перед установкой модлоадера нужно поставить базовую загрузок, качает несколько файлов параллельно, пишет их потоком на диск и по ходу сообщает
версию, а после установки докачать библиотеки нового профиля. прогресс. Одна версия ставится за раз, остальные ждут в очереди.
## Интерфейс Класс также разворачивает цепочку наследования: если у версии есть `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)`, Подключает [minecraftversion.h](minecraftversion.md) — по разобранной версии он и понимает, что
`failed(id, message)`, `canceled(id)`. качать. В конструктор принимает [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` | Сколько попыток уже сделано |
Отмена обрывает активные запросы и ждёт, пока все они вернутся. Только после этого приходит #### Перечисление DownloadTask::Kind
`canceled`. Пока отмена не закончилась, `install()` отвечает отказом.
Деструктор блокирует сигналы и отменяет установку. | Значение | Описание |
|----------|----------|
| `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);
```
--- ---
+145 -45
View File
@@ -1,67 +1,167 @@
# VersionManifestService # VersionManifestService
`versionmanifestservice.h`, `versionmanifestservice.cpp` ## Обзор класса
Каталог версий Mojang. Скачивает `version_manifest_v2.json`, хранит его копию в папке лаунчера и Каталог версий Minecraft — это манифест Mojang: около тысячи записей от альф 2010 года до
отвечает из неё, пока копия свежая. свежайших снапшотов. `VersionManifestService` отвечает за него целиком: скачивает манифест,
кэширует в папке лаунчера и отдаёт из кэша, пока тот не устарел.
Экземпляр держит [LauncherBackend](LauncherBackend.md): из каталога он строит список для окна Класс нужен двум потребителям: окну выбора версии, которому нужен весь список, и установщику,
выбора версии. [VersionInstaller](VersionInstaller.md) берёт из каталога ссылку на описание версии которому по идентификатору версии нужна ссылка на её описание.
через `find()`.
## RemoteVersionEntry ## Место в проекте и зависимости
| Поле | Что это | Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
|------|---------| [VersionInstaller](VersionInstaller.md) — установщик берёт из манифеста адрес описания версии.
| `id` | `1.21.8`, `25w33a`, `b1.7.3` |
| `type` | `release`, `snapshot`, `old_beta`, `old_alpha` |
| `url` | адрес `<id>.json` |
| `sha1` | хеш этого json |
| `releaseTime` | |
Записи без `id` или с адресом не по `https` отбрасываются. Список отсортирован по `releaseTime`, Путь к файлу кэша даёт `LauncherPaths::versionManifestFile()` из
новые сверху. При одинаковом времени сортировка идёт по `id`. [launcherpaths.h](launcherpaths.md).
## ensureLoaded(callback, forceRefresh = false) Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QList`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
Через этот метод сервис получает данные; других способов нет. ## Иерархия и роль
1. При первом вызове читается кэш `<root>/cache/version_manifest.json`. Если он разобрался, Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
сразу испускается `versionsChanged`, и окно показывает список ещё до ответа сети. методов базового класса не переопределяет.
2. Если данные моложе 6 часов и `forceRefresh` не задан, колбэк вызывается через
`QTimer::singleShot(0)` с `ok = true`. Сеть не трогается.
3. Иначе колбэк встаёт в очередь. Если запрос уже идёт, второй не отправляется.
Ответ сети разбирается, кэш перезаписывается через `QSaveFile`, испускается `versionsChanged`, и ## Публичные структуры
все колбэки из очереди получают результат.
Если сеть не ответила, но старые данные есть, колбэк получит `ok = true` и непустой `warning`: ### RemoteVersionEntry
работать можно, просто список, возможно, неполный. Если данных нет совсем, придёт `ok = false`.
Бэкенд показывает `warning` пользователю только во втором случае.
Таймаут запроса 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()` — весь список. Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не
- `find(id)` — запись по id (через хеш-таблицу, а не перебором). попавшие ни в одну из четырёх, интерфейс относит к категории «прочие».
- `hasData()`, `isRefreshing()`, `fetchedAt()`.
- `refreshingChanged` испускается в начале и в конце запроса. Бэкенд превращает его в
`catalogLoading`.
## Формат кэша ## Псевдонимы типов
```json `VersionManifestService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
{ Сочетание `ok == true` с непустым `warning` означает особый случай: данные отдали, но из
"fetchedAt": "2026-10-11T08:00:00Z", устаревшего кэша — сеть недоступна, а показать что-то нужно.
"versions": [
{ "id": "1.21.8", "type": "release", "url": "https://...", "sha1": "...", "releaseTime": "..." } ## Публичные методы
]
} #### explicit VersionManifestService(QObject \*parent = nullptr)
Создаёт сервис и его `QNetworkAccessManager`. Манифест при этом не читается — чтение кэша
откладывается до первого обращения. Конструктор помечен `explicit`.
#### QList&lt;RemoteVersionEntry&gt; versions() const
Текущий список версий. Возвращает копию; пустой список означает, что данных ещё нет.
#### bool hasData() const
Есть ли хоть какие-то данные — из сети или из кэша.
#### bool isRefreshing() const
Идёт ли сейчас сетевое обновление. Интерфейс показывает по этому признаку строку загрузки вместо
пустого списка.
#### QDateTime fetchedAt() const
Когда данные были получены. По этой отметке решается, устарел ли кэш.
#### std::optional&lt;RemoteVersionEntry&gt; find(const QString &id) const
Запись по идентификатору версии; `std::nullopt`, если такой версии в манифесте нет. Из неё
установщик берёт ссылку на описание версии. Поиск идёт по внутреннему указателю, а не перебором.
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без обращения к сети; иначе
запускается один сетевой запрос на всех, кто успел попросить, — колбэки накапливаются и вызываются
все разом по его завершении.
Параметр `forceRefresh` обходит проверку свежести кэша: так работает кнопка принудительного
обновления.
Колбэк вызывается ровно один раз и всегда в потоке GUI, в том числе когда данные уже есть.
## Сигналы
#### versionsChanged()
Список версий изменился — пришли новые данные из сети или прочитан кэш.
Обработчик перечитывает `versions()` и обновляет интерфейс. В лаунчере на этот сигнал завязано
свойство каталога версий, которое читает окно выбора.
#### refreshingChanged()
Изменился признак сетевого обновления. Обработчик показывает или убирает индикатор загрузки.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся в списке до завершения текущего запроса. Уничтожение сервиса с
незавершённым запросом обрывает его вместе с менеджером сети, и накопленные колбэки не
вызываются.
Ссылку на сервис держит установщик версий; уничтожать сервис раньше установщика нельзя.
## Потокобезопасность
Только поток GUI — так же, как [AuthService](AuthService.md). Чтение и запись кэша выполняются в
вызывающем потоке, поэтому первое обращение к манифесту делает короткую файловую операцию
синхронно.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора версии и по кнопке
обновления, а сигналы `versionsChanged` и `refreshingChanged` переправляет в свойства, которые
читает QML. Список из `versions()` он сводит с установленными версиями и отдаёт в интерфейс уже
готовыми строками.
[VersionInstaller](VersionInstaller.md) обращается к `find()`, чтобы получить адрес описания
версии перед началом загрузки.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс скачивает манифест версий Mojang через
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
Стратегия при недоступной сети встроена в контракт колбэка: если есть устаревший кэш, он
отдаётся с `ok == true` и заполненным `warning`, и интерфейс показывает список вместо ошибки.
Полное отсутствие данных даёт `ok == false`.
**Файловый кэш.** Манифест сохраняется в файл, путь к которому даёт
`LauncherPaths::versionManifestFile()`, вместе с отметкой времени получения.
Сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *manifest = new VersionManifestService(this);
connect(manifest, &VersionManifestService::versionsChanged, this, &Backend::rebuildCatalog);
manifest->ensureLoaded([this, manifest](bool ok, const QString &warning) {
if (!ok) {
emit catalogError(warning);
return;
}
if (!warning.isEmpty())
showStatus(warning); // список из устаревшего кэша
const auto entry = manifest->find(QStringLiteral("1.21.8"));
if (entry)
startDownload(entry->url);
});
``` ```
Хранится только то, что нужно лаунчеру, а не весь манифест Mojang.
--- ---
При создании этого документа использовался ИИ. При создании этого документа использовался ИИ.
-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, уже установленной на машине, и выбор подходящей под версию игры. Скачиванием Для запуска Minecraft нужна Java подходящей версии: старым версиям игры — восьмая, новым —
занимается [JavaInstaller](JavaInstaller.md), а здесь только смотрим, что есть. семнадцатая или двадцать первая. Лаунчер умеет скачивать сборки Java сам (см.
[JavaInstaller](JavaInstaller.md)), но сначала стоит посмотреть, что уже есть на машине.
Это пространство имён со свободными функциями. Его используют [GameLauncher](GameLauncher.md) при `JavaLocator` отвечает за поиск: собирает все доступные java, определяет их версии и выбирает
запуске, [LauncherBackend](LauncherBackend.md) для списка в настройках и для шага 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) и #### QStringList findAll(const QString &gameDir)
`jre/bin`.
## majorVersion(javaPath) Все java, которые удалось найти, без дублей и в порядке приоритета. Просматриваются четыре
источника: рантайм самого Minecraft внутри папки игры, переменная `JAVA_HOME`, переменная `PATH` и
стандартные каталоги установки JDK и JRE.
Запускает `java -version` и достаёт мажорную версию из строки `version "..."`. Старая схема Параметр `gameDir` — папка игры; из неё берётся первый источник.
`1.8.0_x` даёт 8. Если процесс не ответил за 5 секунд или вывод не разобрался, возвращается 0.
Результат кэшируется в статической таблице на всё время работы лаунчера. Если 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 #### QString windowlessVariant(const QString &javaPath)
выиграет у системной Java 21, если игре нужна 17.
## 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: виды сборок, строка каталога, запись об установленной сборке, работа с папкой `Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру
`<root>/java` и таблица «какая Java нужна какой версии игры». запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки
живут в папке лаунчера, по одной подпапке на сборку.
## Виды сборок Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две
структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой
`<root>/java` и таблицу требований версий игры к Java.
| `JavaRuntimeKind` | Ключ | Что это | Заголовок подключают [JavaRuntimeService](JavaRuntimeService.md) (каталог),
|-------------------|------|---------| [JavaInstaller](JavaInstaller.md) (скачивание и распаковка) и
| `Mojang` | `java` | сборки, которые Mojang раздаёт для своего лаунчера | [LauncherBackend](LauncherBackend.md) (отдача каталога в QML).
| `Jdk` | `jdk` | Eclipse Temurin JDK |
| `Jre` | `jre` | Eclipse Temurin JRE |
`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` | | `Mojang` | `java` | Тот же рантайм, которым игру запускает официальный лаунчер |
| `kind` | | | `Jdk` | `jdk` | Eclipse Temurin JDK: компилятор и инструменты в комплекте |
| `major` | 8, 17, 21… | | `Jre` | `jre` | Eclipse Temurin JRE: только то, что нужно для запуска |
| `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` | |
## InstalledJavaRuntime Эти же ключи служат именами категорий в окне выбора Java.
Скачанная сборка: `id`, `kind` (ключ), `major`, `version`, `javaPath` (абсолютный путь к ### JavaRuntimeEntry
`java`/`java.exe`), `size` (сколько заняла на диске).
## 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 ### InstalledJavaRuntime
{ "id": "...", "kind": "jdk", "major": 21, "version": "21.0.7", "javaPath": "bin/java", "size": 197132288 }
| Поле | Тип | Описание |
|------|-----|----------|
| `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` рядом со стандартной папкой игры, а не в системном Собственная папка `BiLauncher` лежит рядом со стандартной `.minecraft`, а не в системном
каталоге данных приложения. `QStandardPaths` не используется, поэтому имя приложения и организации каталоге данных приложения: так все файлы лаунчера остаются там же, где сама игра, и переносятся
из `main.cpp` на эти пути не влияют. вместе с ней.
## Где что лежит К этому заголовку обращается почти каждый сервис проекта — везде, где нужно прочитать или записать
файл в папке лаунчера.
`containerDir()` — папка, в которой живут и игра, и лаунчер: ## Пространства имён
| ОС | `containerDir()` | папка игры по умолчанию | `rootDir()` | `LauncherPaths` группирует функции, возвращающие абсолютные пути, и одну функцию создания корневой
|----|------------------|-------------------------|-------------| папки. Состояния у пространства имён нет: все функции вычисляют путь заново при каждом вызове.
| Windows | `%USERPROFILE%/AppData/Roaming` | `.minecraft` | `BiLauncher` |
| macOS | `~/Library/Application Support` | `minecraft` (без точки, как у официального лаунчера) | `BiLauncher` |
| Linux | `~` | `.minecraft` | `BiLauncher` |
На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая. ## Функции
## BILAUNCHER_HOME ### Корневые каталоги
`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он #### QString containerDir()
заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая
папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt
(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит.
Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `BiLauncher`. От неё
у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего отсчитываются и папка игры по умолчанию, и корень данных лаунчера.
`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на
Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp`
при старте пишет в лог предупреждение с итоговой папкой лаунчера.
Внутри `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`), `main.cpp` — стартовая последовательность приложения: здесь при необходимости инициализируется
`QtWebEngineQuick::initialize()` вызывается до создания `QGuiApplication`. После создания Qt WebEngine, создаётся объект приложения, читается каталог переводов, выбирается стиль
приложения общий контекст OpenGL уже не выставить. Qt Quick Controls, загружается QML-модуль и запускается цикл событий. Файл намеренно короткий:
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет. ни одного объекта предметной области он не создаёт — всё, что нужно, QML заводит сам.
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()`.
Аргументы командной строки не разбираются. Контекстных свойств нет: QML общается с C++ только ## Настройка приложения Qt
через зарегистрированные типы `LauncherBackend` и `Loc`.
Создаётся `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` и всё, что с ними связано: правила Каждая версия Minecraft описывается файлом `versions/<id>/<id>.json` — в нём главный класс,
Mojang, нативные библиотеки под текущую платформу, список установленного, удаление. аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют
цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою
очередь, может наследоваться дальше.
Профиль модлоадера обычно содержит только отличия от базовой версии и ссылается на неё через Заголовок `minecraftversion.h` даёт две вещи: структуры, описывающие версию в разобранном виде, и
`inheritsFrom`. `VersionLoader::load()` проходит эту цепочку и сводит её в одну структуру пространство имён `VersionLoader` — чтение, разворачивание цепочки наследования и работа с
`MinecraftVersion`, с которой уже работают [GameLauncher](GameLauncher.md) и установленными версиями на диске.
[VersionInstaller](VersionInstaller.md).
## MinecraftLibrary Заголовок подключают [GameLauncher](GameLauncher.md) (собирает из версии командную строку),
[VersionInstaller](VersionInstaller.md) (по списку библиотек понимает, что качать) и
[LauncherBackend](LauncherBackend.md) (проверяет комплектность и удаляет версии).
Одна библиотека, к которой уже применены правила. ## Типы
| Поле | Что это | | Имя | Вид | Описание |
|------|---------| |-----|-----|----------|
| `name` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` | | `MinecraftLibrary` | `struct` | Одна библиотека из `client.json` с уже разрешёнными правилами |
| `path` | путь относительно `libraries/` | | `MinecraftVersion` | `struct` | `client.json`, «схлопнутый» по всей цепочке `inheritsFrom` |
| `url` | откуда качать; может быть пустым |
| `sha1`, `size` | для проверки; могут отсутствовать |
| `native` | распаковывается в `natives`, в classpath не идёт |
| `extractExclude` | префиксы путей внутри jar, которые не распаковываются |
## MinecraftVersion ### MinecraftLibrary
| Поле | Что это | | Поле | Тип | Описание |
|------|---------| |------|-----|----------|
| `id` | имя папки в `versions/` | | `name` | `QString` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
| `mainClass` | | | `path` | `QString` | Путь относительно `.minecraft/libraries` |
| `type` | `release`, `snapshot` и т. д.; по умолчанию `release` | | `url` | `QString` | Откуда качать, если файла нет; может быть пустым — тогда файл должен уже лежать на месте |
| `assetIndexId` | id индекса ресурсов | | `sha1` | `QString` | Контрольная сумма для проверки скачанного |
| `assetsKind` | поле `assets`: `legacy`, `pre-1.6` или тот же id | | `size` | `qint64` | Размер в байтах; `0` — неизвестен |
| `clientJarPath` | абсолютный путь к клиентскому jar; часто это jar родителя | | `native` | `bool` | Библиотека распаковывается в `natives`, а не кладётся в classpath |
| `javaMajor` | из `javaVersion.majorVersion`; по умолчанию 8 | | `extractExclude` | `QStringList` | Префиксы путей внутри архива, которые не распаковываются |
| `jvmArgs`, `gameArgs` | с ещё не подставленными `${...}` |
| `libraries` | |
| `loggingArgument`, `loggingConfigPath` | аргумент log4j и путь к его xml в `assets/log_configs` |
| `supportsQuickPlay` | среди аргументов игры есть `quickPlayMultiplayer` (1.20+) |
| `hasCustomResolutionArgs` | описание само передаёт `resolution_width` |
`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)` идёт от запрошенной версии к родителям по Метод `isValid()` возвращает `true`, когда заполнены и `id`, и `mainClass`: именно этим проверяется
`inheritsFrom`. Ограничения: не больше 10 звеньев, без циклов. Если файл отсутствует или не успешность чтения версии.
разбирается, это ошибка, и возвращается невалидный объект.
Дальше поля применяются от корня к потомку: ## Функции
- `mainClass`, `type`, `assets`, `assetIndex`, `javaVersion`, `logging` — потомок перекрывает Пространство имён `VersionLoader`.
родителя;
- `minecraftArguments` (старый формат, одна строка) **заменяет** `gameArgs` целиком;
- `arguments.jvm` и `arguments.game` (новый формат) **дописываются** к тому, что дал родитель.
Библиотеки собираются в обратном порядке, от потомка к корню. Повторы убираются по ключу #### QStringList installedVersions(const QString &gameDir)
`группа:артефакт[:классификатор]`, без версии. Поэтому если Forge приносит свою версию библиотеки,
она вытесняет ванильную.
Если среди JVM-аргументов нет `-cp`, значит это старая версия без блока `arguments.jvm`. Тогда Версии, реально установленные в `<gameDir>/versions`: есть и папка, и файл `<id>.json`. Одной
добавляются аргументы, которые официальный лаунчер подставляет сам: `-Djava.library.path`, бренд и только папки недостаточно — она остаётся после неудачной установки.
версия лаунчера, `-cp ${classpath}`.
Клиентский jar берётся первый найденный по цепочке, от потомка к корню. Если его нет нигде, #### QStringList dependentsOf(const QString &gameDir, const QString &versionId)
указывается путь у корня. `GameLauncher::missingFiles()` потом скажет, что его не хватает.
`features` нужен для правил вида `"features": {"has_custom_resolution": true}`. Бэкенд передаёт Установленные профили, у которых `inheritsFrom` равен `versionId`. Без базовой версии они не
`has_custom_resolution`, если в настройках задан размер окна. запустятся, поэтому список показывается пользователю перед удалением версии.
## Правила и натив #### qint64 installedSize(const QString &gameDir, const QString &versionId)
`rulesAllow(rules, features)` реализует алгоритм Mojang: правила проверяются по порядку, Размер `<gameDir>/versions/<id>` в байтах; `0`, если папки нет. Используется в предупреждении об
действует последнее совпавшее; пустой список означает «разрешено». Правило совпадает, если подошли удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится.
`features`, `os.name`, `os.arch` и регулярное выражение `os.version` (сверяется с
`QSysInfo::productVersion()`).
Нативные библиотеки встречаются в двух форматах: #### bool remove(const QString &gameDir, const QString &versionId, QString \*error)
- старый: блок `natives` с классификатором под ОС, `${arch}` заменяется на `32` или `64`; Сносит `<gameDir>/versions/<id>`. Отсутствие папки считается успехом. При неудаче возвращает
- новый (LWJGL 3.3+): отдельные библиотеки с классификатором `natives-<ос>[-<арх>]`. `false` и заполняет `error`.
`pickNativesForCurrentArch()` оставляет для каждого артефакта один классификатор. Лучше всего Библиотеки и ресурсы в `libraries/` и `assets/` не трогаются: они общие для всех версий.
точный (`nativeClassifier()`, например `natives-macos-arm64`), потом общий для ОС
(`natives-macos`), потом какой попадётся.
`osName()` возвращает `windows`, `osx` или `linux`. `osArch()` возвращает архитектуру из #### MinecraftVersion load(const QString &gameDir, const QString &versionId, const QSet&lt;QString&gt; &features, QString \*error)
`QSysInfo`, причём `i386` и `i686` сводятся к `x86`.
## Установленные версии Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` —
набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский
размер окна).
- `installedVersions(gameDir)` — подпапки `versions/`, в которых есть `<id>.json`, по алфавиту. При ошибке возвращает объект, у которого `isValid()` даёт `false`, и заполняет `error`.
- `dependentsOf(gameDir, id)` — установленные профили с `inheritsFrom == id`. Без базовой версии они
не запустятся, поэтому их перечисляют в предупреждении об удалении. #### bool rulesAllow(const QJsonArray &rules, const QSet&lt;QString&gt; &features = {})
- `installedSize(gameDir, id)` — размер папки в байтах.
- `remove(gameDir, id, &error)` — удаляет `versions/<id>`. Сначала проверяет, что id не пустой, Стандартный алгоритм Mojang для блоков `rules`: правила применяются по порядку, побеждает последнее
не начинается с точки и не содержит слэшей, а итоговый путь лежит внутри `versions/`. Если папки совпавшее. Используется и для библиотек, и для аргументов запуска.
нет, это не ошибка.
#### 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/`, поэтому у сборки Заголовок подключают [ModLoaderVersionService](ModLoaderVersionService.md),
может быть не больше одного модлоадера. [LoaderRow](../qml/LoaderRow.md) снимает отметку с [ModLoaderInstaller](ModLoaderInstaller.md) и [LauncherBackend](LauncherBackend.md).
остальных, когда пользователь отмечает один.
## Ключи и названия ## Типы
| Значение | `loaderKey()` | `loaderTitle()` | | Имя | Вид | Описание |
|----------|---------------|-----------------| |-----|-----|----------|
| `Forge` | `forge` | Minecraft Forge | | `ModLoader` | `enum class` | Модлоадеры, которые лаунчер умеет ставить |
| `Fabric` | `fabric` | Fabric Loader | | `LoaderVersionEntry` | `struct` | Одна сборка модлоадера под конкретную версию игры |
| `NeoForge` | `neoforge` | NeoForge |
| `Quilt` | `quilt` | Quilt Loader |
Ключ используется в QML, в имени файла кэша (`cache/loader_<ключ>.json`) и в поле `loader` записи ### ModLoader
сборки в `customBuilds.json`. Если его поменять, старые сборки и кэши перестанут читаться.
`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` | | `loaderVersion` | `QString` | Версия самого лоадера — например `47.4.0`, `0.19.3` или `21.1.248` |
| `gameVersion` | `1.20.1` | | `gameVersion` | `QString` | Версия Minecraft, под которую эта сборка — например `1.20.1` |
| `versionId` | ожидаемое имя профиля в `versions/`. У Fabric и Quilt оно известно заранее (`fabric-loader-<версия>-<игра>`), у Forge и NeoForge пусто: имя становится известно только после работы установщика | | `versionId` | `QString` | Предсказанный идентификатор профиля `versions/<id>`. У Forge пуст: он выясняется только после работы установщика |
| `installerUrl` | ссылка на `installer.jar`, только у Forge и NeoForge | | `installerUrl` | `QUrl` | Адрес установщика; заполнен только у Forge и NeoForge |
| `recommended` | у Forge помечено как recommended в `promotions_slim.json` | | `recommended` | `bool` | Сборка помечена авторами как рекомендуемая; по умолчанию `false` |
| `stable` | не альфа, не бета, не rc/pre | | `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 не ставятся на системах, где вместо `Minecraft_launcher` умеет ставить модлоадеры Forge и NeoForge, а их установщики — это Java-программы,
zlib стоит zlib-ng. которые собирают часть jar-файлов прямо на машине пользователя и сверяют sha1 каждого собранного
файла с эталоном из `install_profile.json`.
## В чём проблема Эталон посчитан на обычном zlib. Дистрибутивы вроде CachyOS и Fedora подставляют вместо него
zlib-ng: сжатие корректное, но побайтово другое — поэтому установка падает на любой версии игры
сообщением «Processor failed, invalid outputs». Сменить Java не выйдет: сборки OpenJDK под Linux
берут `libz.so.1` из системы.
Установщики Forge и NeoForge собирают часть jar прямо на машине пользователя и сверяют sha1 `ZlibReference` — обход этой проблемы. Рядом с лаунчером лежит собранный обычный zlib, и процессу
каждого собранного файла с эталоном из `install_profile.json`. Эталон посчитан на обычном zlib. установщика он подсовывается через `LD_PRELOAD`. Системный zlib-ng при этом остаётся на месте:
CachyOS, Fedora и некоторые другие дистрибутивы ставят вместо него zlib-ng в режиме совместимости. подмена живёт ровно один процесс.
Он сжимает корректно, но результат побайтово другой. Поэтому установка падает на любой версии игры
с сообщением `Processor failed, invalid outputs`.
Другая Java не помогает: сборки OpenJDK под Linux берут `libz.so.1` из системы. Пространство имён используется установщиком модлоадеров при подготовке окружения дочернего
процесса Java.
## Что сделано ## Пространства имён
В репозитории лежат исходники zlib 1.3.1 (каталог `zlib/`). На Linux CMake собирает из них цель `ZlibReference` группирует три функции: проверку системной библиотеки, поиск собранного эталона и
`launcher_zlib_reference` (`libz.so.1`). В дереве сборки она лежит в `<папка бинарника>/zlib/`, при подготовку окружения процесса. Состояния нет.
установке — в `<prefix>/lib/BiLauncher/`. Сам лаунчер с ней не линкуется. Библиотека
подкладывается через `LD_PRELOAD` только процессу установщика, системный zlib-ng не трогается.
Исходники лежат в репозитории, а не скачиваются при сборке, по двум причинам: сборка не зависит от
сети, и версия зафиксирована. От версии зависит побайтовый результат сжатия.
## Функции ## Функции
На Windows и macOS все три функции ничего не делают: `systemIsZlibNg()` возвращает `false`, #### bool systemIsZlibNg()
`bundledPath()` пустую строку, `applyTo()` — `true`.
### systemIsZlibNg() Отвечает на вопрос, окажется ли `libz.so.1`, который достанется процессу java, библиотекой zlib-ng.
От ответа зависит, нужна ли подмена вообще: на системе с обычным zlib она бессмысленна.
Открывает `libz.so.1` через `dlopen`, то есть ту же библиотеку, которую загрузчик выдаст процессу #### QString bundledPath()
java, и смотрит, есть ли `zlib-ng` в строке `zlibVersion()`. Результат вычисляется один раз за
запуск.
### 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` — запуск из дерева сборки; Дописывает `LD_PRELOAD` в переданное окружение, если подмена нужна. Изменяет `env` на месте.
2. `<applicationDirPath>/../lib/BiLauncher/libz.so.1` — установленный пакет, tar.gz и AppImage;
3. `LAUNCHER_ZLIB_INSTALL_DIR/libz.so.1` — путь, заданный при конфигурации CMake.
Второй вариант рассчитан на `CMAKE_INSTALL_LIBDIR=lib`. Поэтому CI на Ubuntu передаёт его явно, Возвращает `true`, когда окружение готово, — в том числе в случае, когда подменять нечего:
иначе библиотека легла бы в `lib/x86_64-linux-gnu`. система с обычным 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()` Подключает `QString`; `QProcessEnvironment` объявлен вперёд и используется только по ссылке.
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая Реализация опирается на макрос `LAUNCHER_ZLIB_INSTALL_DIR`, который `CMakeLists.txt` определяет на
подсказка про zlib-ng. 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. Всё остальное, то есть вход в аккаунт, загрузка Десктопный лаунчер Minecraft на Qt 6. Интерфейс написан на QML, вся работа — авторизация,
версий, установка модлоадеров и Java, смена сборок и запуск игры, написано на C++. скачивание версий, установка модлоадеров и Java, запуск игры — лежит в C++.
Здесь описано, как устроен код. Как собрать проект и выпустить релиз, рассказано в Документация разделена на две части: [QML-компоненты](#qml-компоненты) и
[build-and-release.md](build-and-release.md), как устроены и запускаются автотесты — в [C++-классы](#c-классы).
[testing.md](testing.md).
## Общая схема ## Как устроено приложение
``` Слоёв четыре, и каждый общается только с соседними:
QML (Main.qml и диалоги)
│ свойства, вызовы, сигналы
▼
LauncherBackend ──────────────── Loc (Localization)
│ владеет
▼
сервисы: каталоги, установщики, авторизация, BuildSwitcher, GameLauncher
│
▼
сеть, файлы, дочерние процессы
```
1. **QML.** [Main.qml](qml/Main.md) — единственное окно приложения. Остальные окна — `Dialog` внутри 1. **QML.** [Main](qml/Main.md) — единственное настоящее окно и точка входа. Оно держит
него. Main создаёт один `LauncherBackend` и передаёт его диалогам через `required property var единственный экземпляр `LauncherBackend` и раздаёт его вложенным диалогам через свойство
backend`. К сервисам QML не обращается. `backend`. Ни один QML-файл не обращается к сервисам напрямую.
2. **[LauncherBackend](cpp/LauncherBackend.md)** хранит профили, сборки и настройки, владеет 2. **Фасад.** [LauncherBackend](cpp/LauncherBackend.md) — главный класс, видимый из QML.
двенадцатью сервисами и следит, чтобы их операции не пересекались. Каталоги он отдаёт в QML Хранит профили, сборки и настройки, владеет двенадцатью сервисами и сводит их состояние к
уже сведёнными с тем, что лежит на диске, поэтому окна сами ничего не считают. свойствам, которые читает интерфейс. Каталоги он отдаёт уже сведёнными с локальным состоянием,
3. **Сервисы.** Каждый занят одним делом и про интерфейс не знает. поэтому окна показывают статус, не считая ничего сами.
4. **Внешний мир.** Серверы Mojang, Ely.by, Microsoft/Xbox, Adoptium, Fabric/Quilt/Forge/NeoForge и 3. **Сервисы.** Каталоги (версий, модлоадеров, Java, сезонных сборок), установщики, службы
свой сервер сезонных сборок. Файлы: `.minecraft` и папка лаунчера. Процессы: java с игрой, авторизации, переключатель сборок и запуск игры. Каждый занят одним делом и ничего не знает об
установщики Forge/NeoForge, `tar`. интерфейсе.
Особняком стоит [Localization](cpp/Localization.md) — второй и последний тип, видимый из QML.
Это синглтон `Loc`, через который проходят все тексты интерфейса: `Loc.t.домен.вид.имя` в QML и
`Loc::text("домен.вид.имя")` в C++. Он ни от чего не зависит и доступен всем слоям сразу.
Отдельно стоит [Localization](cpp/Localization.md), синглтон `Loc`. Через него идут все тексты 4. **Внешний мир.** Сеть (Mojang, Ely.by, Microsoft, Adoptium, файловый сервер сборок), файловая
интерфейса: `Loc.t.домен.вид.имя` в QML и `Loc::text("домен.вид.имя")` в C++. Он ни от чего не система (`.minecraft` и папка лаунчера) и дочерние процессы (java, установщики модлоадеров,
зависит, и обращаться к нему можно из любого слоя. `tar`).
Почти всё работает в GUI-потоке. Параллельность даёт асинхронная сеть, а не потоки. Исключение — Почти весь код работает в потоке GUI: параллелизм даёт асинхронная сеть, а не потоки. Единственное
файловые операции над `.minecraft` (это могут быть гигабайты модов и миров). Они вынесены в исключение — операции над содержимым `.minecraft` (гигабайты модов и миров), вынесенные в
отдельный поток в [BuildArchiveWorker](cpp/BuildArchiveWorker.md). отдельный поток к [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) | Подпись и поле ввода одной колонкой |
**Сборка** — версия игры, модлоадер (или без него), адрес сервера и собственное содержимое ## C++-классы
`.minecraft`: моды, конфиги, миры. Активная сборка одна, её файлы разложены в `.minecraft`, у
остальных файлы лежат в архивах в папке лаунчера. Папки `versions`, `libraries`, `assets`,
`runtime` общие для всех сборок. См. [BuildSwitcher](cpp/BuildSwitcher.md).
**Сезонная сборка** — готовая сборка, которую публикует команда на своём сервере. Ставится одной ### Точка входа
кнопкой вместе с версией, модлоадером и 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) | главное окно: экран запуска, профили, настройки, плашка сообщений, панели прогресса | | [Localization](cpp/Localization.md) | Синглтон `Loc`: все тексты интерфейса в одном файле `i18n/translations.json`, переключение языка на лету |
| [BuildsDialog](qml/BuildsDialog.md) | пользовательские сборки: список и карточка | | [LauncherBackend](cpp/LauncherBackend.md) | Единственный тип, видимый из QML: 26 свойств, 42 вызываемых метода, состояние лаунчера и порядок работы всех сервисов |
| [VersionPickerDialog](qml/VersionPickerDialog.md) | выбор версии Minecraft, удаление скачанных |
| [JavaPickerDialog](qml/JavaPickerDialog.md) | выбор и загрузка Java |
| [SeasonalBuildsDialog](qml/SeasonalBuildsDialog.md) | каталог сезонных сборок |
| [MicrosoftLoginDialog](qml/MicrosoftLoginDialog.md) | вход Microsoft во встроенном браузере; только с WebEngine |
| [LoaderRow](qml/LoaderRow.md) | строка модлоадера в карточке сборки |
| [ProgressPanel](qml/ProgressPanel.md) | плашка прогресса |
| [DarkCombo](qml/DarkCombo.md) | выпадающий список в стиле окна |
| [LabelledField](qml/LabelledField.md) | подпись и поле ввода |
## C++ ### Авторизация и запуск
### Вход и фасад | Класс | Описание |
|-------|----------|
| | | | [AuthService](cpp/AuthService.md) | Вход через Ely.by и офлайн-режим; структура `AuthResult` |
|---|---| | [MsaAuthService](cpp/MsaAuthService.md) | Вход через аккаунт Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
| [main.cpp](cpp/main.md) | запуск приложения | | [GameLauncher](cpp/GameLauncher.md) | Сборка командной строки, распаковка нативных библиотек и запуск JVM |
| [LauncherBackend](cpp/LauncherBackend.md) | всё, что видит QML |
| [Localization](cpp/Localization.md) | тексты интерфейса, `i18n/translations.json` |
### Вход в аккаунт и запуск
| | |
|---|---|
| [AuthService](cpp/AuthService.md) | Ely.by, офлайн, authlib-injector; структура `AuthResult` |
| [MsaAuthService](cpp/MsaAuthService.md) | Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
| [GameLauncher](cpp/GameLauncher.md) | командная строка, нативные библиотеки, процесс игры |
### Версии игры ### Версии игры
| | | | Класс | Описание |
|---|---| |-------|----------|
| [VersionManifestService](cpp/VersionManifestService.md) | каталог версий Mojang | | [VersionManifestService](cpp/VersionManifestService.md) | Каталог версий Mojang с кэшем в папке лаунчера |
| [VersionInstaller](cpp/VersionInstaller.md) | установка версии: jar, библиотеки, ресурсы | | [VersionInstaller](cpp/VersionInstaller.md) | Фоновая установка версии: jar, библиотеки, индекс ресурсов и сами ресурсы |
| [minecraftversion.h](cpp/minecraftversion.md) | чтение описаний версий, цепочка `inheritsFrom`, правила Mojang |
### Модлоадеры ### Модлоадеры
| | | | Класс | Описание |
|---|---| |-------|----------|
| [modloader.h](cpp/modloader.md) | перечисление модлоадеров и ключи | | [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | Списки версий Forge, Fabric, NeoForge и Quilt; совместимость заложена в структуру данных |
| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | версии Forge, Fabric, NeoForge, Quilt | | [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | Два пути установки под одним фасадом: готовое описание версии либо запуск `installer.jar` |
| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | установка: готовый профиль или `installer.jar` |
| [zlibreference.h](cpp/zlibreference.md) | обход zlib-ng для установщиков Forge/NeoForge на Linux |
### Java ### Java
| | | | Класс | Описание |
|---|---| |-------|----------|
| [javaruntime.h](cpp/javaruntime.md) | типы, папка `<root>/java`, какая Java нужна какой версии игры | | [JavaRuntimeService](cpp/JavaRuntimeService.md) | Каталог сборок Mojang и Eclipse Temurin под текущую платформу |
| [JavaRuntimeService](cpp/JavaRuntimeService.md) | каталог Mojang и Temurin | | [JavaInstaller](cpp/JavaInstaller.md) | Установка сборки Java: архив Temurin либо дерево файлов Mojang |
| [JavaInstaller](cpp/JavaInstaller.md) | установка: архив Temurin или дерево файлов Mojang |
| [javalocator.h](cpp/javalocator.md) | поиск java в системе |
### Сборки ### Сборки и их содержимое
| | | | Класс | Описание |
|---|---| |-------|----------|
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения | | [BuildSwitcher](cpp/BuildSwitcher.md) | Порядок шагов смены активной сборки и восстановление после прерванной операции |
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке | | [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | Упаковка, очистка, распаковка и раскатка пака в отдельном потоке |
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
## Прочее в репозитории ### Сезонные сборки
| Путь | Что это | | Класс | Описание |
|------|---------| |-------|----------|
| `i18n/translations.json` | все тексты интерфейса | | [SeasonalBuildService](cpp/SeasonalBuildService.md) | Каталог готовых сборок с файлового сервера |
| `tools/check_translations.py` | проверка каталога переводов | | [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | Загрузка архива сборки потоком с проверкой sha256 |
| `tests/` | автотесты, см. [testing.md](testing.md) |
| `icons/` | иконки и скрипт, который их генерирует | ### Общие типы и утилиты
| `images/` | картинки интерфейса |
| `zlib/` | исходники zlib 1.3.1 (чужой код) | | Файл | Описание |
| `packaging/` | установщик Windows, лицензии сторонних компонентов | |------|----------|
| `ci/` | скрипты подготовки раннеров | | [minecraftversion.h](cpp/minecraftversion.md) | Структуры версии и библиотеки, чтение и разворачивание цепочки `inheritsFrom` |
| `.gitea/workflows/release.yml` | сборка релизов | | [javaruntime.h](cpp/javaruntime.md) | Виды сборок Java, папка `<root>/java` и таблица требований версий игры |
| `.gitea/workflows/tests.yml` | автотесты на тех же раннерах | | [modloader.h](cpp/modloader.md) | Перечисление модлоадеров, запись версии и перевод ключей |
| `Screenshots/` | картинки для README | | [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_launcher` — десктопный лаунчер Minecraft на Qt Quick. Сборка в нём — это именованный
(моды, конфиги, миры). Активная сборка одна, её файлы разложены в `.minecraft`, остальные лежат в набор «версия игры + модлоадер + адрес сервера» вместе с собственным содержимым `.minecraft`:
архивах. Подробнее в [BuildSwitcher](../cpp/BuildSwitcher.md). модами, конфигами и мирами. Активная сборка одна, её содержимое лежит в `.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` | | `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — хранилище сборок и исполнитель установки, удаления и переключения. |
| `editIndex` | сборка, открытая в карточке; −1 — ничего не открыто | | `editIndex` | `int` | `-1` | Нет | Индекс сборки, открытой в карточке справа. Значение `-1` означает, что карточка пуста и выключена. С активной сборкой не связан: активную меняет отдельная кнопка, чтобы случайный клик по списку не запускал архивацию `.minecraft`. |
| `loading` | карточка сейчас заполняется данными сборки, обработчики полей ничего не записывают | | `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) список сборок. У активной пометка «активна», при наведении появляется корзина. Функции панели: `applyBuild(loader, loaderVersion)` раздаёт данные сборки всем строкам;
Ниже строка «+ Новая сборка»: создаёт сборку с первым свободным именем вида «Сборка N» и сразу `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
Отдельной кнопки «Сохранить» нет. Каждое поле записывается сразу после правки через Открывает сборку с указанным индексом в карточке. Читает данные через `customBuildAt()`,
`commit(fields)` → `updateCustomBuild()`. Иначе пришлось бы как-то показывать несохранённые правки выставляет `loading` на время заполнения полей, раздаёт лоадеры панели через `applyBuild()` и в
при переключении между сборками в списке. конце обновляет строку состояния.
Чтобы открытие сборки не записывало её саму в себя, на время `selectBuild()` выставляется #### askRemove(int index) : void
`loading`, и `commit()` в это время ничего не делает.
## Модлоадеры Спрашивает подтверждение перед удалением: оно необратимо и уносит с собой архив сборки. Запрашивает
`customBuildRemovalInfo()` и наполняет окно подтверждения именем сборки и тремя признаками — есть
ли у неё архив, активна ли она сейчас и последняя ли она. Индекс запоминается в самом окне
подтверждения, потому что к моменту ответа строка списка под курсором может быть уже другой.
`loaderPanel` держит четыре строки и следит, чтобы был отмечен не больше чем один модлоадер: игра Окно подтверждения объясняет последствия по этим признакам: вместе со сборкой удалится её архив, и
запускается с одним профилем из `versions/`. Ни одной отметки — ванильная сборка. моды, конфиги и миры восстановить будет нельзя; активная сборка владеет содержимым `.minecraft`,
оно будет очищено, а на его место развернётся следующая сборка; для последней сборки содержимое
`.minecraft` остаётся на месте.
- `applyBuild(loader, loaderVersion)` — раздать данные сборки строкам; #### performRemove(int index) : void
- `selectedRow()` — отмеченная строка или `null`;
- `keepOnly(row)` — снять отметки с остальных (на `userChecked` строки);
- `commitSelection()` — записать `loader`, `loaderVersion` и пустой `resolvedVersionId` (на
`changed` строки). Профиль модлоадера появится только после установки, а до этого сборка
запускается как ванильная.
Строки получают `gameVersion` привязкой к выбранной версии, поэтому смена версии игры сама Удаляет сборку через бэкенд и восстанавливает состояние карточки: если сборок не осталось,
перезапрашивает их списки. сбрасывает `editIndex` в `-1`, иначе открывает соседнюю — ту же позицию или последнюю оставшуюся.
## Удаление #### commit(var fields) : void
`askRemove(index)` берёт `customBuildRemovalInfo()` и открывает подтверждение. Текст зависит от Сохраняет часть полей сборки: передаёт `QVariantMap` в `updateCustomBuild()` и обновляет строку
трёх признаков: состояния. Ничего не делает во время заполнения карточки (`loading`) и при пустом `editIndex` —
это и есть защита от записи при открытии сборки.
- `hasArchive` — вместе со сборкой удалится её архив, моды, конфиги и миры будет не вернуть; Вызывается по завершении правки каждого поля: имени, адреса сервера, версии Minecraft и выбора
- `active` — содержимое `.minecraft` принадлежит этой сборке, оно будет очищено, и на его место модлоадера.
развернётся следующая;
- `lastOne` — для последней сборки содержимое `.minecraft` остаётся как есть.
Индекс запоминается в самом окне подтверждения, потому что к моменту ответа под курсором может #### refreshStatus() : void
оказаться другая строка.
`performRemove(index)` удаляет сборку и открывает соседнюю (ту же позицию или последнюю), а если Пересчитывает строку комплектности под карточкой. Спрашивает у бэкенда `checkInstallation()` и
сборок не осталось, очищает карточку. показывает либо сообщение о готовности к запуску, либо число недостающих файлов вместе с первым из
них. При пустом `editIndex` очищает строку.
## Когда окно открывается #### newBuildName() : string
`onAboutToShow` просит обновить каталог версий и открывает активную сборку (или первую). Через Придумывает имя для новой сборки: перебирает «Сборка 1», «Сборка 2» и так далее, пока не найдёт
`Connections` окно слушает `installedVersionsChanged` и обновляет строку комплектности: установка свободное среди существующих имён.
могла закончиться, пока окно открыто.
## Взаимодействие с другими компонентами
**Со стороны родителя.** [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 с полосой прокрутки.
Стрелка взята та же, что у списков профиля и версии на главном окне `Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Всё окно оформлено вручную в
(`images/Profile_Box/Asset_23/24.svg`), вместо двойного шеврона стиля `Basic`. тёмной теме, а стиль Qt Quick Controls принудительно выставлен в `Basic` (`main.cpp`), потому что
нативные стили игнорируют пользовательские `contentItem` и `background`. Из-за этого каждый
стандартный элемент управления, попадающий в интерфейс, приходится переопределять самому.
Новых свойств нет, используется как обычный `ComboBox`. Есть одно ограничение: делегат выводит `DarkCombo` — как раз такое переопределение: выпадающий список в общем тёмном стиле окна.
`modelData` как текст, поэтому модель должна быть списком строк. `textRole` не поддерживается. Компонент не добавляет логики, он целиком про внешний вид — фон, рамку, стрелку, делегат строки и
всплывающую панель. К нему обращаются всюду, где нужен выпадающий список внутри диалогов: выбор
версии модлоадера, выбор 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, которой нет на диске, запуску ничем не поможет — поэтому кнопка подтверждения при
необходимости сразу ставит выбранную сборку в очередь на скачивание.
| | | Окно также показывает, какая версия Java нужна выбранной версии игры, помечает сборки, которые
|---|---| для неё слишком старые, и позволяет удалять скачанное прямо из списка.
| `backend` (required) | `LauncherBackend` |
| `selectedId` | выбранная сборка |
| `category` | `java` (Mojang), `jdk`, `jre` (Temurin) |
| `filterText`, `installedOnly` | поиск и «только скачанные» |
| `requiredMajor` | минимальная Java для версии игры активной сборки; 0 — версия не выбрана |
| `openFor(runtimeId, requiredMajor)` | открыть |
| `runtimeChosen(runtimeId)` | выбор подтверждён |
## Как работает ## Место в проекте и зависимости
`openFor()` сбрасывает поиск, открывает категорию переданной сборки и просит бэкенд обновить В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
каталог Java. По «Отмене» ничего не меняется. `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`), размер и галочка у Каталог приходит из C++ уже отсортированным — новые сверху, скачанные первыми, — поэтому окно
скачанных. Сборка ниже `requiredMajor` не прячется, а помечается красноватым и подписью «слишком только отбирает записи и порядок не трогает. Запись каталога содержит поля `id`, `label`, `kind`
старая для этой версии». Она может понадобиться другой сборке. (тип сборки), `major` (мажорная версия Java числом), `installed`, `downloadable`, `lts`, `sizeMb`,
`detail`, `coverage` и `search`.
Кнопка внизу называется «Скачать», если выбранной сборки нет на диске, и «Выбрать», если есть. ## Иерархия и роль
`acceptSelection()` испускает `runtimeChosen` и, если сборки нет на диске, вызывает
`installJavaRuntime()`. Ход загрузки показывает панель главного окна.
## Удаление Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и
щелчку мимо, оформление тёмное со скруглением и акцентной рамкой.
У скачанных сборок есть корзина, и удаление происходит **без подтверждения**: Содержимое — `RowLayout` из колонки типов шириной 170 px и области версий, разделённых линией.
`removeJavaRuntime()` вызывается сразу. Сборка весит около двухсот мегабайт, и без корзины здесь Каждый пункт колонки типов показывает название и пояснение мелким шрифтом, а под списком типов —
папка лаунчера росла бы незаметно для пользователя. Если удалили сборку, выбранную в настройках, подсказка о требовании выбранной версии игры, видимая только когда это требование известно.
бэкенд очистит `javaRuntime`.
Область версий повторяет устройство окна выбора версии 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
Подпись и под ней поле ввода. Используется в настройках и в карточке сборки. ## Обзор компонента
| | | `Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick с полностью самостоятельно
|---|---| оформленным тёмным интерфейсом. В диалоге настроек и в редакторе сборок много однотипных полей
| `label` | подпись | ввода: подпись сверху, поле под ней. `LabelledField` собирает эту пару в один компонент, чтобы
| `placeholder` | текст-подсказка в пустом поле | отступы, цвета и подсветка фокуса не переписывались в каждом месте заново.
| `text` | алиас текста поля |
| `validator` | алиас валидатора поля |
| `editingFinished()` | правка закончена: поле потеряло фокус или нажат Enter |
Ширина поля равна ширине компонента, высота 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`. Через него проходят все три пути: Использует компонент [DarkCombo](DarkCombo.md) из того же QML-модуля — выпадающий список версий в
ответ из кэша, ответ сети и заполнение из сборки. Если выбранной версии в новом списке нет, она тёмном стиле окна.
заменяется первой строкой, а это самая новая версия для этой версии игры. Если при этом значение
изменилось и строка не в режиме `applying`, испускается `changed()`.
Раньше проверку делал только путь из сети, и версия модлоадера, выбранная под прошлую версию игры, Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, зарегистрирован через `QML_ELEMENT`
доживала до сохранения сборки. в модуле `Minecraft_launcher`), который передаётся снаружи в свойство `backend`. Из него
компонент вызывает `loaderVersions()`, `refreshLoaderVersions()` и `loaderVersionsLoading()`, а
также слушает сигнал `loaderVersionsChanged`.
## Откуда берётся список Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`). Инстанцируется в
[BuildsDialog](BuildsDialog.md) — по одной строке на каждый поддерживаемый лоадер.
`reload()` сначала берёт то, что уже есть в кэше (`loaderVersions()`), чтобы список появился сразу, ## Иерархия и роль
а потом просит обновление (`refreshLoaderVersions()`). Ответ приходит в
`backend.loaderVersionsChanged(key, game)`. Строка сравнивает пару со своей и игнорирует чужие
ответы: от другого модлоадера или от прошлой версии игры.
Под пустым списком написано «загрузка версий…», пока идёт запрос Корневой тип — `Column` с расстоянием 4 px. Внутри три потомка, видимость которых
(`loaderVersionsLoading()`), и «не поддерживает версию N» после. взаимоисключающая по нижней части:
`applyBuild()` берёт версию из сборки как пожелание. Если под текущую версию игры её нет, - `CheckBox` с полностью переопределённым индикатором (квадрат со скруглением и галочкой) и
`applyEntries()` заменит её на самую новую. подписью. Выключен, пока не выбрана версия игры.
- [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` и все обработчики его сигналов; Окно совмещает четыре роли. Оно держит единственный экземпляр `LauncherBackend` — весь остальной
- экран запуска: фон, кнопка игры по центру, список профилей, кнопка активной сборки, кнопки папки интерфейс получает его от главного окна. Оно рисует сам экран запуска: фоновая картинка, большая
модов, настроек и сезонных сборок; кнопка игры по центру, выпадающий список профилей, кнопка активной сборки, кнопки папки модов,
- плашка сообщений и две панели прогресса ([ProgressPanel](ProgressPanel.md)); настроек и сезонных сборок. Оно показывает обратную связь — всплывающую плашку сообщений и две
- диалоги, которые не вынесены в отдельные файлы: новый профиль, редактирование профиля, код панели хода долгих операций. И наконец, оно объявляет диалоги, которые не вынесены в отдельные
двухфакторной аутентификации, настройки запуска. файлы: создание и редактирование профиля, ввод кода двухфакторной аутентификации и настройки
запуска.
Отдельными файлами сделаны [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». Текст из Использует ресурсы из `RESOURCES` QML-модуля: фоновую картинку, три состояния кнопки запуска, по
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION` три состояния кнопок папки и настроек, стрелки выпадающих списков, `images/Trash.svg` и
(см. [main.cpp](../cpp/main.md)). `images/Pencil.svg`.
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях. ## Иерархия и роль
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
наоборот. Панель смены сборки отменить нельзя.
У заголовков и подвалов всех диалогов корнем служит `Item` с явным `implicitHeight`. У Корневой тип — `Window` размером 1280×720 px, видимое при старте. Это не переиспользуемый
`Rectangle` `implicitHeight` всегда 0, и `Dialog` не смог бы посчитать свою высоту. компонент, а точка входа приложения, поэтому раздел с примером использования здесь неприменим.
## Функции Раскладка держится на якорях относительно центральной кнопки запуска: список профилей — слева
сверху от неё, кнопка активной сборки — справа сверху, кнопки папки и настроек — под списком
профилей. Кнопка сезонных сборок стоит в правом нижнем углу: это единственная свободная часть
окна, потому что панели хода работ висят слева, а всё остальное собрано вокруг кнопки запуска.
**`showToast(text, color, timeout)`** — показать сообщение в плашке. `timeout` в миллисекундах, Панели загрузки и смены сборки имеют одни и те же якоря — они взаимоисключающи по построению:
по умолчанию 4000. `0` значит «держать до следующего сообщения». Так показываются шаги запуска, признак занятости бэкенда не даёт начать переключение во время установки и наоборот. Панель смены
чтобы сообщение не исчезало посреди долгой операции. сборки объявлена неотменяемой: отступать после очистки `.minecraft` некуда, операцию нужно довести
до конца.
**`openMicrosoftLogin(url)`** — открыть окно входа Microsoft. Окно создаётся при первом вызове Заголовки и подвалы всех диалогов сделаны на `Item` с явным `implicitHeight`, а не на
через `Qt.createComponent()` и хранится в `microsoftLoginDialog`. Объявить его прямо в файле `Rectangle`: у прямоугольника `implicitHeight` равен нулю независимо от заданной высоты, и
нельзя: `MicrosoftLoginDialog.qml` есть в модуле только в сборках с WebEngine, и в остальных `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`; `Main.qml` объявляет четыре диалога прямо в файле. Их свойства — часть состояния главного окна.
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
числе сезонной сборки) доступна только отмена на панели загрузки.
## Профили **Диалог редактирования профиля** (`editProfileDialog`):
Выпадающий список профилей переделан целиком. Наверху всплывающей панели закреплена кнопка | Свойство | Тип | По умолчанию | Описание |
«+ Добавить профиль», под ней список. У каждой строки при наведении появляются карандаш (открывает |----------|-----|--------------|----------|
редактирование через `openFor()`) и корзина (`removeProfile()`). | `editIndex` | `int` | `-1` | Индекс редактируемого профиля; `-1` — диалог не открыт ни для кого. |
| `msProfile` | `bool` | `false` | Открытый профиль имеет тип «Microsoft». |
| `msLinked` | `bool` | `false` | У профиля есть действующая сессия Microsoft — от этого зависит строка статуса и подпись кнопки входа. |
| `msName` | `string` | `""` | Ник, полученный при официальной авторизации. Показывается отдельным полем только для чтения, а не подменой поля логина: привязка сломалась бы первым же вводом в поле логина обычного профиля. |
Типы входа во всех списках идут в одном порядке: 0 — `offline`, 1 — `elyby`, 2 — `microsoft`. **Диалог двухфакторной аутентификации** (`twoFactorDialog`):
Модель берётся из `Loc.t.profile.authTypes`, и порядок в каталоге важен, потому что код сравнивает
`currentIndex` с 1 и 2. Без WebEngine третий пункт отрезается через `.slice(0, 2)`. В окне
редактирования он остаётся, если профиль уже лицензионный: иначе в сборке без WebEngine такой
профиль молча стал бы офлайновым.
**Новый профиль.** `addProfile()`, новый профиль выбирается в списке. Если тип «Microsoft», сразу | Свойство | Тип | По умолчанию | Описание |
начинается вход, потому что без него такой профиль бесполезен. Скрытые поля при сохранении не |----------|-----|--------------|----------|
читаются: в них мог остаться текст, набранный до смены типа. | `profileName` | `string` | `""` | Имя профиля, для которого запрошен код; подставляется в текст просьбы. |
**Редактирование.** Дополнительные свойства диалога: `editIndex`, `msProfile`, `msLinked` (есть ли **Диалог настроек** (`settingsDialog`):
сессия Microsoft), `msName` (ник из аккаунта). Ник показывается отдельным полем только для чтения,
а не подставляется в поле логина. Иначе привязка оборвалась бы при первом же вводе в это поле у
обычного профиля. Кнопка входа сначала сохраняет профиль с типом `microsoft` через
`updateProfile()` и только потом вызывает `startMicrosoftLogin()`. Тип могли переключить только
что, и без этого токены достались бы профилю другого типа.
## Двухфакторная аутентификация | Свойство | Тип | По умолчанию | Описание |
|----------|-----|--------------|----------|
| `javaRuntimeId` | `string` | `""` | Выбранная сборка Java из папки лаунчера. Живёт в свойстве, а не в поле ввода: её выбирают в отдельном окне, а записывается она только по «Сохранить». Пустая строка означает «искать Java в системе». |
| `javaRuntimeInfo` | `var` | `null` | Подробности выбранной сборки для строки поля. Не привязка: `javaRuntimeInfo()` — обычный вызов, и сам он не пересчитается, когда сборка докачается, поэтому значение обновляется по событиям. |
Диалог показывает имя профиля (`profileName`) и поле для кода. «Подтвердить» вызывает Типы профилей во всех выпадающих списках кодируются одинаково: позиция 0 — `offline`
`submitTwoFactorCode()`, «Отмена» — `cancelPendingLaunch()`. Если отмену не вызвать, запуск так и (офлайн, без пароля), 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`. Поэтому `Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Он поддерживает несколько
[Main](Main.md) не объявляет окно в разметке, а создаёт его через `Qt.createComponent()` при первом способов входа: офлайн-профиль, сервер Ely.by и учётную запись Microsoft. Последний путь требует
входе (`openMicrosoftLogin()`). показать пользователю настоящую страницу входа 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()`. Он передаёт адрес в Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT`), который передаётся
`backend.inspectMicrosoftRedirect()`. Разбор сделан в C++, чтобы правила совпадали с тем, как снаружи в свойство `backend`. Вызывает у него `inspectMicrosoftRedirect()`,
[MsaAuthService](../cpp/MsaAuthService.md) строит `redirect_uri`. `finishMicrosoftLogin()` и `cancelMicrosoftLogin()`.
Когда адрес совпал: **Особенность сборки.** Это единственный QML-файл проекта, который попадает в модуль условно.
`CMakeLists.txt` ищет `Qt6WebEngineQuick` через `find_package(... QUIET)`; модуль объявлен
необязательным сознательно — он ставится отдельной галочкой в установщике Qt и тянет за собой
WebChannel с Positioning, которых в типовой установке нет. Если модуль найден, файл добавляется в
`QML_FILES` и определяется макрос `LAUNCHER_HAS_WEBENGINE`; если нет — лаунчер собирается и
работает как прежде, только без входа через Microsoft. В QML это различие видно через свойство
`backend.microsoftAvailable`, и интерфейс не должен предлагать этот путь, когда оно ложно.
- окно закрывается, `codeTaken` выставляется в `true`; Инстанцируется динамически из [Main](Main.md) — главное окно создаёт диалог по требованию, потому
- если есть код, вызывается `backend.finishMicrosoftLogin(code)`; что при сборке без WebEngine самого типа в модуле не существует.
- если кода нет, вызывается `backend.cancelMicrosoftLogin()` и испускается `failed` с текстом от
Microsoft или общим «вход не завершён».
`codeTaken` нужен потому, что `WebEngineView` может сообщить об одном и том же адресе несколько ## Иерархия и роль
раз, а отдавать код дважды нельзя.
Кнопка «Отмена» выставляет `codeTaken`, закрывает окно и вызывает `cancelMicrosoftLogin()`. Щелчок Корневой тип — `Dialog` из Qt Quick Controls: модальный, 560×680 px, по центру родителя, с нулевым
мимо окна и Esc его не закрывают. внутренним отступом и `closePolicy: Popup.NoAutoClose` — окно нельзя закрыть щелчком мимо или
клавишей Escape, выход только через кнопку отмены или успешный вход.
## Куки Оформление задано вручную: тёмный фон со скруглением и акцентной рамкой, заголовок с
разделительной линией, подвал с кнопкой «Отмена».
Профиль браузера создаётся через `WebEngineProfilePrototype` без `storageName`, то есть только в Содержимое — `WebEngineView` во всю площадь с отступом 12 px и индикатор занятости по центру,
памяти. Куки живут, пока работает лаунчер, и в общий браузер не попадают. Выбрать другой аккаунт видимый на время загрузки страницы. Рядом объявлен `WebEngineProfilePrototype` без `storageName`:
в пределах одного запуска позволяет `prompt=select_account` в адресе входа. профиль без имени хранилища означает профиль без диска, поэтому куки живут только пока работает
лаунчер и в общий браузер не попадают. За выбор аккаунта в пределах сессии отвечает параметр
`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()` | нажат крестик |
Панель только показывает данные. Видимость, значения и реакцию на отмену задаёт главное окно: у Импортирует `QtQuick` и `QtQuick.Controls 2.15`; C++-типы напрямую не использует.
загрузки крестик вызывает `backend.cancelDownload()`, у смены сборки крестика нет.
Объявлен в `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` | | `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога сезонных сборок и исполнитель установки. |
| `selectedId` | выбранная строка, по `id` сборки | | `selectedId` | `string` | `""` | Нет | Идентификатор выбранной строки. Хранится по `id`, а не по индексу: список обновляется под руками, и индекс после обновления указывал бы на другую сборку. |
| `columns` | описание колонок: ключ, заголовок, ширина, выравнивание | | `columns` | `var` (только чтение) | список из семи колонок | Нет | Описание таблицы: массив объектов с полями `key` (поле записи), `title` (заголовок), `width` (ширина в пикселях) и `align` (выравнивание). Колонки: название, версия, загрузчик, число модов, начало и конец сезона, статус. Ширины собраны в одном месте, потому что их повторяют и шапка, и делегат строки. |
| `entries` | `var` (только чтение) | `backend.seasonalCatalog` | Нет | Строки каталога напрямую из бэкенда. Отбора и сортировки в окне нет. |
| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки или `null`, если ничего не выбрано. От неё зависят подвал и доступность кнопки установки. |
Выбранная строка хранится по `id`, а не по индексу: список может обновиться, пока окно открыто, ## Сигналы
и индекс начал бы указывать на другую сборку.
## Таблица Собственных сигналов компонент не объявляет. Результат работы окна виден через состояние бэкенда:
установка меняет список сборок и запускает загрузку, за ходом которой следит главное окно.
Колонки: название, версия, модлоадер с версией, число модов, начало и конец сезона, статус ## Методы
(«не установлена», «установлена», «доступно обновление»). Ширины заданы в одном месте, `columns`,
и их используют и шапка, и строки.
Таблица собрана из `Row` с колонками фиксированной ширины, а не из `TableView`. В проекте нет ни #### openCatalog() : void
одной модели на `QAbstractItemModel`, строки приходят из C++ готовыми `QVariantMap`, и заводить
модель ради семи колонок не стоит.
Строки приходят уже отсортированными и сведёнными с локальными записями: окно ничего не считает. Открывает окно. Обновление каталога происходит само в обработчике `onAboutToShow`, который просит
у бэкенда `refreshSeasonalCatalog(false)` — без принудительного обхода кэша: свежий кэш отвечает
без сети, поэтому вызов при каждом открытии ничего не стоит.
Пустой список и ошибка сервера выглядят одинаково пусто, поэтому причина пишется прямо на месте #### formatMb(bytes) : string
строк: «загрузка…», «сборок пока нет» или текст `seasonalCatalogError` красным.
Под таблицей описание выбранной сборки, ревизия, размер архива и установленная ревизия, если она Переводит размер в байтах в строку с мегабайтами и одним знаком после запятой. Для нулевого или
есть. Описания длинные и в таблицу не помещаются, а решение принимают как раз по ним. отсутствующего значения возвращает пустую строку, чтобы размер просто не попал в строку подвала.
## Кнопки #### 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()` сбрасывает поиск, переключается на категорию переданной версии (а не всегда на Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg` из
релизы), просит бэкенд обновить каталог (`refreshVersionCatalog()`) и прокручивает список к `RESOURCES` того же модуля. Инстанцируется в [BuildsDialog](BuildsDialog.md).
выбранной строке. Если версии нет в каталоге, окно открывается на релизах. По «Отмене» ничего не
меняется: результат уходит наружу только через `versionChosen`.
`versionCatalog` приходит из C++ уже отсортированным, новые сверху. `visibleEntries` только Каталог приходит из C++ уже отсортированным (новые сверху), поэтому окно только отбирает записи и
отбирает строки по категории, флажку «только скачанные» и подстроке в поле `search`, а порядок не порядок не трогает. Каждая запись каталога — объект с полями `id` (идентификатор версии), `label`
меняет. В «Прочие» попадают профили модлоадеров и всё, чего нет в манифесте Mojang. (подпись строки), `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 <QFileInfo>
#include <QHash> #include <QHash>
#include <QProcess> #include <QProcess>
#include <QRegularExpression>
#include <QtCore/private/qzipreader_p.h> #include <QtCore/private/qzipreader_p.h>
namespace { namespace {
@@ -47,11 +46,7 @@ bool isNativeLibraryFile(const QString &fileName)
for (const QString &suffix : suffixes) for (const QString &suffix : suffixes)
if (fileName.endsWith(suffix, Qt::CaseInsensitive)) if (fileName.endsWith(suffix, Qt::CaseInsensitive))
return true; return true;
// Версионированные so (libfoo.so.1, libfoo.so.1.2.3). Хвост — строго цифры, return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
// иначе в натив попадают служебные файлы LWJGL вроде liblwjgl.so.sha1 / .so.git.
static const QRegularExpression versionedSo(QStringLiteral("\\.so(\\.\\d+)+$"),
QRegularExpression::CaseInsensitiveOption);
return versionedSo.match(fileName).hasMatch();
} }
} // namespace } // namespace
+1 -1
View File
@@ -34,7 +34,7 @@ struct LaunchOptions
QString authlibInjectorApi = QStringLiteral("ely.by"); QString authlibInjectorApi = QStringLiteral("ely.by");
QString launcherName = QStringLiteral("BiLauncher"); QString launcherName = QStringLiteral("BiLauncher");
QString launcherVersion = QStringLiteral(LAUNCHER_VERSION); QString launcherVersion = QStringLiteral("1.0");
}; };
class GameLauncher : public QObject class GameLauncher : public QObject
+20 -32
View File
@@ -9,7 +9,7 @@
"en": "English" "en": "English"
}, },
"_readme": [ "_readme": [
"Файл локализации", "Единственный файл со всеми текстами интерфейса. Правится руками.",
"Ключ: <домен>.<вид>.<имя>, сегменты в lowerCamelCase.", "Ключ: <домен>.<вид>.<имя>, сегменты в lowerCamelCase.",
"Домены: app, common, settings, profile, build, seasonal, version, java,", "Домены: app, common, settings, profile, build, seasonal, version, java,",
"loader, auth, launch, game, switch, storage, zlib.", "loader, auth, launch, game, switch, storage, zlib.",
@@ -31,10 +31,6 @@
} }
}, },
"strings": { "strings": {
"app.label.version": {
"ru": "Версия: %1",
"en": "Version: %1"
},
"app.title": { "app.title": {
"ru": "BiLauncher", "ru": "BiLauncher",
"en": "BiLauncher" "en": "BiLauncher"
@@ -44,7 +40,7 @@
"en": "Open mods folder" "en": "Open mods folder"
}, },
"app.tooltip.seasonal": { "app.tooltip.seasonal": {
"ru": "Сезонные сборки от команды Galeon'а", "ru": "Сезонные сборки от команды Мельника",
"en": "Seasonal builds from Galeon team" "en": "Seasonal builds from Galeon team"
}, },
"app.tooltip.settings": { "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." "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": { "auth.msa.error.noWebEngine": {
"ru": "Эта сборка лаунчера собрана без Qt WebEngine", "ru": "Эта сборка лаунчера собрана без Qt WebEngine — показать окно входа Microsoft нечем",
"en": "This launcher build was made without Qt WebEngine" "en": "This launcher build was made without Qt WebEngine — there is nothing to show the Microsoft sign-in window with"
}, },
"auth.msa.error.noXboxProfile": { "auth.msa.error.noXboxProfile": {
"ru": "К аккаунту Microsoft не привязан профиль Xbox. Создайте его на xbox.com и повторите вход.", "ru": "К аккаунту Microsoft не привязан профиль Xbox. Создайте его на xbox.com и повторите вход.",
@@ -200,8 +196,8 @@
"en": "The Microsoft session has expired — sign in to the account again (%1)" "en": "The Microsoft session has expired — sign in to the account again (%1)"
}, },
"auth.msa.error.windowUnavailable": { "auth.msa.error.windowUnavailable": {
"ru": "Окно входа Microsoft недоступно", "ru": "Окно входа Microsoft недоступно: сборка без Qt WebEngine",
"en": "The Microsoft sign-in window is unavailable" "en": "The Microsoft sign-in window is unavailable: this build has no Qt WebEngine"
}, },
"auth.msa.error.xboxNoToken": { "auth.msa.error.xboxNoToken": {
"ru": "Xbox Live не вернул токен", "ru": "Xbox Live не вернул токен",
@@ -292,8 +288,8 @@
"en": "Pick a version" "en": "Pick a version"
}, },
"build.error.closeGameFilesMoving": { "build.error.closeGameFilesMoving": {
"ru": "Файлы игры в процессе переноса, сначала закройте игру", "ru": "Сначала закройте игру — её файлы сейчас переносятся",
"en": "Game files are being moved, close the game first" "en": "Close the game first — its files are being moved right now"
}, },
"build.error.filesIncomplete": { "build.error.filesIncomplete": {
"ru": "Файлы сборки установлены не полностью: %1\nПовторите установку сборки.", "ru": "Файлы сборки установлены не полностью: %1\nПовторите установку сборки.",
@@ -332,8 +328,8 @@
"en": "The build was not switched: %1" "en": "The build was not switched: %1"
}, },
"build.error.switchFailedDirty": { "build.error.switchFailedDirty": {
"ru": "Сборка не переключена: %1\n Повторите переключение.", "ru": "Сборка не переключена: %1\nСодержимое .minecraft осталось разобранным — повторите переключение.",
"en": "The build was not switched: %1\n Switch again." "en": "The build was not switched: %1\nThe contents of .minecraft are left half-applied — switch again."
}, },
"build.error.versionNotInstalled": { "build.error.versionNotInstalled": {
"ru": "Версия «%1» из сборки «%2» не установлена в %3", "ru": "Версия «%1» из сборки «%2» не установлена в %3",
@@ -344,8 +340,8 @@
"en": "Build \"%1\" will be removed and cannot be recovered." "en": "Build \"%1\" will be removed and cannot be recovered."
}, },
"build.hint.removingLast": { "build.hint.removingLast": {
"ru": "Последняя сборка, файлы из корня игры не удаляются.", "ru": "Это последняя сборка. Содержимое .minecraft останется на месте.",
"en": "Last build. The contents of .minecraft will stay." "en": "This is the last build. The contents of .minecraft will stay where they are."
}, },
"build.label.active": { "build.label.active": {
"ru": "активна", "ru": "активна",
@@ -364,8 +360,8 @@
"en": "Server address" "en": "Server address"
}, },
"build.placeholder.name": { "build.placeholder.name": {
"ru": "Введите наименование", "ru": "как её видно в списке",
"en": "Write a name" "en": "as it appears in the list"
}, },
"build.placeholder.serverAddress": { "build.placeholder.serverAddress": {
"ru": "host:port (необязательно)", "ru": "host:port (необязательно)",
@@ -383,21 +379,13 @@
"ru": "Активна сборка «%1»", "ru": "Активна сборка «%1»",
"en": "Build \"%1\" is active" "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": { "build.status.named": {
"ru": "Сборка «%1»: %2", "ru": "Сборка «%1»: %2",
"en": "Build \"%1\": %2" "en": "Build \"%1\": %2"
}, },
"build.status.ready": { "build.status.ready": {
"ru": "Готова к запуску", "ru": "Всё на месте, сборку можно запускать",
"en": "Ready to launch" "en": "Everything is in place, the build can be launched"
},
"build.title.fromGameDir": {
"ru": "Сборка из папки игры",
"en": "Build from the game folder"
}, },
"build.title.numbered": { "build.title.numbered": {
"ru": "Сборка %1", "ru": "Сборка %1",
@@ -408,8 +396,8 @@
"en": "Remove the build?" "en": "Remove the build?"
}, },
"build.warning.archiveGoesToo": { "build.warning.archiveGoesToo": {
"ru": "Внимание, при удалении так же очищаются пользовательские сохранённые архивы.", "ru": "Вместе с ней удалится её архив: моды, конфиги и миры этой сборки восстановить будет нельзя.",
"en": "Attention, after removing from the list all saved files will be deleted." "en": "Its archive goes with it: the mods, configs and worlds of this build cannot be recovered."
}, },
"build.warning.archiveNotRemoved": { "build.warning.archiveNotRemoved": {
"ru": "Сборка «%1» удалена, но её архив в %2 стереть не удалось", "ru": "Сборка «%1» удалена, но её архив в %2 стереть не удалось",
@@ -1376,8 +1364,8 @@
"en": "%1 — downloading" "en": "%1 — downloading"
}, },
"settings.status.javaRuntimeNone": { "settings.status.javaRuntimeNone": {
"ru": "не выбрана", "ru": "не выбрана — искать в системе",
"en": "not selected" "en": "not selected — search the system"
}, },
"settings.status.resolvedGameDir": { "settings.status.resolvedGameDir": {
"ru": "Папка игры: %1", "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 "msaauthservice.h"
#include "modloaderversionservice.h" #include "modloaderversionservice.h"
#include "buildswitcher.h" #include "buildswitcher.h"
#include "gamedirimport.h"
#include "seasonalbuildservice.h" #include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h" #include "seasonalpackdownloader.h"
#include <QCoreApplication>
#include <QDate> #include <QDate>
#include <QDateTime> #include <QDateTime>
#include <QDesktopServices> #include <QDesktopServices>
@@ -98,20 +96,11 @@ LauncherBackend::LauncherBackend(QObject *parent)
if (!LauncherPaths::ensureRootExists(&storageError)) if (!LauncherPaths::ensureRootExists(&storageError))
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(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(); loadSettings();
// Порядок важен: миграция должна успеть до того, как ensureDataFiles() // Порядок важен: миграция должна успеть до того, как ensureDataFiles()
// создаст пустой customBuilds.json поверх старых данных. // создаст пустой customBuilds.json поверх старых данных.
migrateLegacyBuildsFile(); migrateLegacyBuildsFile();
loadData(); loadData();
const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString();
ensureDataFiles(); 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()) { if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] { QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n')); emit launchError(m_storageIssues.join(u'\n'));
@@ -448,10 +433,7 @@ void LauncherBackend::addCustomBuild(const QString &name,
m_builds.append(build); m_builds.append(build);
saveCustomBuilds(); saveCustomBuilds();
emit customBuildsChanged(); emit customBuildsChanged();
// Без версии игры ставить нечего: launchVersionIdOf() вернул бы имя сборки, maybeInstall(launchVersionIdOf(m_builds.last()));
// и установщик пошёл бы искать в манифесте «Сборка 1».
if (!minecraftVersion.trimmed().isEmpty())
maybeInstall(minecraftVersion.trimmed());
} }
void LauncherBackend::updateProfile(int index, void LauncherBackend::updateProfile(int index,
@@ -1481,8 +1463,8 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0); options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0);
options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool(); options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool();
options.serverAddress = build.serverUrl.trimmed(); options.serverAddress = build.serverUrl.trimmed();
options.launcherName = QCoreApplication::applicationName(); options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
options.launcherVersion = QCoreApplication::applicationVersion(); options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed(); const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed();
if (!extra.isEmpty()) if (!extra.isEmpty())
@@ -2497,36 +2479,6 @@ bool LauncherBackend::migrateLegacyBuildsFile()
return true; 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 — // Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована. // создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles() void LauncherBackend::ensureDataFiles()
@@ -2591,6 +2543,8 @@ void LauncherBackend::loadSettings()
{QStringLiteral("fullscreen"), false}, {QStringLiteral("fullscreen"), false},
// Язык интерфейса: "system" — по системной локали. // Язык интерфейса: "system" — по системной локали.
{QStringLiteral("language"), QStringLiteral("system")}, {QStringLiteral("language"), QStringLiteral("system")},
{QStringLiteral("launcherName"), QStringLiteral("BiLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0}, {QStringLiteral("activeBuildId"), 0},
// Сервер готовых сборок. В интерфейс не вынесен: менять его // Сервер готовых сборок. В интерфейс не вынесен: менять его
// приходится только при отладке против локального сервера. // приходится только при отладке против локального сервера.
@@ -2599,12 +2553,6 @@ void LauncherBackend::loadSettings()
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile()); const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it) for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
m_settings.insert(it.key(), it.value().toVariant()); m_settings.insert(it.key(), it.value().toVariant());
// Имя и версия лаунчера раньше хранились здесь со значением "1.0" и не
// обновлялись вместе с программой. Теперь они берутся из QCoreApplication,
// а старые ключи вычищаются из файла при следующем сохранении.
m_settings.remove(QStringLiteral("launcherName"));
m_settings.remove(QStringLiteral("launcherVersion"));
} }
void LauncherBackend::saveSettings() void LauncherBackend::saveSettings()
-6
View File
@@ -232,9 +232,6 @@ signals:
// Сезонная сборка установлена и активна — можно запускать игру. // Сезонная сборка установлена и активна — можно запускать игру.
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName); void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
// Первый запуск: по содержимому папки игры заведена активная сборка.
void gameDirImported(const QString &buildName);
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь. // Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
void javaRuntimeInstalled(const QString &runtimeId); void javaRuntimeInstalled(const QString &runtimeId);
@@ -316,9 +313,6 @@ private:
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске // Перекладывает <root>/versions.json в customBuilds.json при первом запуске
// после переименования. true — миграция прошла, читать нужно новый файл. // после переименования. true — миграция прошла, читать нужно новый файл.
bool migrateLegacyBuildsFile(); bool migrateLegacyBuildsFile();
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
// Папку игры только читает. Возвращает имя сборки или пустую строку.
QString importGameDir();
void invalidateCatalog(); void invalidateCatalog();
void rebuildCatalog() const; void rebuildCatalog() const;
+3 -20
View File
@@ -3,38 +3,21 @@
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QtGlobal>
namespace { namespace {
const QString kFolderName = QStringLiteral("BiLauncher"); const QString kFolderName = QStringLiteral("BiLauncher");
// Домашняя папка, от которой строятся все пути (см. LauncherPaths::homeOverride()).
QString homeBase()
{
const QString overridden = LauncherPaths::homeOverride();
return overridden.isEmpty() ? QDir::homePath() : overridden;
}
} // namespace } // 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() QString LauncherPaths::containerDir()
{ {
#if defined(Q_OS_WIN) #if defined(Q_OS_WIN)
return homeBase() + QStringLiteral("/AppData/Roaming"); return QDir::homePath() + QStringLiteral("/AppData/Roaming");
#elif defined(Q_OS_MACOS) #elif defined(Q_OS_MACOS)
return homeBase() + QStringLiteral("/Library/Application Support"); return QDir::homePath() + QStringLiteral("/Library/Application Support");
#else // Linux и прочие Unix-системы #else // Linux и прочие Unix-системы
return homeBase(); return QDir::homePath();
#endif #endif
} }
-8
View File
@@ -4,14 +4,6 @@
namespace LauncherPaths { namespace LauncherPaths {
// Все пути строятся от домашней папки пользователя (QDir::homePath()). Переменная
// окружения BILAUNCHER_HOME с абсолютным путём подменяет её — только для путей
// лаунчера (папка лаунчера и папка игры по умолчанию), остальной Qt её не видит.
// Нужна тестам и дымовой проверке на Windows: там QDir::homePath() спрашивает
// профиль у системы и HOME/USERPROFILE игнорирует. Пустое или относительное
// значение не учитывается. Возвращает подменённую папку или пустую строку.
QString homeOverride();
QString containerDir(); QString containerDir();
QString rootDir(); QString rootDir();
QString defaultMinecraftDir(); QString defaultMinecraftDir();
-7
View File
@@ -1,8 +1,5 @@
#include "launcherpaths.h"
#include "localization.h" #include "localization.h"
#include <QDebug>
#include <QDir>
#include <QGuiApplication> #include <QGuiApplication>
#include <QIcon> #include <QIcon>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
@@ -36,10 +33,6 @@ int main(int argc, char *argv[])
QGuiApplication::setDesktopFileName(QStringLiteral("bilauncher")); QGuiApplication::setDesktopFileName(QStringLiteral("bilauncher"));
// Случайно оставленная BILAUNCHER_HOME увела бы данные в чужую папку — пусть это будет видно в логе.
if (!LauncherPaths::homeOverride().isEmpty())
qWarning().noquote() << "BILAUNCHER_HOME: данные лаунчера в" << QDir::toNativeSeparators(LauncherPaths::rootDir());
if (!Localization::instance().load()) if (!Localization::instance().load())
return -1; 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 @@
; Установщик BiLauncher для Windows (Inno Setup 6).
;
; Собирается в CI после `cmake --install`:
; iscc /DAppVersion=1.0.0 /DSourceDir=C:\...\stage /DOutputDir=C:\...\dist packaging\windows\BiLauncher.iss
; SourceDir — префикс установки: bin\ с BiLauncher.exe и развёрнутым windeployqt Qt,
; share\doc\BiLauncher\ с лицензиями.
#ifndef AppVersion
#error Передайте версию: /DAppVersion=X.Y.Z
#endif
#ifndef SourceDir
#error Передайте префикс установки: /DSourceDir=...
#endif
#ifndef OutputDir
#define OutputDir "."
#endif
[Setup]
; AppId не менять между версиями — по нему обновление находит прежнюю установку.
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={#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
-128
View File
@@ -1,128 +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}")
target_compile_definitions(bilauncher_testlib PUBLIC
LAUNCHER_VERSION="${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"
}
}
]
}
-18
View File
@@ -1,18 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIC6zCCAdOgAwIBAgIJALwPNtuB/leeMA0GCSqGSIb3DQEBCwUAMB0xGzAZBgNV
BAMMEmZha2UtaW50ZXJuZXQudGVzdDAgFw0yNjEwMTEyMDQ4NTZaGA8yMTI2MDkx
NzIwNDg1NlowHTEbMBkGA1UEAwwSZmFrZS1pbnRlcm5ldC50ZXN0MIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvFAbXruU1WU9utLa/SSBIX8XUQs8rjum
KoK13RVPKlkSGln5umz0MZJas95QwHDf8s0qKR2wOyFZgDl5S4YD6sotHiqcF6zV
9FmP14So0dAL+B/RsxnLFc8seCDtW6GB1fS0G36EJBRFpt2RizGGx4zdj5thzlrf
0msXdzEY8gFriFhuejS4DbX7BlPjnuaXb2iFpTQnNw+MpIDcdfnUuUm56x29wZRb
trMnOqSP7YqVi06XM2w5NbDUlXXCSWR57nPlrleJlMX1YbDdERf7Nngl2ECBCOAY
J3pkKkQ2I8Ne059bC9ZRoF/daggGclp1JTi2PgDewefSkHPiTr7eBQIDAQABoyww
KjAoBgNVHREEITAfghJmYWtlLWludGVybmV0LnRlc3SCCWxvY2FsaG9zdDANBgkq
hkiG9w0BAQsFAAOCAQEAQQFCHNvJrn/fC8H6ntJktLyzseOQ8q6B8SSXGUd0l5/n
TQDufm/+49TpPrHu2+eoHMeeb4gijlMK8PME685x154E2erdjtilE/YBUemeLrrn
NLRIaqafArhd7qXE5pFzNc6QNAcrLF9Ao0SsI3xCxtK5duWMhQGFKQr6f7NqrSit
wNB9uYCf5pAZfL+bfRBtC6Xc6qnZy5LBUQcEkpu/HweE6DZKV1Z4XH4TGxWiO0y7
9YFYH9P/I0jsHDT/VK+6PF2JTdErqWfCxryDuUgheLw1/W8T+5CVVCvb4Hr0fsP3
0o/cCfPR9wdsqGalv6dl3cuAWCRqEq1lNVLQVbY1cg==
-----END CERTIFICATE-----
-28
View File
@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8UBteu5TVZT26
0tr9JIEhfxdRCzyuO6YqgrXdFU8qWRIaWfm6bPQxklqz3lDAcN/yzSopHbA7IVmA
OXlLhgPqyi0eKpwXrNX0WY/XhKjR0Av4H9GzGcsVzyx4IO1boYHV9LQbfoQkFEWm
3ZGLMYbHjN2Pm2HOWt/Saxd3MRjyAWuIWG56NLgNtfsGU+Oe5pdvaIWlNCc3D4yk
gNx1+dS5SbnrHb3BlFu2syc6pI/tipWLTpczbDk1sNSVdcJJZHnuc+WuV4mUxfVh
sN0RF/s2eCXYQIEI4BgnemQqRDYjw17Tn1sL1lGgX91qCAZyWnUlOLY+AN7B59KQ
c+JOvt4FAgMBAAECggEABOS0AV8we+cYlo06IY/cRymNSo7XMdPc+ofRAAC4wOn1
nXevFJgWNF+QOdJSGN4WA7IvIUhjNvYj5EAaFctSuLwuNfV3fun/2Uymfzw0Qtd8
KCNziIroCxYKGskIQfRNcmjccUZ1WpaoRLDHQm20CeVwMHYiMYGNBWPBBVjiRA+X
+T+iQq1f4GX2ErPw1dlcCSW/HVzyRqRwxB12VfQpW71Jq9h7oeOtcrVAEZj+rPaa
io1u/Z4kmRxuyqgE9tcDUmdYhvijnZRhPSqWJhmQyVzzCpUX8Vtwy/Bow1EKUBaa
jKtwu75ap4oTvaSJsHbX3+69eXJjIvXAQTKC7HgUFQKBgQDendqYimm1G/GDCBCj
vXLYhHkP4MRI78jcggc4rJIv+rZGto7QZZRIEi5eVAdqlqoKL/xBIi7bDc6AH73p
/M+xTWDJ4p09AMOY0k8WwsThzNOsA+R3futbKaM4NSTqwMzqaZs+jcyl/sIJUr8S
tq7ndQtMSSf0O3mCg8nnvQuKLwKBgQDYjVmugVxKYchOZ3yXjPD7a/H54n4NiqnW
/BvFPwhxMtQtb3YzzgyAuBskK3PEFjI+hKoLDGjHV/uZS1sAm1qVm2mN/lyPSeaU
P2+1EOKsvWl9DoOkwTeb3QoGkXzZ35fORCLd7iphicFptFSgmqtaKT7KSQyKHaw2
pxSwnPlyCwKBgCraqGIFAyRiJLPcsZdTuPAFUGjGFAMWZcxNG6bFUb76vGwQaJ2l
cE4Pce2mr2bNah5b7mt7cRmqJr5zXdPVXwX6p0812J8NrpmUorT2+X+eNoE+npSC
UjxSX6FKWbj3NHtLqR75nRO3XxL8YtvvXuwfk/UzgAV8DYAabpu1wKrxAoGAMUep
ev7RQ9QQw1blkY9vX6fm/xi92tMNZ3Ej2LVJdIk+o8BTw+JGEhzTgF2rEJqEgJms
IKuIRKUPComNggvYh2dAlCGoaqtEn8P35dqb2veXw+/DoYCe6QdaOrhtEx/A6h2z
WZHTbzLsIuP4xjusdu4f7eTFx8CM6jsHiFGQqpUCgYBZlBn76C+3ctH4sgLfU/Zd
Kz8cP+K/QpvA2R6K7DBn57drVG6wRIhibv3YO4YwDntLyOwZ2r5VA5uTvLqU2gGO
AYDMTZ+g9csla0QjpoMDz3ThUgeYMb5nJ3maMx/kq3aNTGMKjncTLhiC7CNSWagC
Mv+y9lT/Hf6ZSiiti8EQGQ==
-----END PRIVATE KEY-----
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more