Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d30b1594 | ||
|
|
56dd58b8d1 | ||
|
|
50e0cd2aab | ||
|
|
4ceaf000fd | ||
|
|
d82a9d0729 | ||
|
|
2575aae5c6 | ||
|
|
6442514d4f | ||
|
|
1817157e4d | ||
|
|
4463d4e5da | ||
|
|
7dd89db508 | ||
|
|
a89bf20486 | ||
|
|
ec60df859a | ||
|
|
0cacbc3ef3 | ||
|
|
5508b9a936 | ||
|
|
24115b4ce6 | ||
|
|
1d781b5324 | ||
|
|
bc205fa2e7 | ||
|
|
aa021269be | ||
|
|
92531c6e9a | ||
|
|
acd9334c16 | ||
|
|
3ac2156ef2 | ||
|
|
5d819129d6 | ||
|
|
0dd6bef715 | ||
|
|
41599fa82f | ||
|
|
ebf8ff7869 | ||
|
|
443850196d | ||
|
|
f10b654953 | ||
|
|
1943996608 | ||
|
|
0f370167db | ||
|
|
fd14b54d8a | ||
|
|
8a3d0596ef | ||
|
|
29b53ce75a | ||
|
|
5bbca110d7 | ||
|
|
c66d90ece6 | ||
|
|
d77d4c69cd | ||
|
|
7a54f97dcd | ||
|
|
e85f52e886 | ||
|
|
1bf3617055 | ||
|
|
bc2b8d27ca | ||
|
|
bb202af9a7 |
No files matched your search
@@ -0,0 +1,311 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# master -> релиз v<версия>, если такого ещё нет
|
||||||
|
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
|
||||||
|
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
|
||||||
|
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
#branches: [master, rc]
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-release:
|
||||||
|
runs-on: linux
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.meta.outputs.version }}
|
||||||
|
tag: ${{ steps.meta.outputs.tag }}
|
||||||
|
skip: ${{ steps.meta.outputs.skip }}
|
||||||
|
release_id: ${{ steps.release.outputs.id }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
name: Версия из CMakeLists.txt, тег и тип релиза
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
|
||||||
|
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
|
||||||
|
|
||||||
|
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
|
||||||
|
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
|
||||||
|
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
|
||||||
|
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
|
||||||
|
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
|
||||||
|
known() {
|
||||||
|
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
|
||||||
|
printf '%s\n' "$release_tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
tag=""; skip=false
|
||||||
|
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
|
||||||
|
tag:*)
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
base="${tag#v}"; base="${base%%-*}"
|
||||||
|
if [ "$base" != "$version" ]; then
|
||||||
|
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||||
|
fi
|
||||||
|
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
|
||||||
|
if has_release "$tag"; then
|
||||||
|
echo "Релиз $tag уже есть"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:master)
|
||||||
|
tag="v$version"
|
||||||
|
if known | grep -xF "$tag" > /dev/null; then
|
||||||
|
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:rc)
|
||||||
|
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
|
||||||
|
tag="v$version-rc$(( ${n:-0} + 1 ))"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ -n "$tag" ] && version="${tag#v}"
|
||||||
|
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip=$skip" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
|
||||||
|
|
||||||
|
- id: release
|
||||||
|
name: Черновик релиза
|
||||||
|
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
TAG: ${{ steps.meta.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
full="${TAG#v}"; base="${full%%-*}"
|
||||||
|
section() {
|
||||||
|
awk -v v="$1" '
|
||||||
|
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||||
|
on' CHANGELOG.md
|
||||||
|
}
|
||||||
|
# Сначала раздел ровно под тег ([1.0.1-rc1]), иначе общий под версию ([1.0.1])
|
||||||
|
notes=$(section "$full")
|
||||||
|
[ -n "$notes" ] || notes=$(section "$base")
|
||||||
|
[ -n "$notes" ] || echo "::warning::В CHANGELOG.md нет раздела [$full] или [$base]"
|
||||||
|
prerelease=false; case "$TAG" in *-rc*) prerelease=true;; esac
|
||||||
|
# target_commitish: у новой ветки тег появится на этом коммите при публикации
|
||||||
|
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"target_commitish":sys.argv[4],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
||||||
|
"$TAG" "$notes" "$prerelease" "$GITHUB_SHA")
|
||||||
|
id=$(curl -fsS -X POST "$API/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
|
runs-on: linux
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
outputs:
|
||||||
|
sums: ${{ steps.pack.outputs.sums }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Сборка
|
||||||
|
run: |
|
||||||
|
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
|
rm -rf build AppDir dist
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
|
||||||
|
cmake --build build -j 8
|
||||||
|
cmake --install build --prefix "$PWD/AppDir/usr"
|
||||||
|
|
||||||
|
- id: pack
|
||||||
|
name: AppImage и tar.gz
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
cp AppDir/usr/share/applications/bilauncher.desktop AppDir/
|
||||||
|
cp AppDir/usr/share/icons/hicolor/256x256/apps/bilauncher.png AppDir/
|
||||||
|
ln -sf usr/bin/BiLauncher AppDir/AppRun
|
||||||
|
ARCH=x86_64 appimagetool --no-appstream AppDir "dist/BiLauncher-$VERSION-x86_64.AppImage"
|
||||||
|
cp -a AppDir/usr "BiLauncher-$VERSION"
|
||||||
|
tar -czf "dist/BiLauncher-$VERSION-linux-x86_64.tar.gz" "BiLauncher-$VERSION"
|
||||||
|
rm -rf "BiLauncher-$VERSION"
|
||||||
|
ls -lh dist
|
||||||
|
echo "sums=$(cd dist && sha256sum * | base64 -w0)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Загрузка в релиз
|
||||||
|
if: env.RELEASE_ID != ''
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for f in dist/*; do
|
||||||
|
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$f" "$API/releases/$RELEASE_ID/assets?name=$(basename "$f")" > /dev/null
|
||||||
|
echo "Загружен $(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Артефакты прогона
|
||||||
|
if: env.RELEASE_ID == ''
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
|
runs-on: windows
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
outputs:
|
||||||
|
sums: ${{ steps.build.outputs.sums }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- id: build
|
||||||
|
name: Сборка, установщик и portable-архив
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$build = "C:\r\bl-build"; $stage = "C:\r\bl-stage"; $dist = "$PWD\dist"
|
||||||
|
Remove-Item -Recurse -Force $build, $stage, $dist -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
|
||||||
|
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||||
|
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||||
|
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||||
|
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||||
|
|
||||||
|
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT" "-DLAUNCHER_BUILD_VERSION=$env:BUILD_VERSION"
|
||||||
|
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||||
|
cmake --build $build
|
||||||
|
if ($LASTEXITCODE) { throw "cmake build" }
|
||||||
|
cmake --install $build --prefix $stage
|
||||||
|
if ($LASTEXITCODE) { throw "cmake install" }
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force $dist | Out-Null
|
||||||
|
& "$env:ISCC_DIR\ISCC.exe" /Q "/DAppVersion=$env:VERSION" "/DSourceDir=$stage" "/DOutputDir=$dist" packaging\windows\BiLauncher.iss
|
||||||
|
if ($LASTEXITCODE) { throw "iscc" }
|
||||||
|
|
||||||
|
Remove-Item "$stage\bin\vc_redist.x64.exe" -ErrorAction SilentlyContinue
|
||||||
|
$portable = "C:\r\BiLauncher-$env:VERSION"
|
||||||
|
Remove-Item -Recurse -Force $portable -ErrorAction SilentlyContinue
|
||||||
|
Move-Item $stage $portable
|
||||||
|
& "$env:SystemRoot\System32\tar.exe" -a -c -f "$dist\BiLauncher-$env:VERSION-win64-portable.zip" -C C:\r "BiLauncher-$env:VERSION"
|
||||||
|
if ($LASTEXITCODE) { throw "zip" }
|
||||||
|
Remove-Item -Recurse -Force $portable
|
||||||
|
|
||||||
|
Get-ChildItem $dist | Format-Table Name, Length
|
||||||
|
$sums = Get-ChildItem $dist | ForEach-Object {
|
||||||
|
"{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name }
|
||||||
|
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($sums -join "`n") + "`n"))
|
||||||
|
[IO.File]::AppendAllText($env:GITHUB_OUTPUT, "sums=$b64`n")
|
||||||
|
|
||||||
|
- name: Загрузка в релиз
|
||||||
|
if: env.RELEASE_ID != ''
|
||||||
|
shell: powershell
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
foreach ($f in Get-ChildItem dist) {
|
||||||
|
curl.exe -fsS -X POST -H "Authorization: token $env:TOKEN" `
|
||||||
|
-F "attachment=@$($f.FullName)" "$env:API/releases/$env:RELEASE_ID/assets?name=$($f.Name)" | Out-Null
|
||||||
|
if ($LASTEXITCODE) { throw "Не удалось загрузить $($f.Name)" }
|
||||||
|
"Загружен $($f.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Артефакты прогона
|
||||||
|
if: env.RELEASE_ID == ''
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
|
runs-on: macos
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
outputs:
|
||||||
|
sums: ${{ steps.pack.outputs.sums }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Сборка (universal, arm64 + x86_64)
|
||||||
|
run: |
|
||||||
|
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
|
rm -rf build stage dist dmgroot
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||||
|
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
||||||
|
cmake --build build
|
||||||
|
cmake --install build --prefix "$PWD/stage"
|
||||||
|
|
||||||
|
- id: pack
|
||||||
|
name: Ad-hoc подпись и DMG
|
||||||
|
run: |
|
||||||
|
codesign --force --deep --sign - stage/BiLauncher.app
|
||||||
|
codesign --verify --deep --strict stage/BiLauncher.app
|
||||||
|
mkdir -p dmgroot dist
|
||||||
|
cp -R stage/BiLauncher.app dmgroot/
|
||||||
|
ln -s /Applications dmgroot/Applications
|
||||||
|
hdiutil create -volname "BiLauncher" -srcfolder dmgroot -ov -format UDZO \
|
||||||
|
"dist/BiLauncher-$VERSION-macOS-universal.dmg"
|
||||||
|
rm -rf dmgroot
|
||||||
|
ls -lh dist
|
||||||
|
echo "sums=$(cd dist && shasum -a 256 * | base64 | tr -d '\n')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Загрузка в релиз
|
||||||
|
if: env.RELEASE_ID != ''
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for f in dist/*; do
|
||||||
|
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$f" "$API/releases/$RELEASE_ID/assets?name=$(basename "$f")" > /dev/null
|
||||||
|
echo "Загружен $(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Артефакты прогона
|
||||||
|
if: env.RELEASE_ID == ''
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [create-release, build-linux, build-windows, build-macos]
|
||||||
|
if: needs.create-release.outputs.release_id != ''
|
||||||
|
runs-on: linux
|
||||||
|
env:
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: SHA256SUMS.txt и публикация
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "${{ needs.build-windows.outputs.sums }}" | base64 -d
|
||||||
|
echo "${{ needs.build-linux.outputs.sums }}" | base64 -d
|
||||||
|
echo "${{ needs.build-macos.outputs.sums }}" | base64 -d
|
||||||
|
} | tr -d '\r' | sed '/^$/d' > SHA256SUMS.txt
|
||||||
|
cat SHA256SUMS.txt
|
||||||
|
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@SHA256SUMS.txt" "$API/releases/$RELEASE_ID/assets?name=SHA256SUMS.txt" > /dev/null
|
||||||
|
curl -fsS -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"draft": false}' "$API/releases/$RELEASE_ID" > /dev/null
|
||||||
|
echo "Релиз опубликован"
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: tests-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
runs-on: linux
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Сборка лаунчера и тестов
|
||||||
|
run: |
|
||||||
|
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
|
launcher=""
|
||||||
|
if command -v ccache >/dev/null; then
|
||||||
|
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
|
||||||
|
fi
|
||||||
|
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
|
||||||
|
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT" $launcher
|
||||||
|
cmake --build build-tests -j 8
|
||||||
|
|
||||||
|
- name: Статические проверки (переводы, qmllint, CHANGELOG)
|
||||||
|
run: CMAKE="$QT_TOOLS/CMake/bin/cmake" python3 tests/scripts/static_checks.py build-tests
|
||||||
|
|
||||||
|
- name: Тесты
|
||||||
|
run: |
|
||||||
|
export PATH="$QT_TOOLS/CMake/bin:$PATH"
|
||||||
|
ctest --test-dir build-tests --output-on-failure -j 4 \
|
||||||
|
--output-junit "$PWD/build-tests/junit.xml"
|
||||||
|
|
||||||
|
- name: Отчёты
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: tests-linux
|
||||||
|
if-no-files-found: ignore
|
||||||
|
path: |
|
||||||
|
build-tests/junit.xml
|
||||||
|
build-tests/qmllint.log
|
||||||
|
build-tests/Testing/Temporary/LastTest.log
|
||||||
|
|
||||||
|
windows:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||||
|
runs-on: windows
|
||||||
|
timeout-minutes: 150
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Сборка и тесты
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
# Короткий путь: дерево WebEngine легко вылезает за MAX_PATH (см. release.yml).
|
||||||
|
$build = "C:\r\bl-tests"
|
||||||
|
Remove-Item -Recurse -Force $build -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
|
||||||
|
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||||
|
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||||
|
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||||
|
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||||
|
|
||||||
|
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBILAUNCHER_BUILD_TESTS=ON "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
||||||
|
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||||
|
cmake --build $build
|
||||||
|
if ($LASTEXITCODE) { throw "cmake build" }
|
||||||
|
|
||||||
|
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
New-Item -ItemType Directory -Force test-results | Out-Null
|
||||||
|
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
|
||||||
|
if ($code) { throw "ctest" }
|
||||||
|
|
||||||
|
- name: Отчёты
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: tests-windows
|
||||||
|
if-no-files-found: ignore
|
||||||
|
path: test-results/
|
||||||
|
|
||||||
|
macos:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||||
|
runs-on: macos
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Сборка и тесты
|
||||||
|
run: |
|
||||||
|
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
|
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
|
||||||
|
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT"
|
||||||
|
cmake --build build-tests
|
||||||
|
ctest --test-dir build-tests --output-on-failure -j 4 \
|
||||||
|
--output-junit "$PWD/build-tests/junit.xml"
|
||||||
|
|
||||||
|
- name: Отчёты
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: tests-macos
|
||||||
|
if-no-files-found: ignore
|
||||||
|
path: build-tests/junit.xml
|
||||||
|
|
||||||
|
package-smoke-linux:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: linux
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Release-сборка, установка, запуск
|
||||||
|
run: |
|
||||||
|
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
|
rm -rf build-smoke smoke-stage
|
||||||
|
launcher=""
|
||||||
|
if command -v ccache >/dev/null; then
|
||||||
|
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
|
||||||
|
fi
|
||||||
|
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib $launcher
|
||||||
|
cmake --build build-smoke -j 8
|
||||||
|
cmake --install build-smoke --prefix "$PWD/smoke-stage/usr"
|
||||||
|
bash tests/scripts/smoke_installed.sh smoke-stage/usr
|
||||||
|
|
||||||
|
package-smoke-windows:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: windows
|
||||||
|
timeout-minutes: 150
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Release-сборка, установка, запуск
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$build = "C:\r\bl-smoke"; $stage = "C:\r\bl-smoke-stage"
|
||||||
|
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
|
||||||
|
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||||
|
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||||
|
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||||
|
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||||
|
|
||||||
|
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
||||||
|
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||||
|
cmake --build $build
|
||||||
|
if ($LASTEXITCODE) { throw "cmake build" }
|
||||||
|
cmake --install $build --prefix $stage
|
||||||
|
if ($LASTEXITCODE) { throw "cmake install" }
|
||||||
|
|
||||||
|
& powershell -ExecutionPolicy Bypass -File tests\scripts\smoke_installed.ps1 -Prefix $stage
|
||||||
|
if ($LASTEXITCODE) { throw "smoke" }
|
||||||
|
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
package-smoke-macos:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: macos
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Release-сборка, установка, запуск
|
||||||
|
run: |
|
||||||
|
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
|
rm -rf build-smoke smoke-stage
|
||||||
|
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QT_ROOT"
|
||||||
|
cmake --build build-smoke
|
||||||
|
cmake --install build-smoke --prefix "$PWD/smoke-stage"
|
||||||
|
bash tests/scripts/smoke_installed.sh smoke-stage
|
||||||
@@ -61,6 +61,9 @@ 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-*/
|
||||||
@@ -15,8 +15,9 @@ Dialog {
|
|||||||
padding: 0
|
padding: 0
|
||||||
width: 880
|
width: 880
|
||||||
height: 600
|
height: 600
|
||||||
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||||
|
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: "#1e1e1e"
|
color: "#1e1e1e"
|
||||||
@@ -314,7 +315,8 @@ 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: "#91B315"
|
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||||
|
? "#ffffff" : "#91B315"
|
||||||
font.pixelSize: 10
|
font.pixelSize: 10
|
||||||
}
|
}
|
||||||
Image {
|
Image {
|
||||||
|
|||||||
@@ -1,51 +1,21 @@
|
|||||||
# First Level Heading
|
# Changelog
|
||||||
|
|
||||||
Paragraph.
|
## [1.0.0] — 2026-10-07
|
||||||
|
### Добавлено
|
||||||
|
- Настройки запуска Minecraft
|
||||||
|
- Автоматическая загрузка версий java из предложенного списка
|
||||||
|
- Система хранения сборок, автоматического скачивания версий игры из меню и модлоадеры к каждой версии
|
||||||
|
- Сохранение сборок в zip формате и отображение в списке
|
||||||
|
- Система сезонных сборок: полная автоматизация в один клик
|
||||||
|
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
||||||
|
### Исправлено
|
||||||
|
- UI фиксы
|
||||||
|
|
||||||
## Second Level Heading
|
## [1.0.1-rc1] — 2026-10-23
|
||||||
|
### Добавлено
|
||||||
Paragraph.
|
- Отображене текущей версии сборки лаунчера на главном окне
|
||||||
|
### Исправлено
|
||||||
- bullet
|
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
|
||||||
+ other bullet
|
- Исправлен баг отображения статуса активной сборки на зелёном фоне
|
||||||
* another bullet
|
- Исправлен баг, что во время игры можно было потенциально сменить сборку
|
||||||
* child bullet
|
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,6 +1,27 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
|
project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C)
|
||||||
|
|
||||||
|
# Канал сборки (dev / rc / релиз) не хранится в репозитории, а вычисляется
|
||||||
|
# при сборке — см. cmake/LauncherVersion.cmake. CI передаёт полную версию:
|
||||||
|
# -DLAUNCHER_BUILD_VERSION=1.0.1-rc2.
|
||||||
|
set(LAUNCHER_BUILD_VERSION "" CACHE STRING "Полная версия от CI (X.Y.Z[-rcN]); пусто — по тегу и ветке git")
|
||||||
|
set(LAUNCHER_VERSION_NUMERIC "${PROJECT_VERSION}")
|
||||||
|
set(LAUNCHER_SOURCE_DIR "${PROJECT_SOURCE_DIR}")
|
||||||
|
set(LAUNCHER_GENERATED_DIR "${PROJECT_BINARY_DIR}/generated")
|
||||||
|
set(LAUNCHER_VERSION_HEADER "${LAUNCHER_GENERATED_DIR}/launcherversion.h")
|
||||||
|
include(cmake/LauncherVersion.cmake)
|
||||||
|
message(STATUS "Версия лаунчера: ${LAUNCHER_VERSION} (канал ${LAUNCHER_CHANNEL})")
|
||||||
|
add_custom_target(launcher_version
|
||||||
|
COMMAND "${CMAKE_COMMAND}"
|
||||||
|
"-DLAUNCHER_VERSION_NUMERIC=${LAUNCHER_VERSION_NUMERIC}"
|
||||||
|
"-DLAUNCHER_SOURCE_DIR=${LAUNCHER_SOURCE_DIR}"
|
||||||
|
"-DLAUNCHER_VERSION_HEADER=${LAUNCHER_VERSION_HEADER}"
|
||||||
|
"-DLAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION}"
|
||||||
|
-P "${PROJECT_SOURCE_DIR}/cmake/LauncherVersion.cmake"
|
||||||
|
BYPRODUCTS "${LAUNCHER_VERSION_HEADER}"
|
||||||
|
COMMENT "Версия лаунчера из git"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
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)
|
||||||
@@ -19,15 +40,6 @@ 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)
|
||||||
@@ -47,10 +59,6 @@ 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"
|
||||||
@@ -70,8 +78,14 @@ 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
|
||||||
@@ -110,6 +124,8 @@ 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
|
||||||
@@ -127,12 +143,9 @@ 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/launcherBackground2.mp4 images/launcherBackground2static.jpg
|
RESOURCES 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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,8 +156,6 @@ 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
|
||||||
@@ -154,9 +165,8 @@ target_link_libraries(BiLauncher
|
|||||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(BiLauncher PRIVATE
|
target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
|
||||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
add_dependencies(BiLauncher launcher_version)
|
||||||
)
|
|
||||||
|
|
||||||
if(Qt6WebEngineQuick_FOUND)
|
if(Qt6WebEngineQuick_FOUND)
|
||||||
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
||||||
@@ -205,8 +215,6 @@ 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"
|
||||||
@@ -242,3 +250,8 @@ 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()
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -4,22 +4,16 @@ 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)
|
||||||
@@ -43,8 +37,6 @@ 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
|
||||||
@@ -107,7 +99,6 @@ 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)
|
||||||
@@ -138,7 +129,6 @@ Dialog {
|
|||||||
contentItem: RowLayout {
|
contentItem: RowLayout {
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
// ── Типы сборок ─────────────────────────────────────────────────
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.preferredWidth: 170
|
Layout.preferredWidth: 170
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
@@ -195,8 +185,6 @@ 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
|
||||||
@@ -215,7 +203,6 @@ Dialog {
|
|||||||
color: "#333333"
|
color: "#333333"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Версии ──────────────────────────────────────────────────────
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
@@ -317,8 +304,7 @@ 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
|
||||||
|
|
||||||
@@ -353,8 +339,6 @@ Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
// Колонка растянута якорями, поэтому её ширина
|
|
||||||
// задана, а не выведена из детей — цикла тут нет.
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
color: runtimeRow.chosen ? "#1e1e1e"
|
color: runtimeRow.chosen ? "#1e1e1e"
|
||||||
@@ -388,8 +372,6 @@ 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"
|
||||||
@@ -430,7 +412,6 @@ Dialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пустая категория, ничего не нашлось или каталог ещё не пришёл.
|
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: runtimeList
|
anchors.centerIn: runtimeList
|
||||||
width: runtimeList.width - 20
|
width: runtimeList.width - 20
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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.
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls 2.15
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
// Подпись + поле ввода одной колонкой — используется в настройках.
|
|
||||||
Column {
|
Column {
|
||||||
id: labelled
|
id: labelled
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ Column {
|
|||||||
property string label: ""
|
property string label: ""
|
||||||
property string placeholder: ""
|
property string placeholder: ""
|
||||||
|
|
||||||
// Правка закончена: поле потеряло фокус или пользователь нажал Enter.
|
|
||||||
signal editingFinished()
|
signal editingFinished()
|
||||||
|
|
||||||
spacing: 3
|
spacing: 3
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls 2.15
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
// Один модлоадер в карточке сборки: чекбокс и, когда он отмечен, список версий
|
|
||||||
// именно под выбранную версию Minecraft.
|
|
||||||
//
|
|
||||||
// Совместимость проверять не нужно: backend.loaderVersions() отдаёт список уже
|
|
||||||
// под конкретную версию игры, поэтому несовместимой строки в нём не бывает.
|
|
||||||
// Пустой список — это «лоадер не поддерживает эту версию игры», и он тоже
|
|
||||||
// показывается словами, а не пустым выпадающим списком.
|
|
||||||
Column {
|
Column {
|
||||||
id: loaderRow
|
id: loaderRow
|
||||||
|
|
||||||
@@ -15,21 +8,13 @@ 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
|
||||||
@@ -42,16 +27,9 @@ 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
|
||||||
@@ -68,26 +46,20 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ 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)
|
||||||
@@ -89,6 +92,38 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Канал сборки из суффикса версии (cmake/LauncherVersion.cmake): у релиза бейджа нет
|
||||||
|
Rectangle {
|
||||||
|
id: channelBadge
|
||||||
|
readonly property var channel: /-(dev|rc)/.exec(Qt.application.version)
|
||||||
|
visible: channel !== null
|
||||||
|
anchors.left: launcherVersion.right
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.verticalCenter: launcherVersion.verticalCenter
|
||||||
|
width: channelLabel.implicitWidth + 8
|
||||||
|
height: channelLabel.implicitHeight + 2
|
||||||
|
radius: 3
|
||||||
|
color: channel && channel[1] === "rc" ? "#c08a1e" : "#b03a3a"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: channelLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: channelBadge.channel ? channelBadge.channel[1].toUpperCase() : ""
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status toast ───────────────────────────────────────────────────────
|
// ── Status toast ───────────────────────────────────────────────────────
|
||||||
@@ -192,6 +227,10 @@ 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
|
||||||
|
|
||||||
@@ -377,8 +416,6 @@ Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Активная сборка ────────────────────────────────────────────────────
|
// ── Активная сборка ────────────────────────────────────────────────────
|
||||||
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
|
|
||||||
// и её содержимое правятся теперь в модальном окне.
|
|
||||||
Button {
|
Button {
|
||||||
id: buildButton
|
id: buildButton
|
||||||
x: 624
|
x: 624
|
||||||
@@ -391,6 +428,7 @@ 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
|
||||||
@@ -464,6 +502,7 @@ 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
|
||||||
|
|||||||
@@ -4,19 +4,12 @@ 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
|
||||||
@@ -32,8 +25,6 @@ Dialog {
|
|||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Разбор адреса живёт в C++: правила совпадения должны совпадать с теми,
|
|
||||||
// по которым сервис строит сам redirect_uri.
|
|
||||||
function handleUrl(url) {
|
function handleUrl(url) {
|
||||||
if (codeTaken)
|
if (codeTaken)
|
||||||
return
|
return
|
||||||
@@ -76,9 +67,6 @@ Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentItem: Item {
|
contentItem: Item {
|
||||||
// Профиль без storageName — значит без диска: куки живут только пока
|
|
||||||
// работает лаунчер и в общий браузер не попадают. За выбор аккаунта в
|
|
||||||
// пределах сессии отвечает prompt=select_account в адресе входа.
|
|
||||||
WebEngineProfilePrototype {
|
WebEngineProfilePrototype {
|
||||||
id: msProfile
|
id: msProfile
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
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,3 +1,34 @@
|
|||||||
# 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>
|
||||||
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -3,23 +3,11 @@ 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 },
|
||||||
@@ -34,11 +22,9 @@ Dialog {
|
|||||||
padding: 0
|
padding: 0
|
||||||
width: 960
|
width: 960
|
||||||
height: 560
|
height: 560
|
||||||
// Пока идёт установка, окно закрывается только кнопкой: случайный щелчок
|
// Закрывается и во время установки: отменить её можно только на панели
|
||||||
// мимо не должен спрятать единственную видимую отмену.
|
// загрузки главного окна, а модальное окно эту панель перекрывает.
|
||||||
closePolicy: seasons.backend.seasonalInstalling
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
? Popup.NoAutoClose
|
|
||||||
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: "#1e1e1e"
|
color: "#1e1e1e"
|
||||||
@@ -47,8 +33,6 @@ 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: {
|
||||||
@@ -79,7 +63,6 @@ Dialog {
|
|||||||
return String(entry[key])
|
return String(entry[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Свежий кэш отвечает без сети, поэтому дёргаем при каждом открытии.
|
|
||||||
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
|
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
|
||||||
|
|
||||||
header: Item {
|
header: Item {
|
||||||
@@ -101,7 +84,6 @@ Dialog {
|
|||||||
|
|
||||||
contentItem: Item {
|
contentItem: Item {
|
||||||
|
|
||||||
// ── Шапка таблицы ───────────────────────────────────────────────
|
|
||||||
Row {
|
Row {
|
||||||
id: tableHeader
|
id: tableHeader
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
@@ -139,9 +121,6 @@ Dialog {
|
|||||||
color: "#333333"
|
color: "#333333"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Сообщение вместо таблицы ────────────────────────────────────
|
|
||||||
// Пустой список и ошибка выглядят одинаково пустыми, поэтому причина
|
|
||||||
// показывается прямо на месте строк.
|
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
width: parent.width - 80
|
width: parent.width - 80
|
||||||
@@ -157,7 +136,6 @@ Dialog {
|
|||||||
: seasons.backend.seasonalCatalogError)
|
: seasons.backend.seasonalCatalogError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Строки ──────────────────────────────────────────────────────
|
|
||||||
ListView {
|
ListView {
|
||||||
id: buildList
|
id: buildList
|
||||||
anchors.top: headerLine.bottom
|
anchors.top: headerLine.bottom
|
||||||
@@ -243,8 +221,6 @@ Dialog {
|
|||||||
color: "#333333"
|
color: "#333333"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Описание и размер выбранной сборки: они длинные и в таблицу не
|
|
||||||
// помещаются, а решение принимается по ним.
|
|
||||||
Column {
|
Column {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 18
|
anchors.leftMargin: 18
|
||||||
@@ -314,8 +290,6 @@ 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
|
||||||
|
|||||||
@@ -4,25 +4,16 @@ 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
|
||||||
@@ -46,8 +37,6 @@ 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
|
||||||
@@ -69,7 +58,6 @@ 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()
|
||||||
@@ -117,9 +105,6 @@ 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)
|
||||||
@@ -133,8 +118,6 @@ 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()
|
||||||
@@ -305,7 +288,6 @@ Dialog {
|
|||||||
contentItem: RowLayout {
|
contentItem: RowLayout {
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
// ── Категории ───────────────────────────────────────────────────
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.preferredWidth: 150
|
Layout.preferredWidth: 150
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
@@ -358,7 +340,6 @@ Dialog {
|
|||||||
color: "#333333"
|
color: "#333333"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Версии ──────────────────────────────────────────────────────
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
@@ -493,8 +474,6 @@ 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"
|
||||||
@@ -531,7 +510,6 @@ Dialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пустая категория, ничего не нашлось или манифест ещё не пришёл.
|
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: versionList
|
anchors.centerIn: versionList
|
||||||
width: versionList.width - 20
|
width: versionList.width - 20
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Подготовка 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 на месте."
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Полная версия лаунчера и канал сборки (dev / rc / release).
|
||||||
|
#
|
||||||
|
# В репозитории хранится только число X.Y.Z из project(), одинаковое во всех
|
||||||
|
# ветках, — поэтому merge dev → rc → master не трогает версию. Канал
|
||||||
|
# вычисляется при сборке (по убыванию приоритета):
|
||||||
|
# 1. LAUNCHER_BUILD_VERSION — полная версия от CI: 1.0.1, 1.0.1-rc2;
|
||||||
|
# 2. тег на HEAD: vX.Y.Z — релиз, vX.Y.Z-rcN — rc;
|
||||||
|
# 3. ветка (из переменных CI или git): master — релиз, rc/beta — rc;
|
||||||
|
# 4. всё остальное, в т.ч. сборка без git, — dev.
|
||||||
|
#
|
||||||
|
# Работает в двух режимах:
|
||||||
|
# include() при configure — проверяет входные данные и пишет заголовок;
|
||||||
|
# cmake -P на каждой сборке — чтобы после git checkout без reconfigure
|
||||||
|
# канал и хэш коммита не устаревали.
|
||||||
|
# Входные переменные: LAUNCHER_VERSION_NUMERIC, LAUNCHER_SOURCE_DIR,
|
||||||
|
# LAUNCHER_VERSION_HEADER, LAUNCHER_BUILD_VERSION (необязательна).
|
||||||
|
# Результат: LAUNCHER_VERSION, LAUNCHER_CHANNEL и файл LAUNCHER_VERSION_HEADER.
|
||||||
|
|
||||||
|
set(_numeric "${LAUNCHER_VERSION_NUMERIC}")
|
||||||
|
|
||||||
|
find_package(Git QUIET)
|
||||||
|
function(_launcher_git out)
|
||||||
|
set(${out} "" PARENT_SCOPE)
|
||||||
|
if(NOT GIT_EXECUTABLE)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
execute_process(COMMAND "${GIT_EXECUTABLE}" ${ARGN}
|
||||||
|
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}"
|
||||||
|
RESULT_VARIABLE _rc OUTPUT_VARIABLE _out ERROR_QUIET
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE)
|
||||||
|
if(_rc EQUAL 0)
|
||||||
|
set(${out} "${_out}" PARENT_SCOPE)
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
set(_full "")
|
||||||
|
if(LAUNCHER_BUILD_VERSION)
|
||||||
|
set(_full "${LAUNCHER_BUILD_VERSION}")
|
||||||
|
string(REGEX REPLACE "^v" "" _full "${_full}")
|
||||||
|
string(REGEX REPLACE "-.*$" "" _base "${_full}")
|
||||||
|
if(NOT _base STREQUAL _numeric)
|
||||||
|
message(FATAL_ERROR "LAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION} не совпадает "
|
||||||
|
"с project(BiLauncher VERSION ${_numeric})")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
# Тег vX.Y.Z или vX.Y.Z-rcN ровно на HEAD, относящийся к текущей версии
|
||||||
|
_launcher_git(_tags tag --points-at HEAD)
|
||||||
|
string(REPLACE "\n" ";" _tags "${_tags}")
|
||||||
|
string(REPLACE "." "\\." _numeric_re "${_numeric}")
|
||||||
|
foreach(_tag IN LISTS _tags)
|
||||||
|
if(_tag MATCHES "^v(${_numeric_re}(-rc[0-9]+)?)$")
|
||||||
|
# Релизный тег важнее rc-тега на том же коммите
|
||||||
|
if(NOT _full OR NOT CMAKE_MATCH_2)
|
||||||
|
set(_full "${CMAKE_MATCH_1}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(_full)
|
||||||
|
if(_full MATCHES "-rc[0-9]*$")
|
||||||
|
set(LAUNCHER_CHANNEL "rc")
|
||||||
|
elseif(_full STREQUAL _numeric)
|
||||||
|
set(LAUNCHER_CHANNEL "release")
|
||||||
|
else()
|
||||||
|
set(LAUNCHER_CHANNEL "dev")
|
||||||
|
endif()
|
||||||
|
set(LAUNCHER_VERSION "${_full}")
|
||||||
|
else()
|
||||||
|
# В CI на detached HEAD имени ветки в git нет — берём его из окружения
|
||||||
|
set(_branch "$ENV{GITHUB_HEAD_REF}")
|
||||||
|
if(NOT _branch AND "$ENV{GITHUB_REF_TYPE}" STREQUAL "branch")
|
||||||
|
set(_branch "$ENV{GITHUB_REF_NAME}")
|
||||||
|
endif()
|
||||||
|
if(NOT _branch)
|
||||||
|
_launcher_git(_branch rev-parse --abbrev-ref HEAD)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(_branch STREQUAL "master")
|
||||||
|
set(LAUNCHER_CHANNEL "release")
|
||||||
|
set(LAUNCHER_VERSION "${_numeric}")
|
||||||
|
elseif(_branch STREQUAL "rc" OR _branch STREQUAL "beta")
|
||||||
|
set(LAUNCHER_CHANNEL "rc")
|
||||||
|
set(LAUNCHER_VERSION "${_numeric}-rc")
|
||||||
|
else()
|
||||||
|
set(LAUNCHER_CHANNEL "dev")
|
||||||
|
set(LAUNCHER_VERSION "${_numeric}-dev")
|
||||||
|
_launcher_git(_hash rev-parse --short=7 HEAD)
|
||||||
|
if(_hash)
|
||||||
|
# Неотслеживаемые файлы (каталоги сборки и т.п.) не считаем
|
||||||
|
execute_process(COMMAND "${GIT_EXECUTABLE}" diff --quiet HEAD --
|
||||||
|
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}" RESULT_VARIABLE _dirty ERROR_QUIET)
|
||||||
|
if(NOT _dirty EQUAL 0)
|
||||||
|
set(_hash "${_hash}.dirty")
|
||||||
|
endif()
|
||||||
|
string(APPEND LAUNCHER_VERSION "+${_hash}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
configure_file("${CMAKE_CURRENT_LIST_DIR}/launcherversion.h.in" "${LAUNCHER_VERSION_HEADER}" @ONLY)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Сгенерировано cmake/LauncherVersion.cmake — не редактировать.
|
||||||
|
|
||||||
|
#define LAUNCHER_VERSION "@LAUNCHER_VERSION@" // 1.0.1, 1.0.1-rc2, 1.0.1-dev+a1b2c3d
|
||||||
|
#define LAUNCHER_VERSION_NUMERIC "@LAUNCHER_VERSION_NUMERIC@"
|
||||||
|
#define LAUNCHER_CHANNEL "@LAUNCHER_CHANNEL@" // dev, rc, release
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Сборка, упаковка и релизы
|
||||||
|
|
||||||
|
## Что нужно
|
||||||
|
|
||||||
|
- CMake 3.20+, компилятор C++17 и C (C нужен для zlib на Linux).
|
||||||
|
- Qt 6.8 или новее (`qt_standard_project_setup(REQUIRES 6.8)`). CI собирает на Qt 6.11.1.
|
||||||
|
- Обязательные модули: `Quick`, `QuickControls2`, `Qml`, `Svg`, `Core`, `CorePrivate`, `Gui`,
|
||||||
|
`Network`.
|
||||||
|
- Необязательный: `WebEngineQuick`, для входа через Microsoft.
|
||||||
|
|
||||||
|
`CorePrivate` нужен ради `QZipReader` и `QZipWriter`: ими распаковываются нативные библиотеки
|
||||||
|
LWJGL, архивы Temurin и архивы сборок. Приватные модули Qt не гарантируют совместимость между
|
||||||
|
версиями, поэтому проект привязан к конкретной версии Qt. Предупреждение об этом отключено
|
||||||
|
(`QT_NO_PRIVATE_MODULE_WARNING`).
|
||||||
|
|
||||||
|
### Qt WebEngine
|
||||||
|
|
||||||
|
Модуль ставится в MaintenanceTool отдельной галочкой и тянет за собой Qt WebChannel и Qt
|
||||||
|
Positioning. Чтобы он работал, нужны все три.
|
||||||
|
|
||||||
|
Если сделать WebEngine обязательным, у любого, кто его не поставил, проект вообще не соберётся,
|
||||||
|
хотя офлайн-вход и Ely.by без него работают. Поэтому:
|
||||||
|
|
||||||
|
- модуль найден — определяется `LAUNCHER_HAS_WEBENGINE`, `MicrosoftLoginDialog.qml` попадает в
|
||||||
|
модуль, `backend.microsoftAvailable` равно `true`;
|
||||||
|
- не найден — CMake выдаёт предупреждение со списком компонентов, которые надо доставить, а
|
||||||
|
лаунчер собирается без входа через Microsoft.
|
||||||
|
|
||||||
|
Релизные сборки в CI всегда с WebEngine: скрипты подготовки раннеров проверяют, что он есть.
|
||||||
|
|
||||||
|
## Локальная сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
|
||||||
|
Из Qt Creator проект открывается как обычный CMake-проект.
|
||||||
|
|
||||||
|
### Версия и канал сборки
|
||||||
|
|
||||||
|
В репозитории хранится только число: `project(BiLauncher VERSION x.y.z)` в `CMakeLists.txt`. Оно
|
||||||
|
одинаковое во всех ветках, поэтому merge `dev` → `rc` → `master` версию не перетирает и не даёт
|
||||||
|
конфликтов. `PROJECT_VERSION` идёт в ресурс версии `.exe`, в `Info.plist` и в установщик: все три
|
||||||
|
принимают только числовую версию.
|
||||||
|
|
||||||
|
Канал (dev / rc / релиз) в коде не записан — его на каждой сборке вычисляет
|
||||||
|
`cmake/LauncherVersion.cmake` и пишет в сгенерированный `<build>/generated/launcherversion.h`
|
||||||
|
(`LAUNCHER_VERSION`, `LAUNCHER_CHANNEL`). Порядок определения:
|
||||||
|
|
||||||
|
1. `-DLAUNCHER_BUILD_VERSION=1.0.1-rc2` — полная версия от CI; база обязана совпадать с
|
||||||
|
`project()`, иначе configure падает;
|
||||||
|
2. тег на HEAD: `vx.y.z` — релиз, `vx.y.z-rcN` — rc;
|
||||||
|
3. ветка (из `GITHUB_HEAD_REF`/`GITHUB_REF_NAME` или git): `master` — релиз, `rc` и `beta` — rc;
|
||||||
|
4. любая другая ветка или сборка без git — dev.
|
||||||
|
|
||||||
|
| Сборка | `LAUNCHER_VERSION` | В окне |
|
||||||
|
|---------------------------|----------------------|-----------------|
|
||||||
|
| релиз (master, `vx.y.z`) | `1.0.1` | `Версия: 1.0.1` |
|
||||||
|
| rc из CI (`vx.y.z-rcN`) | `1.0.1-rc2` | + бейдж RC |
|
||||||
|
| rc локально | `1.0.1-rc` | + бейдж RC |
|
||||||
|
| dev | `1.0.1-dev+a1b2c3d` | + бейдж DEV |
|
||||||
|
|
||||||
|
У dev-сборки после `+` — короткий хэш коммита, `.dirty` — есть незакоммиченные правки в
|
||||||
|
отслеживаемых файлах. Генерация идёт на каждой сборке, так что после `git checkout` другой
|
||||||
|
ветки reconfigure не нужен.
|
||||||
|
|
||||||
|
Полная строка уходит в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в
|
||||||
|
подпись «Версия: …» в левом верхнем углу окна; бейдж канала QML берёт из её суффикса. Почему так,
|
||||||
|
см. [ADR-0002](adr/0002-launcher-version-display.md).
|
||||||
|
|
||||||
|
**Когда поднимать версию.** Номер поднимается в `dev` в начале цикла (сразу после релиза) и
|
||||||
|
дальше приходит в `rc` и `master` через merge. Хотфикс прямо в `rc` или `master` — правка и bump
|
||||||
|
patch там же, затем обратный merge `master` → `rc` → `dev`.
|
||||||
|
|
||||||
|
## Что собирается
|
||||||
|
|
||||||
|
**`BiLauncher`** — исполняемый файл и QML-модуль с URI `BiLauncher`. В модуль входят все `.cpp`,
|
||||||
|
`.qml`, картинки из `images/`, `i18n/translations.json`, а также `CHANGELOG.md`, `LICENSE` и
|
||||||
|
`README.md`. Последние три сейчас никто из кода не читает, они просто лежат в ресурсах.
|
||||||
|
|
||||||
|
QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIRECTORY`). Если оставить по
|
||||||
|
умолчанию, на Linux папка `BiLauncher` совпадёт по имени с исполняемым файлом (у него нет
|
||||||
|
расширения), и линковка упадёт с «Is a directory».
|
||||||
|
|
||||||
|
**`launcher_zlib_reference`** — только на Linux. Это обычный zlib 1.3.1 из каталога `zlib/`,
|
||||||
|
выходной файл `libz.so.1` в `<build>/zlib/`. С ним лаунчер не линкуется. Библиотека нужна только
|
||||||
|
установщикам Forge и NeoForge на системах с zlib-ng, см. [zlibreference.h](cpp/zlibreference.md).
|
||||||
|
Исходники zlib — чужой код из upstream, документацией он не покрыт.
|
||||||
|
|
||||||
|
## Иконки
|
||||||
|
|
||||||
|
Все размеры собираются из одного файла `icons/master/icon_master.png` (1024×1024, рисунок до краёв
|
||||||
|
холста) скриптом `icons/generate_icons.sh`. Для него нужны ImageMagick и `iconutil`, то есть
|
||||||
|
запускается он на macOS. Правки вносятся в мастер, после чего скрипт перезапускается. Отдельные
|
||||||
|
размеры руками не правятся.
|
||||||
|
|
||||||
|
Системы по-разному добавляют поля вокруг иконки, поэтому один и тот же рисунок кладётся
|
||||||
|
по-разному:
|
||||||
|
|
||||||
|
- **macOS** сама поля не добавляет. По сетке Apple рисунок занимает 824 из 1024 точек, углы
|
||||||
|
скруглены радиусом 185.4. Результат — `icon.icns`, кладётся в `Contents/Resources` бандла.
|
||||||
|
- **Windows** ждёт картинку на весь холст. `icon.ico` содержит кадры 16–256, включая 20, 40 и 96
|
||||||
|
для масштабов 125/150/200%. Без явного списка ImageMagick положил бы в `.ico` только 1024×1024,
|
||||||
|
и система размывала бы иконку при уменьшении. Вкомпилирована через `icon.rc.in`, где заодно
|
||||||
|
ресурс версии.
|
||||||
|
- **Linux**: PNG 16–512 ставятся в тему `hicolor` под именем `bilauncher.png`, плюс
|
||||||
|
`bilauncher.desktop`. Окружения рабочего стола ищут иконку по имени из `Icon=`, а не по имени
|
||||||
|
файла.
|
||||||
|
|
||||||
|
Иконка окна задаётся в `main.cpp` из ресурса `:/icons/` (на всех системах, кроме macOS).
|
||||||
|
|
||||||
|
## Установка (`cmake --install`)
|
||||||
|
|
||||||
|
- исполняемый файл в `bin/` (на macOS — `BiLauncher.app`);
|
||||||
|
- `qt_generate_deploy_qml_app_script` раскладывает рядом Qt, плагины и QML-модули
|
||||||
|
(windeployqt/macdeployqt под капотом; на macOS с `-hardened-runtime`);
|
||||||
|
- `LICENSE`, `CHANGELOG.md`, `packaging/THIRD_PARTY_LICENSES.txt` в `share/doc/BiLauncher/` (кроме
|
||||||
|
macOS);
|
||||||
|
- на Linux ещё `libz.so.1` в `lib/BiLauncher/`, иконки и `.desktop`.
|
||||||
|
|
||||||
|
На Linux ставьте с `-DCMAKE_INSTALL_LIBDIR=lib`. Лаунчер ищет эталонный zlib в
|
||||||
|
`<bin>/../lib/BiLauncher`, а на Ubuntu по умолчанию `LIBDIR` был бы `lib/x86_64-linux-gnu`.
|
||||||
|
|
||||||
|
## Релизы
|
||||||
|
|
||||||
|
Релизы собирает Gitea Actions, `.gitea/workflows/release.yml`.
|
||||||
|
|
||||||
|
### Как выпустить версию
|
||||||
|
|
||||||
|
0. Запустить workflow `tests` вручную (Run workflow) и дождаться зелёного прогона на всех трёх
|
||||||
|
системах, включая дымовую проверку установленного дерева. `release.yml` от тестов не зависит,
|
||||||
|
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
|
||||||
|
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
|
||||||
|
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
|
||||||
|
Для пре-релиза можно завести отдельный раздел `## [x.y.z-rcN]`: он берётся в первую очередь,
|
||||||
|
а если его нет — используется `## [x.y.z]`.
|
||||||
|
3. Запустить релиз одним из способов:
|
||||||
|
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
|
||||||
|
прогон ничего не собирает: сначала поднимите версию.
|
||||||
|
- **Пуш в `rc`** — пре-релиз `vx.y.z-rcN`, где `N` — следующий свободный номер для этой версии
|
||||||
|
(`rc1`, `rc2`, ...).
|
||||||
|
- **Тег `vx.y.z`** — полноценный релиз, **тег `vx.y.z-rcN`** — пре-релиз. Пре-релизом
|
||||||
|
становится только тег с суффиксом `-rc`, любой другой суффикс даёт обычный релиз.
|
||||||
|
|
||||||
|
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
|
||||||
|
|
||||||
|
Для веток тег создаёт сам Gitea при публикации релиза, на коммите прогона. Этот тег снова
|
||||||
|
запускает `release.yml`, но прогон видит, что релиз уже есть, и ничего не собирает.
|
||||||
|
|
||||||
|
### Что происходит
|
||||||
|
|
||||||
|
1. **create-release** (Linux) — определяет тег (из пуша тега или по ветке), сверяет его с версией, вырезает раздел из `CHANGELOG.md` и
|
||||||
|
создаёт **черновик** релиза.
|
||||||
|
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
|
||||||
|
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
|
||||||
|
идёт с `-j 8`: на ВМ 6 ГБ памяти, и без ограничения компиляторы съедают её всю.
|
||||||
|
- **Windows**: `BiLauncher-<v>-win64-setup.exe` (Inno Setup) и
|
||||||
|
`BiLauncher-<v>-win64-portable.zip`. В portable нет `vc_redist`, среда выполнения MSVC должна
|
||||||
|
уже стоять в системе. Сборка идёт в `C:\r\...`, потому что дерево WebEngine легко выходит за
|
||||||
|
`MAX_PATH`. Zip делается системным `tar.exe` по полному пути: GNU tar из Git, который стоит в
|
||||||
|
`PATH` раньше, zip не умеет и принимает `C:` за имя удалённого хоста.
|
||||||
|
- **macOS**: `BiLauncher-<v>-macOS-universal.dmg` (arm64 + x86_64). Подпись ad-hoc: без неё
|
||||||
|
приложение вообще не запустится на Apple Silicon. Developer ID нет, поэтому Gatekeeper при
|
||||||
|
первом запуске всё равно спросит подтверждение.
|
||||||
|
3. **publish** собирает `SHA256SUMS.txt` из хешей всех трёх сборок, прикладывает его и снимает с
|
||||||
|
релиза флаг черновика.
|
||||||
|
|
||||||
|
Если хоть одна сборка упала, релиз остаётся черновиком и пользователи его не видят.
|
||||||
|
|
||||||
|
Ручной запуск (`workflow_dispatch`) делает те же сборки, но релиз не создаёт: файлы остаются
|
||||||
|
артефактами прогона.
|
||||||
|
|
||||||
|
Секрет `RELEASE_TOKEN` — персональный токен Gitea с правом `repository: write`.
|
||||||
|
|
||||||
|
### Установщик Windows
|
||||||
|
|
||||||
|
`packaging/windows/BiLauncher.iss` (Inno Setup 6). Ставит в `Program Files\BiLauncher`, разрешает
|
||||||
|
выбрать установку только для текущего пользователя, создаёт ярлык в меню «Пуск» и, по желанию, на
|
||||||
|
рабочем столе, тихо ставит `vc_redist`, если он есть в дереве. Языки установщика — русский и
|
||||||
|
английский.
|
||||||
|
|
||||||
|
`AppId` менять нельзя: по нему новая версия находит и обновляет старую установку.
|
||||||
|
|
||||||
|
## Раннеры
|
||||||
|
|
||||||
|
Раннеры работают в режиме host, то есть прямо на машине, без Docker. Метки: `linux`, `windows`,
|
||||||
|
`macos`.
|
||||||
|
|
||||||
|
Пути к Qt раннер передаёт сборке через переменные окружения в своём `config.yaml`:
|
||||||
|
|
||||||
|
- `QT_ROOT` — kit Qt (например, `~/Qt/6.11.1/gcc_64`);
|
||||||
|
- `QT_TOOLS` — папка `Tools` из Qt Online Installer, оттуда CMake и Ninja;
|
||||||
|
- `ISCC_DIR` — только Windows, папка Inno Setup.
|
||||||
|
|
||||||
|
Qt с WebEngine, CMake и Ninja ставятся заранее через Qt Online Installer. Скрипты подготовки
|
||||||
|
докачивают остальное и регистрируют раннер. Повторный запуск безопасен.
|
||||||
|
|
||||||
|
**Linux** (Ubuntu 22.04), `ci/setup-runner-linux.sh [токен]`. Запускать от пользователя, которому
|
||||||
|
принадлежит Qt, нужен sudo без пароля. Ставит компилятор, библиотеки для сборки и деплоя (xcb,
|
||||||
|
OpenGL, NSS и т. д.), `libfuse2` для appimagetool, Node.js (нужен `actions/checkout`), appimagetool,
|
||||||
|
gitea-runner и systemd-сервис `gitea-runner`.
|
||||||
|
|
||||||
|
**Windows**, `ci/setup-runner-windows.ps1 [-Token ...]` из PowerShell от администратора. Ставит
|
||||||
|
VS 2022 Build Tools (это 20–40 минут), Git, Node.js, Inno Setup, включает длинные пути в системе и
|
||||||
|
в git, регистрирует gitea-runner. Раннер запускается задачей планировщика при входе пользователя,
|
||||||
|
поэтому пароль учётной записи не нужен, но без входа в систему раннер не поднимется. Чтобы он
|
||||||
|
стартовал при загрузке машины, задачу нужно пересоздать с `/sc onstart /rp *`.
|
||||||
|
|
||||||
|
**macOS** — скрипта подготовки нет, раннер настроен вручную.
|
||||||
|
|
||||||
|
Те же раннеры гоняют автотесты (`.gitea/workflows/tests.yml`): Linux — на каждый push, все три
|
||||||
|
системы — ночью и по кнопке. Подробности — [testing.md](testing.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -1,170 +1,87 @@
|
|||||||
# AuthService
|
# AuthService
|
||||||
|
|
||||||
## Обзор класса
|
`authservice.h`, `authservice.cpp`
|
||||||
|
|
||||||
`Minecraft_launcher` поддерживает три способа входа: офлайн-профиль без пароля, учётную запись
|
Вход через Ely.by, офлайн-профиль и загрузка `authlib-injector`. Вход через Microsoft живёт
|
||||||
Ely.by и учётную запись Microsoft. `AuthService` закрывает первые два: это Yggdrasil-клиент Ely.by
|
отдельно, в [MsaAuthService](MsaAuthService.md), но отдаёт результат в той же структуре
|
||||||
плюс офлайн-режим. За третий отвечает [MsaAuthService](MsaAuthService.md).
|
`AuthResult`. Поэтому запуск игры дальше идёт одинаково, как бы игрок ни вошёл.
|
||||||
|
|
||||||
Результат любого способа — структура `AuthResult`, объявленная в этом же заголовке. Она содержит
|
Экземпляр создаёт и держит [LauncherBackend](LauncherBackend.md). Сервис ничего не пишет на диск,
|
||||||
ровно то, что подставляется в аргументы запуска вида `${auth_*}`, поэтому дальше запуск игры идёт
|
кроме скачанного jar: токены между запусками сохраняет бэкенд в `profiles.json`.
|
||||||
по общему пути независимо от того, как пользователь вошёл.
|
|
||||||
|
|
||||||
Класс также умеет скачивать `authlib-injector` — библиотеку, которая перенаправляет обращения игры
|
## AuthResult
|
||||||
к серверу авторизации на Ely.by.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
Каждое поле соответствует подстановке в аргументах запуска.
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); других владельцев нет.
|
| Поле | Подстановка / смысл |
|
||||||
Заголовок подключает [MsaAuthService](MsaAuthService.md) — ради общей структуры `AuthResult` — и
|
|------|---------------------|
|
||||||
[GameLauncher](GameLauncher.md) через поля `LaunchOptions`, которые заполняются из результата
|
| `ok` | вход удался |
|
||||||
авторизации.
|
| `twoFactorRequired` | Ely.by требует одноразовый код |
|
||||||
|
| `licenseMissing` | Microsoft: вход прошёл, но игры на аккаунте нет. Отдельный флаг, потому что повторный вход тут не поможет |
|
||||||
|
| `error` | текст ошибки, если `ok == false` |
|
||||||
|
| `playerName` | `${auth_player_name}` |
|
||||||
|
| `uuid` | `${auth_uuid}`, hex без дефисов |
|
||||||
|
| `accessToken` | `${auth_access_token}` |
|
||||||
|
| `clientToken` | `${clientid}` |
|
||||||
|
| `userType` | `${user_type}`: `legacy` (офлайн), `ELYBY`, `msa` |
|
||||||
|
| `refreshToken` | только Microsoft: продление сессии без пароля |
|
||||||
|
| `xuid` | только Microsoft: `${auth_xuid}` |
|
||||||
|
| `expiresAt` | только Microsoft: когда истекает `accessToken`, UTC |
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QJsonObject`, `QString`) и `Qt6::Network`
|
## Офлайн
|
||||||
(`QNetworkAccessManager`).
|
|
||||||
|
|
||||||
## Иерархия и роль
|
`static AuthResult offline(nickname)` работает без сети. UUID считается так же, как в самом
|
||||||
|
Minecraft: MD5 от `OfflinePlayer:<ник>`, после чего выставляются биты версии 3 и варианта RFC 4122.
|
||||||
|
Значит, у одного ника всегда один UUID, и на офлайн-сервере игрок не теряет инвентарь. В
|
||||||
|
`accessToken` и `clientToken` записывается `"0"`.
|
||||||
|
|
||||||
Наследует `QObject`: даёт мета-объектную систему, сигнал `progress` и владение по родителю.
|
Пустой ник — ошибка.
|
||||||
Виртуальных методов базового класса не переопределяет.
|
|
||||||
|
|
||||||
## Публичные структуры
|
## Ely.by
|
||||||
|
|
||||||
### AuthResult
|
Сервер — `https://authserver.ely.by`, протокол Yggdrasil, JSON по HTTPS.
|
||||||
|
|
||||||
Результат авторизации — то, что подставляется в `${auth_*}` аргументы запуска.
|
`loginElyBy(login, password, clientToken, accessToken, callback)` старается не спрашивать пароль:
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
1. Если сохранённого `accessToken` нет, сразу `/auth/authenticate` с паролем.
|
||||||
|------|-----|----------|
|
2. Если есть, `/auth/validate`. Ответ 200 — токен рабочий, вход закончен.
|
||||||
| `ok` | `bool` | Авторизация удалась |
|
3. Иначе `/auth/refresh`. Если продлить удалось, приходят новый токен, ник и UUID.
|
||||||
| `twoFactorRequired` | `bool` | Ely.by отклонил пароль с пометкой two factor — нужен одноразовый код |
|
4. Если и это не вышло, `/auth/authenticate` с паролем.
|
||||||
| `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 и сетевая ошибка), цепочка обрывается с ошибкой, без попытки войти
|
||||||
|
по паролю.
|
||||||
|
|
||||||
`AuthService::Callback` — `std::function<void(const AuthResult &)>`. Все сетевые методы
|
Второй фактор Ely.by не сообщает отдельным кодом. Сервис ищет в тексте ошибки подстроку
|
||||||
асинхронные: колбэк вызывается ровно один раз и всегда в потоке GUI.
|
`two factor` и выставляет `twoFactorRequired`. Тогда бэкенд спрашивает код у игрока и вызывает
|
||||||
|
`loginElyByWithTotp(login, password, totp, clientToken, callback)`, который отправляет пароль в виде
|
||||||
|
`пароль:код`, как требует Ely.by.
|
||||||
|
|
||||||
## Публичные методы
|
`clientToken` генерируется один раз на профиль (`generateClientToken()`, случайный UUID) и хранится
|
||||||
|
вместе с ним: Yggdrasil привязывает выданный `accessToken` к этому значению.
|
||||||
|
|
||||||
#### explicit AuthService(QObject \*parent = nullptr)
|
По ходу работы испускается `progress(message)`. Бэкенд пересылает его в `launchProgress`.
|
||||||
|
|
||||||
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
## authlib-injector
|
||||||
|
|
||||||
#### static AuthResult offline(const QString &nickname)
|
Без него игра пойдёт проверять сессию на серверы Mojang, и токен Ely.by окажется недействительным.
|
||||||
|
Jar подключается к JVM через `-javaagent` (см. [GameLauncher](GameLauncher.md)).
|
||||||
|
|
||||||
Готовит результат для офлайн-профиля без единого сетевого запроса. UUID выводится из ника так же,
|
`ensureAuthlibInjector(targetDir, callback)`:
|
||||||
как это делает сам Minecraft в офлайне: `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name)
|
|
||||||
.getBytes(UTF_8))`. Благодаря этому один и тот же ник всегда даёт один и тот же UUID, и прогресс
|
|
||||||
на сервере не теряется.
|
|
||||||
|
|
||||||
#### static QString generateClientToken()
|
- если `<targetDir>/authlib-injector.jar` уже есть, сразу отдаёт путь. Версия не проверяется, и
|
||||||
|
обновить файл можно, только удалив его;
|
||||||
|
- иначе читает `https://authlib-injector.yushi.moe/artifact/latest.json`, скачивает jar по
|
||||||
|
`download_url` и, если в манифесте есть `checksums.sha256`, сверяет хеш.
|
||||||
|
|
||||||
Случайный `clientToken` лаунчера. Генерируется один раз на профиль и хранится вместе с ним:
|
Колбэк получает либо путь, либо текст ошибки. `targetDir` — это `<root>/runtime`.
|
||||||
Yggdrasil связывает выданный `accessToken` именно с этим значением.
|
|
||||||
|
|
||||||
#### void loginElyBy(const QString &login, const QString &password, const QString &clientToken, const QString &accessToken, Callback callback)
|
## Как вызываются колбэки
|
||||||
|
|
||||||
Полный цикл входа в Ely.by: сначала проверка имеющегося токена, затем его продление, и только при
|
Все сетевые методы асинхронные, колбэк вызывается один раз в GUI-потоке. Если сервис удалить во
|
||||||
неудаче — авторизация по паролю. Пароль можно оставить пустым, если уже есть рабочий
|
время запроса, менеджер сети уйдёт вместе с ним, и колбэк не вызовется вообще. Бэкенд к тому же
|
||||||
`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<void(const QString &path, const QString &error)> callback)
|
|
||||||
|
|
||||||
Скачивает `authlib-injector` в `targetDir`, если его там ещё нет. Колбэк получает либо путь к
|
|
||||||
готовому jar, либо текст ошибки — заполнено всегда ровно одно из двух.
|
|
||||||
|
|
||||||
Библиотека нужна только для профилей Ely.by: она подключается к JVM аргументом `-javaagent` и
|
|
||||||
перенаправляет обращения игры к серверу авторизации.
|
|
||||||
|
|
||||||
## Сигналы
|
|
||||||
|
|
||||||
#### progress(const QString &message)
|
|
||||||
|
|
||||||
Описание текущего шага авторизации. Испускается по ходу всех сетевых операций.
|
|
||||||
|
|
||||||
Обработчик показывает сообщение пользователю: в главном окне лаунчера оно попадает в плашку
|
|
||||||
статуса и держится до следующего сообщения, потому что шаг может занять заметное время.
|
|
||||||
|
|
||||||
## Владение и время жизни
|
|
||||||
|
|
||||||
Класс наследует `QObject` и принимает `parent` в конструкторе — родитель его и удалит.
|
|
||||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя и уничтожается вместе
|
|
||||||
с ним.
|
|
||||||
|
|
||||||
Колбэки захватываются по значению и живут до своего единственного вызова. Уничтожение сервиса во
|
|
||||||
время незавершённого запроса отменяет запрос вместе с менеджером сети — колбэк в этом случае не
|
|
||||||
вызывается, поэтому захватывать в него сырые указатели на объекты с меньшим временем жизни, чем у
|
|
||||||
сервиса, нельзя.
|
|
||||||
|
|
||||||
## Потокобезопасность
|
|
||||||
|
|
||||||
Только поток GUI. Все сетевые методы асинхронные, и их колбэки вызываются в том же потоке, в
|
|
||||||
котором создан сервис. Собственной синхронизации в классе нет.
|
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
|
||||||
|
|
||||||
`LauncherBackend` вызывает методы входа при запуске игры и переправляет сигнал `progress` в
|
|
||||||
интерфейс. Полученный `AuthResult` он раскладывает по полям `LaunchOptions`, которые уходят в
|
|
||||||
[GameLauncher](GameLauncher.md). Путь, возвращённый `ensureAuthlibInjector()`, попадает в поле
|
|
||||||
`authlibInjectorPath` тех же параметров запуска.
|
|
||||||
|
|
||||||
Сохранением токенов между запусками занимается `LauncherBackend`: сам сервис ничего не пишет на
|
|
||||||
диск, кроме скачанного jar.
|
|
||||||
|
|
||||||
## Внешнее взаимодействие
|
|
||||||
|
|
||||||
**Сеть, исходящие запросы.** Класс общается с сервером авторизации Ely.by через
|
|
||||||
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запросы инициирует всегда лаунчер. Внутренний
|
|
||||||
помощник `postJson()` разделяет три исхода: успешный ответ, ответ с кодом ошибки и транспортную
|
|
||||||
ошибку — последняя отдаётся отдельным параметром, чтобы отличить недоступную сеть от отказа
|
|
||||||
сервера.
|
|
||||||
|
|
||||||
Отдельным каналом идёт загрузка `authlib-injector` — обычная HTTPS-загрузка файла в
|
|
||||||
`targetDir`. Повторных попыток при неудаче класс не делает: решение о повторе принимает вызывающий
|
|
||||||
код.
|
|
||||||
|
|
||||||
Все сигналы и колбэки приходят в поток GUI.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
auto *auth = new AuthService(this);
|
|
||||||
connect(auth, &AuthService::progress, this, &Backend::showStatus);
|
|
||||||
|
|
||||||
auth->loginElyBy(profile.login, profile.password,
|
|
||||||
profile.clientToken, profile.accessToken,
|
|
||||||
[this](const AuthResult &result) {
|
|
||||||
if (result.twoFactorRequired) {
|
|
||||||
emit twoFactorRequired(m_pendingProfileName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!result.ok) {
|
|
||||||
emit launchError(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
continueLaunch(result);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +1,54 @@
|
|||||||
# BuildArchiveWorker
|
# BuildArchiveWorker
|
||||||
|
|
||||||
## Обзор класса
|
`buildarchiveworker.h`, `buildarchiveworker.cpp`
|
||||||
|
|
||||||
Смена сборки в лаунчере — это перекладывание содержимого `.minecraft`: текущее упаковывается в
|
Файловые операции над `.minecraft`: упаковать, очистить, распаковать, раскатать пак. В
|
||||||
архив, папка вычищается, на её место распаковывается архив другой сборки. Речь о гигабайтах модов,
|
`.minecraft` могут лежать гигабайты модов и миров, поэтому это единственная часть проекта,
|
||||||
конфигов и миров, и гонять их в потоке GUI нельзя — окно замерзало бы на всё время смены.
|
которая работает не в GUI-потоке.
|
||||||
|
|
||||||
`BuildArchiveWorker` — исполнитель этих операций в отдельном потоке. Он умеет четыре вещи:
|
Объект создаёт [BuildSwitcher](BuildSwitcher.md) без родителя, переносит в свой `QThread` и вызывает
|
||||||
упаковать, вычистить, распаковать и докатить пак сезонной сборки поверх уже разложенного
|
слоты через `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`. Работник не знает ни про
|
||||||
содержимого.
|
сборки, ни про журнал. Он получает пути и отвечает сигналами.
|
||||||
|
|
||||||
Класс намеренно ничего не знает ни о сборках, ни о путях лаунчера: он принимает готовые пути и
|
ZIP читается и пишется через `QZipReader` и `QZipWriter` из `Qt6::CorePrivate`.
|
||||||
списки. Порядок шагов и восстановление после сбоя — дело [BuildSwitcher](BuildSwitcher.md).
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
## Слоты
|
||||||
|
|
||||||
Создаётся и целиком управляется [BuildSwitcher](BuildSwitcher.md), который же и переносит его в
|
Каждый слот заканчивается одним `finished(ok, error)`. По ходу работы испускается
|
||||||
собственный `QThread`. Больше к классу никто не обращается.
|
`progress(done, total, currentPath)` на каждый файл.
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` и `Qt6::CorePrivate` — последний нужен ради `QZipReader` и
|
### archive(gameDir, tempZipPath, excludeTop)
|
||||||
`QZipWriter`, которыми читаются и пишутся архивы сборок.
|
|
||||||
|
|
||||||
## Иерархия и роль
|
Упаковывает всё содержимое `gameDir`, кроме папок и файлов верхнего уровня из `excludeTop`.
|
||||||
|
Символьные ссылки пропускаются. Сжатие `AutoCompress`: Qt сам не сжимает то, что уже сжато.
|
||||||
|
При ошибке или отмене недописанный архив удаляется.
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, слоты, сигналы и возможность жить в отдельном потоке
|
### clear(gameDir, keepTop)
|
||||||
через `moveToThread()`. Виртуальных методов базового класса не переопределяет.
|
|
||||||
|
|
||||||
Объект живёт в своём `QThread` и вызывается только через очередь сигналов — прямых вызовов его
|
Удаляет всё на верхнем уровне `gameDir`, кроме `keepTop`. Символьная ссылка удаляется как файл,
|
||||||
слотов из потока GUI быть не должно.
|
то, на что она указывает, не трогается. Если `gameDir` нет, это успех. Отменить нельзя.
|
||||||
|
|
||||||
## Публичные методы
|
### restore(zipPath, gameDir)
|
||||||
|
|
||||||
#### explicit BuildArchiveWorker(QObject \*parent = nullptr)
|
Распаковывает архив в `gameDir`. Пути с `..`, абсолютные, с `\` или `:` останавливают распаковку.
|
||||||
|
|
||||||
Создаёт исполнителя. Конструктор помечен `explicit`.
|
### applyPack(zipPath, gameDir, removeRelative, forbiddenTop)
|
||||||
|
|
||||||
#### void requestCancel()
|
1. Сначала проверяет весь архив. Небезопасный путь или файл внутри общих папок (`forbiddenTop`:
|
||||||
|
`versions`, `libraries` и т. д.) — отказ до любых изменений. Пак не может подменить jar игры
|
||||||
|
или библиотеки.
|
||||||
|
2. Удаляет файлы из `removeRelative`. Опасные пути и пути в общих папках молча пропускаются. После
|
||||||
|
этого удаляются папки, которые стали пустыми.
|
||||||
|
3. Распаковывает пак, перезаписывая существующие файлы.
|
||||||
|
4. Испускает `packEntries(список файлов пака)`, потом `finished(true)`.
|
||||||
|
|
||||||
Просит прервать текущую операцию. Вызывается из потока GUI прямо во время работы — это
|
## Отмена
|
||||||
единственный метод класса, предназначенный для вызова снаружи рабочего потока.
|
|
||||||
|
|
||||||
Отмена не прерывает операцию мгновенно: признак проверяется между файлами. Хранится он в
|
`requestCancel()` и `clearCancel()` выставляют атомарный флаг, его можно трогать из любого потока.
|
||||||
`QAtomicInt`, поэтому запись из одного потока и чтение из другого безопасны без блокировок.
|
Флаг проверяется перед каждым файлом в `archive`, `restore` и `applyPack`.
|
||||||
|
|
||||||
#### void clearCancel()
|
Файлы распаковываются через `QFile`, а не `QSaveFile`. Если операцию прервать, последний файл
|
||||||
|
может остаться недописанным. Поэтому `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 ¤tPath)
|
|
||||||
|
|
||||||
Ход операции: сколько файлов обработано из скольких и какой обрабатывается сейчас. Испускается по
|
|
||||||
ходу всех четырёх операций.
|
|
||||||
|
|
||||||
Обработчик — `BuildSwitcher` — пересчитывает долю выполнения и обновляет панель прогресса. Сигнал
|
|
||||||
приходит в поток GUI через очередь, поэтому прямого доступа к состоянию рабочего потока у
|
|
||||||
обработчика нет.
|
|
||||||
|
|
||||||
#### finished(bool ok, const QString &error)
|
|
||||||
|
|
||||||
Операция завершена. При `ok == false` во втором параметре — текст ошибки.
|
|
||||||
|
|
||||||
Обработчик решает, что делать дальше: перейти к следующему шагу смены сборки или откатить уже
|
|
||||||
сделанное.
|
|
||||||
|
|
||||||
#### packEntries(const QStringList &entries)
|
|
||||||
|
|
||||||
Что именно принёс пак — относительными путями. Испускается только из `applyPack()`.
|
|
||||||
|
|
||||||
Список сохраняется в описании сезонной сборки: следующему обновлению он нужен, чтобы вычислить,
|
|
||||||
какие файлы из сборки ушли, и передать их в `removeRelative`.
|
|
||||||
|
|
||||||
## Владение и время жизни
|
|
||||||
|
|
||||||
Класс наследует `QObject` и принимает `parent`, но на практике родителя не имеет: объект,
|
|
||||||
перенесённый в другой поток через `moveToThread()`, не может иметь родителя в потоке GUI.
|
|
||||||
Ответственность за его удаление лежит на `BuildSwitcher`, который создаёт и поток, и исполнителя.
|
|
||||||
|
|
||||||
Удалять объект следует безопасным для потоков способом — не напрямую из потока GUI во время
|
|
||||||
работы. Уничтожение потока раньше исполнителя приведёт к обрыву незавершённой операции.
|
|
||||||
|
|
||||||
## Потокобезопасность
|
|
||||||
|
|
||||||
Класс рассчитан на жизнь в отдельном потоке. Все четыре слота выполняются в рабочем потоке;
|
|
||||||
вызывать их напрямую нельзя — только через очередь сигналов.
|
|
||||||
|
|
||||||
Единственная точка межпотокового взаимодействия — признак отмены в `QAtomicInt`:
|
|
||||||
`requestCancel()` и `clearCancel()` пишут его из потока GUI, а рабочий поток читает между файлами.
|
|
||||||
Другого разделяемого состояния у класса нет, поэтому иных блокировок не требуется.
|
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
|
||||||
|
|
||||||
Единственный собеседник — [BuildSwitcher](BuildSwitcher.md). Он создаёт поток и исполнителя,
|
|
||||||
переносит второй в первый, вызывает слоты через очередь сигналов и принимает `progress`,
|
|
||||||
`finished` и `packEntries`.
|
|
||||||
|
|
||||||
Пути и списки исключений `BuildSwitcher` берёт из [launcherpaths.h](launcherpaths.md) и из
|
|
||||||
описаний сборок; сам исполнитель к ним не обращается.
|
|
||||||
|
|
||||||
## Внешнее взаимодействие
|
|
||||||
|
|
||||||
**Файловая система.** Класс читает, пишет и удаляет файлы в папке игры и в каталоге архивов
|
|
||||||
сборок. Сетевых обращений и дочерних процессов у него нет: архив сезонной сборки к моменту вызова
|
|
||||||
`applyPack()` уже скачан [SeasonalPackDownloader](SeasonalPackDownloader.md).
|
|
||||||
|
|
||||||
Все операции выполняются в рабочем потоке, все сигналы приходят в поток GUI через очередь.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
auto *thread = new QThread(this);
|
|
||||||
auto *worker = new BuildArchiveWorker; // без родителя: уедет в другой поток
|
|
||||||
worker->moveToThread(thread);
|
|
||||||
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
|
|
||||||
thread->start();
|
|
||||||
|
|
||||||
connect(worker, &BuildArchiveWorker::progress, this, &Switcher::onProgress);
|
|
||||||
connect(worker, &BuildArchiveWorker::finished, this, &Switcher::onStepFinished);
|
|
||||||
|
|
||||||
worker->clearCancel();
|
|
||||||
QMetaObject::invokeMethod(worker, "archive", Qt::QueuedConnection,
|
|
||||||
Q_ARG(QString, gameDir),
|
|
||||||
Q_ARG(QString, tempZipPath),
|
|
||||||
Q_ARG(QStringList, excludeTop));
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,210 +1,105 @@
|
|||||||
# 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`, по одному на сборку.
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Класс создаёт и целиком
|
Общие для всех сборок папки и файлы никогда не архивируются и не удаляются (`sharedTopLevel()`):
|
||||||
владеет своим `QThread` и объектом [BuildArchiveWorker](BuildArchiveWorker.md).
|
|
||||||
|
|
||||||
Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
|
- `assets`
|
||||||
(`buildStorageDir()`, `buildDir()`).
|
- `libraries`
|
||||||
|
- `versions`
|
||||||
|
- `launcher_profiles.json`
|
||||||
|
- `runtime`
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QThread`, `QJsonObject`) и `Qt6::CorePrivate` — опосредованно,
|
Всё остальное (моды, конфиги, миры, скриншоты, `options.txt`) принадлежит сборке.
|
||||||
через исполнителя.
|
|
||||||
|
|
||||||
## Иерархия и роль
|
В папке сборки могут лежать и другие zip, но файлы с точкой в начале имени не считаются архивом
|
||||||
|
сборки. Это временные архивы (`.tmp-*.zip`) и скачанные паки сезонных сборок (`.pack-r*.zip`).
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, три сигнала и владение по родителю. Объявлен
|
## switchTo(fromId, fromName, toId, toName, gameDir)
|
||||||
виртуальный деструктор — класс обязан корректно остановить свой поток.
|
|
||||||
|
|
||||||
## Публичные методы
|
1. **Архивация.** Если прежней сборки нет (`fromId < 0`) или папки игры нет, шаг пропускается.
|
||||||
|
Иначе сначала проверяется свободное место: размер всего, что уйдёт в архив, плюс 256 МБ. Потом
|
||||||
|
архив пишется во временный `builds/<from>/.tmp-<uuid>.zip`. Если на этом шаге что-то пошло не
|
||||||
|
так, `.minecraft` не тронута.
|
||||||
|
2. **Фиксация архива.** Временный файл переименовывается в `<гггг-мм-дд>_<имя>.zip`, остальные
|
||||||
|
архивы этой сборки удаляются. В `index.json` записывается `state: "switching"` с `from` и `to`.
|
||||||
|
3. **Очистка.** Из `.minecraft` удаляется всё, кроме общего.
|
||||||
|
4. **Распаковка.** Если у новой сборки есть архив, он распаковывается. Если нет (новая сборка),
|
||||||
|
`.minecraft` остаётся пустой, только с общими папками.
|
||||||
|
5. В `index.json` записываются `state: "idle"` и `active`, испускается `finished(toId)`.
|
||||||
|
|
||||||
#### explicit BuildSwitcher(QObject \*parent = nullptr)
|
При ошибке приходит `failed(toId, message, gameDirIntact)`. `gameDirIntact` равен `true`, если
|
||||||
|
ошибка случилась до очистки. Бэкенд по нему выбирает текст: «ничего не изменилось» или «папка
|
||||||
|
игры разобрана».
|
||||||
|
|
||||||
Создаёт переключатель, рабочий поток и исполнителя, переносит второй в первый и запускает поток.
|
`cancel()` работает только на шаге архивации. После него отступать уже некуда.
|
||||||
Конструктор помечен `explicit`.
|
|
||||||
|
|
||||||
#### bool isRunning() const
|
## applyPack(buildId, buildName, packZip, removeRelative, note, gameDir)
|
||||||
|
|
||||||
Идёт ли сейчас переключение или раскатка пака.
|
Раскатка пака поверх активной сборки. Сначала в `index.json` пишется `state: "applying"` со всем,
|
||||||
|
что нужно для повтора: имя файла пака, имя сборки, список на удаление и `note`. Потом работник
|
||||||
|
удаляет файлы прошлой ревизии и распаковывает пак.
|
||||||
|
|
||||||
#### QString stage() const
|
После успеха пак удаляется, `index.json` возвращается в `idle`, а `lastPackEntries()` и
|
||||||
|
`lastPackNote()` содержат список разложенных файлов и `note`. Бэкенд забирает их в обработчике
|
||||||
|
`finished` и пишет `season.json`.
|
||||||
|
|
||||||
Текущий этап словами — заголовок панели прогресса.
|
`gameDirIntact` при ошибке раскатки равен `true`, только если работник не успел обработать ни одного
|
||||||
|
файла.
|
||||||
|
|
||||||
#### QString status() const
|
## После падения
|
||||||
|
|
||||||
Строка состояния: что обрабатывается сейчас.
|
При старте бэкенд вызывает `interruptedSwitchWarning()`. Если в `index.json` осталось
|
||||||
|
`switching` или `applying`, метод возвращает текст для пользователя, а `resumeInterrupted(gameDir)`
|
||||||
|
продолжает операцию:
|
||||||
|
|
||||||
#### double fraction() const
|
- `switching` — архив прежней сборки уже записан целиком, поэтому начинаем с очистки и
|
||||||
|
распаковываем сборку `to`. Начинать с архивации нельзя: заархивировалась бы полуразобранная
|
||||||
|
`.minecraft`;
|
||||||
|
- `applying` — раскатка повторяется с тем же паком и тем же списком на удаление. Если файла пака
|
||||||
|
уже нет, журнал просто сбрасывается в `idle`.
|
||||||
|
|
||||||
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.
|
Если программа упала во время самой архивации, в журнале ещё ничего нет, и продолжать нечего:
|
||||||
|
`.minecraft` цела, а временный архив удалится при следующем старте (`sweepTempArchives()` в
|
||||||
|
конструкторе).
|
||||||
|
|
||||||
#### static QStringList sharedTopLevel()
|
## Прочее
|
||||||
|
|
||||||
Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
|
- `forgetBuild(id)` — удаляет запись о сборке из `index.json` и папку `builds/<id>` целиком.
|
||||||
лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
|
- `hasArchive(id)` — есть ли у сборки архив.
|
||||||
как `keepTop` при очистке.
|
- `isRunning()`, `stage()`, `status()` (текущий файл), `fraction()` — для панели прогресса.
|
||||||
|
Сигнал `progressChanged()` не ограничен по частоте и приходит на каждый обработанный файл.
|
||||||
|
|
||||||
#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)
|
### index.json
|
||||||
|
|
||||||
Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
|
`<root>/builds/index.json`:
|
||||||
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
|
|
||||||
сборки.
|
|
||||||
|
|
||||||
Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.
|
```json
|
||||||
|
{
|
||||||
#### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject ¬e, const QString &gameDir)
|
"state": "idle",
|
||||||
|
"active": 3,
|
||||||
Докатывает пак сезонной сборки поверх содержимого `.minecraft`. Вызывается только для уже активной
|
"builds": {
|
||||||
сборки: пак ложится на то, что сейчас разложено, а не внутрь чужого архива.
|
"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 секунд.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
При создании этого документа использовался ИИ.
|
При создании этого документа использовался ИИ.
|
||||||
@@ -1,199 +1,106 @@
|
|||||||
# GameLauncher
|
# GameLauncher
|
||||||
|
|
||||||
## Обзор класса
|
`gamelauncher.h`, `gamelauncher.cpp`
|
||||||
|
|
||||||
`GameLauncher` — то, ради чего существует весь остальной лаунчер: он готовит и запускает JVM с
|
Собирает командную строку, распаковывает нативные библиотеки и запускает JVM с игрой. К этому
|
||||||
Minecraft. К моменту его вызова уже известно всё — версия разобрана, файлы скачаны, пользователь
|
моменту версия уже разобрана ([minecraftversion.h](minecraftversion.md)), файлы скачаны, а игрок
|
||||||
авторизован, — и класс превращает это в командную строку и дочерний процесс.
|
вошёл в аккаунт. Всё остальное приходит в структуре `LaunchOptions`.
|
||||||
|
|
||||||
Кроме самого запуска класс умеет проверять комплектность `.minecraft` и распаковывать нативные
|
Экземпляр один, его держит [LauncherBackend](LauncherBackend.md). Одновременно может работать
|
||||||
библиотеки, без которых игра не стартует.
|
только одна игра.
|
||||||
|
|
||||||
Один экземпляр — одна игра одновременно.
|
## LaunchOptions
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
| Поле | По умолчанию | Что это |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `gameDir` | | папка игры, она же рабочий каталог процесса |
|
||||||
|
| `versionId` | | папка в `versions/` |
|
||||||
|
| `playerName`, `uuid`, `accessToken`, `userType`, `clientToken`, `xuid` | | из [AuthResult](AuthService.md) |
|
||||||
|
| `javaPath` | пусто | пусто — ищет [JavaLocator](javalocator.md) |
|
||||||
|
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx`; 0 — не передавать |
|
||||||
|
| `extraJvmArgs` | | аргументы JVM из настроек |
|
||||||
|
| `windowWidth`, `windowHeight` | 0, 0 | 0 — не передавать размер |
|
||||||
|
| `fullscreen` | `false` | |
|
||||||
|
| `serverAddress` | пусто | `host[:port]`, сервер для автоподключения |
|
||||||
|
| `authlibInjectorPath` | пусто | путь к `authlib-injector.jar`; пусто — без него |
|
||||||
|
| `authlibInjectorApi` | `ely.by` | сервер авторизации для инжектора |
|
||||||
|
| `launcherName`, `launcherVersion` | `BiLauncher`, `LAUNCHER_VERSION` | бэкенд передаёт имя и версию из `QCoreApplication` |
|
||||||
|
|
||||||
Подключает [minecraftversion.h](minecraftversion.md): разобранная версия — половина входных данных
|
## launch(options, version, &error)
|
||||||
запуска, вторая половина приходит структурой `LaunchOptions` из этого же заголовка.
|
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md), который заполняет
|
1. Если игра уже идёт, отказ.
|
||||||
`LaunchOptions` из настроек, выбранной сборки и результата авторизации.
|
2. `missingFiles()`: если чего-то не хватает, отказ со списком.
|
||||||
|
3. Распаковка нативных библиотек в `<gameDir>/versions/<id>/natives`.
|
||||||
|
4. `JavaLocator::select()` с `version.javaMajor` и `options.javaPath`. На Windows вместо
|
||||||
|
`java.exe` запускается `javaw.exe`, чтобы не было консольного окна.
|
||||||
|
5. `QProcess` с рабочим каталогом `gameDir` и объединёнными stdout и stderr.
|
||||||
|
6. Ожидание старта до 15 секунд. Если процесс не стартовал, отказ.
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QProcess`, `QStringList`) и `Qt6::CorePrivate` — последний нужен
|
Если всё прошло, метод возвращает `true` и испускает `gameStarted(commandLine)`. В этой строке
|
||||||
ради `QZipReader`, которым распаковываются нативные библиотеки LWJGL. Зависимость от приватного
|
значение после `--accessToken` заменено на `<скрыт>`, чтобы токен не попал в журнал. Других
|
||||||
модуля привязывает проект к конкретной версии Qt; в `CMakeLists.txt` это осознанный выбор, и
|
мест, где мог бы оказаться токен (например, в `${auth_session}`), замена не касается.
|
||||||
предупреждение о нём отключено.
|
|
||||||
|
|
||||||
## Иерархия и роль
|
Перед шагами 2–4 испускается `progress(message)`.
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, четыре сигнала и владение по родителю. Виртуальных
|
Вывод игры читается кусками и испускается построчно в `output(line)`. Когда процесс завершится,
|
||||||
методов базового класса не переопределяет.
|
придёт `gameFinished(exitCode, crashed)`, где `crashed` значит `QProcess::CrashExit`.
|
||||||
|
|
||||||
## Публичные структуры
|
`terminate()` вызывает `kill()`, а не `terminate()`. Игра не успеет ничего сохранить, это то же
|
||||||
|
самое, что снять процесс в диспетчере задач.
|
||||||
|
|
||||||
### LaunchOptions
|
## missingFiles(options, version, limit = 12)
|
||||||
|
|
||||||
Всё, что лаунчер знает к моменту нажатия кнопки запуска.
|
Проверяет, что на месте папка игры, клиентский jar, все библиотеки, индекс ресурсов и папка
|
||||||
|
`assets/objects`. Возвращает до `limit` путей. Если папки игры нет, в списке будет только она.
|
||||||
|
|
||||||
| Поле | Тип | По умолчанию | Описание |
|
Размеры и хеши не проверяются, только наличие. Функция статическая, бэкенд вызывает её и для
|
||||||
|------|-----|--------------|----------|
|
подсказки в интерфейсе, и перед запуском.
|
||||||
| `gameDir` | `QString` | — | Папка `.minecraft` |
|
|
||||||
| `versionId` | `QString` | — | Папка в `versions`, которую запускаем |
|
|
||||||
| `playerName` | `QString` | — | Ник игрока из результата авторизации |
|
|
||||||
| `uuid` | `QString` | — | UUID игрока |
|
|
||||||
| `accessToken` | `QString` | — | Токен доступа |
|
|
||||||
| `userType` | `QString` | — | Тип учётной записи: `legacy`, `msa` или `ELYBY` |
|
|
||||||
| `clientToken` | `QString` | — | Токен клиента |
|
|
||||||
| `xuid` | `QString` | — | Идентификатор Xbox; пустое значение заменяется на `0`, как в офлайне |
|
|
||||||
| `javaPath` | `QString` | — | Путь к java; пустое значение означает «искать самим» |
|
|
||||||
| `minMemoryMb` | `int` | `512` | Значение `-Xms` |
|
|
||||||
| `maxMemoryMb` | `int` | `4096` | Значение `-Xmx` |
|
|
||||||
| `extraJvmArgs` | `QStringList` | — | Дополнительные аргументы JVM из настроек |
|
|
||||||
| `windowWidth` | `int` | `0` | Ширина окна игры; `0` — не передавать `--width` и `--height` |
|
|
||||||
| `windowHeight` | `int` | `0` | Высота окна игры |
|
|
||||||
| `fullscreen` | `bool` | `false` | Запускать в полноэкранном режиме |
|
|
||||||
| `serverAddress` | `QString` | — | `host[:port]` для автоматического захода на сервер |
|
|
||||||
| `authlibInjectorPath` | `QString` | — | Путь к `authlib-injector`; пустое значение — не подключать |
|
|
||||||
| `authlibInjectorApi` | `QString` | `ely.by` | Сервер авторизации, на который перенаправляется игра |
|
|
||||||
| `launcherName` | `QString` | `BiLauncher` | Имя лаунчера, которое видит игра |
|
|
||||||
| `launcherVersion` | `QString` | `1.0` | Версия лаунчера |
|
|
||||||
|
|
||||||
## Публичные методы
|
## extractNatives(options, version, nativesDir, &error)
|
||||||
|
|
||||||
#### explicit GameLauncher(QObject \*parent = nullptr)
|
Для каждой библиотеки с `native == true` открывает jar через `QZipReader` (отсюда зависимость от
|
||||||
|
`Qt6::CorePrivate`) и вынимает файлы `.dll`, `.so`, `.so.N`, `.dylib`, `.jnilib`. Пропускаются
|
||||||
|
`META-INF/` и пути из `extract.exclude` описания версии. Все файлы кладутся в одну плоскую папку.
|
||||||
|
Если файл такого же размера уже лежит, его не переписывают.
|
||||||
|
|
||||||
Создаёт объект. Процесс игры при этом не запускается. Конструктор помечен `explicit`.
|
## buildArguments(options, version, nativesDir)
|
||||||
|
|
||||||
#### bool isRunning() const
|
Порядок аргументов:
|
||||||
|
|
||||||
Идёт ли сейчас игра. От этого зависит доступность кнопки запуска в интерфейсе.
|
1. `-javaagent:<authlib-injector>=ely.by` и `-Dauthlibinjector.side=client`, если инжектор задан;
|
||||||
|
2. `-Xms`, `-Xmx`, потом `extraJvmArgs`;
|
||||||
|
3. JVM-аргументы из описания версии;
|
||||||
|
4. аргумент логирования, если файл конфигурации логов скачан;
|
||||||
|
5. главный класс;
|
||||||
|
6. аргументы игры из описания версии;
|
||||||
|
7. `--width`/`--height`, если размер задан, а описание версии само его не передаёт;
|
||||||
|
8. `--fullscreen`;
|
||||||
|
9. сервер: `--quickPlayMultiplayer host:port` для версий, которые это понимают, иначе
|
||||||
|
`--server host --port port` (порт по умолчанию 25565).
|
||||||
|
|
||||||
#### static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12)
|
Подстановки: `auth_player_name`, `auth_uuid`, `auth_access_token`, `auth_session`
|
||||||
|
(`token:<токен>:<uuid>`), `auth_xuid` (пусто → `0`), `clientid` (пусто → `0`), `user_type`,
|
||||||
|
`user_properties` (`{}`), `version_name`, `version_type`, `game_directory`, `assets_root`,
|
||||||
|
`assets_index_name`, `game_assets`, `natives_directory`, `library_directory`, `classpath`,
|
||||||
|
`classpath_separator`, `primary_jar`, `launcher_name`, `launcher_version`, `resolution_width`,
|
||||||
|
`resolution_height`.
|
||||||
|
|
||||||
Проверяет `.minecraft` на комплектность и возвращает описания недостающих файлов. Пустой список
|
Аргумент, в котором после подстановки остался `${`, выбрасывается целиком. Так отсекаются
|
||||||
означает, что всё на месте и сборку можно запускать.
|
переменные, о которых лаунчер не знает (например, связанные с демо-режимом).
|
||||||
|
|
||||||
Параметр `limit` ограничивает длину списка: перечислять все отсутствующие файлы у неустановленной
|
У версий со старыми ресурсами (`legacy`, `pre-1.6`) `game_assets` указывает на
|
||||||
версии бессмысленно, важен сам факт и пара примеров.
|
`assets/virtual/legacy`.
|
||||||
|
|
||||||
Метод статический, ничего не меняет и вызывается интерфейсом для строки состояния сборки.
|
Classpath — это все не-нативные библиотеки и в конце клиентский jar.
|
||||||
|
|
||||||
#### static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir)
|
### Forge 1.17+ и `-DignoreList`
|
||||||
|
|
||||||
Собирает аргументы ровно в том порядке, в котором их ждёт JVM: сначала аргументы JVM, затем главный
|
Forge собирает слой модулей из classpath и пропускает файлы, имя которых начинается с записи в
|
||||||
класс, затем аргументы игры. Подстановки вида `${...}` из версии заменяются значениями из
|
`-DignoreList`. В профиле Forge там записан `${version_name}.jar`. Официальный лаунчер кладёт в
|
||||||
параметров запуска.
|
classpath jar самого профиля, а у нас своего jar у профиля нет, и в classpath попадает
|
||||||
|
родительский, например `1.20.1.jar`. Если его имени нет в списке, он становится вторым модулем с
|
||||||
Метод статический и не имеет побочных эффектов, поэтому годится и для показа собранной командной
|
классами Minecraft, и игра падает с ошибкой вида `Modules minecraft and _1._20._1 export package
|
||||||
строки без запуска.
|
...`. Поэтому к `-DignoreList` дописывается имя клиентского jar, если его там ещё нет.
|
||||||
|
|
||||||
#### 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);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,200 +1,60 @@
|
|||||||
# JavaInstaller
|
# JavaInstaller
|
||||||
|
|
||||||
## Обзор класса
|
`javainstaller.h`, `javainstaller.cpp`
|
||||||
|
|
||||||
`JavaInstaller` ставит сборку Java в `<root>/java/<id>`. Как и
|
Ставит одну сборку Java из каталога [JavaRuntimeService](JavaRuntimeService.md) в
|
||||||
[ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути.
|
`<root>/java/<id>/`. Сборки Temurin приходят архивом, сборки Mojang — деревом отдельных файлов,
|
||||||
|
поэтому путей установки два.
|
||||||
|
|
||||||
**Temurin** отдаёт один архив: лаунчер качает его, сверяет sha256 и распаковывает. **Mojang**
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса такие же, как у других
|
||||||
отдаёт манифест с деревом файлов: лаунчер качает файлы по отдельности, как это делает официальный
|
установщиков: `label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`, плюс
|
||||||
лаунчер.
|
`runtimeId`. Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс),
|
||||||
|
`finished(runtimeId, javaPath)`, `failed(label, message)`, `canceled(label)`.
|
||||||
|
|
||||||
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md): панель загрузки в
|
## install(entry)
|
||||||
интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
Если установка уже идёт, вызов молча игнорируется. Иначе:
|
||||||
|
|
||||||
Подключает [javaruntime.h](javaruntime.md): на вход установщик принимает запись каталога
|
1. проверяется папка лаунчера;
|
||||||
`JavaRuntimeEntry`, а результат записывает через `JavaRuntimeStore`.
|
2. **папка `<root>/java/<id>` удаляется целиком**, если она была, и создаётся заново. Повторная
|
||||||
|
установка — это всегда установка с нуля;
|
||||||
|
3. дальше одна из двух веток.
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят
|
### Temurin (`zip`, `tar.gz`)
|
||||||
от [JavaRuntimeService](JavaRuntimeService.md).
|
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`),
|
1. Архив качается в `<root>/cache/java/<имя файла>` через `QSaveFile`, sha256 считается на ходу и
|
||||||
`Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`.
|
сверяется с каталогом.
|
||||||
|
2. `zip` распаковывается через `QZipReader` по 24 записи за такт таймера, чтобы окно не
|
||||||
|
подвисало. Права из архива переносятся. Записи, которые ведут за пределы папки сборки,
|
||||||
|
останавливают установку.
|
||||||
|
3. `tar.gz` распаковывается системным `tar -xzf`. Он есть на Linux, macOS и Windows 10 и новее.
|
||||||
|
`QZipReader` tar не умеет, а держать ради этого ещё одну библиотеку незачем.
|
||||||
|
4. Архив удаляется.
|
||||||
|
|
||||||
## Иерархия и роль
|
Прогресс при загрузке считается в байтах, при распаковке zip — в записях. Для tar прогресса нет.
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
### Mojang
|
||||||
виртуальный деструктор — класс владеет незавершёнными загрузками, открытым архивом и процессом
|
|
||||||
распаковки.
|
|
||||||
|
|
||||||
## Публичные структуры
|
1. Качается `manifest.json` компонента. Его sha1 из каталога сейчас **не проверяется**.
|
||||||
|
2. Из `files` строится дерево: папки создаются сразу, ссылки откладываются, файлы ставятся в
|
||||||
|
очередь.
|
||||||
|
3. До 4 файлов качаются параллельно, sha1 каждого сверяется. При временной ошибке (таймаут,
|
||||||
|
5xx и т. п.) и при несовпадении хеша файл уходит в конец очереди без паузы. После третьей
|
||||||
|
неудачи установка падает. Файлам с `executable: true` ставится бит исполнения.
|
||||||
|
4. Ссылки создаются через `QFile::link`. Если не вышло (на Windows без прав разработчика), файл
|
||||||
|
копируется.
|
||||||
|
|
||||||
### JavaFileTask
|
### Конец
|
||||||
|
|
||||||
Один файл рантайма Mojang.
|
`JavaRuntimeStore::locateBinary()` находит `bin/java`. На Unix ему ставится бит исполнения.
|
||||||
|
`runtime.json` записывается последним: пока его нет, `JavaRuntimeStore` сборку не видит. Поэтому
|
||||||
|
недокачанная сборка в списке не появится, даже если лаунчер упал на середине.
|
||||||
|
|
||||||
| Поле | Тип | По умолчанию | Описание |
|
При ошибке и при отмене папка сборки и скачанный архив удаляются.
|
||||||
|------|-----|--------------|----------|
|
|
||||||
| `url` | `QUrl` | — | Откуда качать |
|
|
||||||
| `path` | `QString` | — | Абсолютный путь назначения |
|
|
||||||
| `sha1` | `QString` | — | Контрольная сумма файла |
|
|
||||||
| `size` | `qint64` | `0` | Размер в байтах |
|
|
||||||
| `executable` | `bool` | `false` | Файлу нужно выставить право на исполнение — иначе `bin/java` не запустится |
|
|
||||||
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
|
|
||||||
|
|
||||||
### JavaActiveDownload
|
## cancel()
|
||||||
|
|
||||||
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
|
Обрывает загрузки и убивает `tar`. `canceled` приходит, когда вернутся все активные запросы.
|
||||||
контрольная сумма и число принятых байт. Файлы пишутся потоком — рантайм весит около двухсот
|
|
||||||
мегабайт.
|
|
||||||
|
|
||||||
Структура `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);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,155 +1,67 @@
|
|||||||
# JavaRuntimeService
|
# JavaRuntimeService
|
||||||
|
|
||||||
## Обзор класса
|
`javaruntimeservice.h`, `javaruntimeservice.cpp`
|
||||||
|
|
||||||
`JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в
|
Каталог сборок Java, которые лаунчер может скачать. Устроен как
|
||||||
папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как
|
[VersionManifestService](VersionManifestService.md), но кэш хранится сутки и лежит в
|
||||||
[VersionManifestService](VersionManifestService.md).
|
`<root>/cache/java_runtimes.json`.
|
||||||
|
|
||||||
Источников два, и они дополняют друг друга. **Mojang** (категория «Java») — ровно тот рантайм,
|
Источников два:
|
||||||
которым запускает игру официальный лаунчер: версий немного, зато они заведомо совместимы.
|
|
||||||
**Eclipse Temurin** (категории JDK и JRE) — свежие сборки всех мажорных версий, включая те, до
|
|
||||||
которых Mojang ещё не дошёл.
|
|
||||||
|
|
||||||
Отдаются только сборки под текущие операционную систему и архитектуру: выбрать заведомо
|
- **Mojang** (в интерфейсе категория «Java») — те же рантаймы, с которыми игру запускает
|
||||||
неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 сборок `aarch64` нет
|
официальный лаунчер. Их немного, зато с игрой они точно совместимы.
|
||||||
вовсе и приходится брать `x64`, который работает через Rosetta.
|
- **Eclipse Temurin** (JDK и JRE) — свежие сборки всех мажорных версий, в том числе тех, которых у
|
||||||
|
Mojang ещё нет.
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
В каталог попадают только сборки под текущую ОС и архитектуру.
|
||||||
|
|
||||||
Подключает [javaruntime.h](javaruntime.md): перечисление `JavaRuntimeKind` и структура
|
Экземпляр держит [LauncherBackend](LauncherBackend.md), ставит сборки
|
||||||
`JavaRuntimeEntry` приходят оттуда.
|
[JavaInstaller](JavaInstaller.md).
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записями каталога
|
## Как обновляется каталог
|
||||||
пользуется [JavaInstaller](JavaInstaller.md).
|
|
||||||
|
|
||||||
Путь к файлу кэша даёт `LauncherPaths::javaCatalogFile()` из [launcherpaths.h](launcherpaths.md).
|
Запросы идут параллельно. Каталог заменяется целиком, когда ответят все.
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
|
1. `api.adoptium.net/v3/info/available_releases` — какие мажорные версии есть и какие из них LTS.
|
||||||
(`QNetworkAccessManager`).
|
2. Для каждой мажорной версии `assets/feature_releases/<major>/ga` с фильтрами: ОС, архитектура,
|
||||||
|
HotSpot, обычная куча, vendor `eclipse`, три последних релиза. Из каждого берутся бинарники с
|
||||||
|
`image_type` = `jdk` или `jre` в виде `.zip` или `.tar.gz` по `https`.
|
||||||
|
3. `piston-meta.mojang.com/.../java-runtime/.../all.json` — раздел под текущую платформу
|
||||||
|
(`windows-x64`, `mac-os-arm64`, `linux` и т. п.), по одному варианту на компонент. Компонент
|
||||||
|
`minecraft-java-exe` пропускается: это не Java.
|
||||||
|
|
||||||
## Иерархия и роль
|
**Нет сборки под aarch64.** У Temurin под ARM нет Java 8 и 16. Если для aarch64 ничего не нашлось,
|
||||||
|
запрос повторяется для `x64`. На Mac с Apple Silicon такая сборка работает через Rosetta. Сейчас
|
||||||
|
повтор делается на любой ОС с aarch64, хотя комментарий в заголовке говорит только про macOS.
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
Id строк: `temurin-<jdk|jre>-<версия>-<арх>` и `mojang-<компонент>`, пропущенные через
|
||||||
методов базового класса не переопределяет.
|
`JavaRuntimeStore::sanitizeId()`.
|
||||||
|
|
||||||
## Псевдонимы типов
|
Версия Temurin 8 показывается как `8u<security>`, остальные — как `openjdk_version` без `-LTS`.
|
||||||
|
Мажорная версия у Mojang берётся из имени (`8u…` → 8, иначе первое число).
|
||||||
|
|
||||||
`JavaRuntimeService::Callback` — `std::function<void(bool ok, const QString &warning)>`. Как и в
|
У сборок Mojang `size` равен 0: в каталоге указан только размер манифеста, а не рантайма.
|
||||||
остальных каталогах лаунчера, `ok == true` с непустым `warning` означает данные из устаревшего
|
|
||||||
кэша.
|
|
||||||
|
|
||||||
## Публичные методы
|
Если какие-то запросы упали, а другие ответили, каталог всё равно обновляется, а предупреждения
|
||||||
|
склеиваются через `; `. Если не пришло ничего, но старые данные есть, колбэк получает `ok = true` и
|
||||||
|
предупреждение.
|
||||||
|
|
||||||
#### explicit JavaRuntimeService(QObject \*parent = nullptr)
|
Кэш помечается платформой (`mojangPlatform()`). Кэш с другой платформы не читается. Это возможно,
|
||||||
|
например, если папку лаунчера перенесли с другой машины.
|
||||||
|
|
||||||
Создаёт сервис и его `QNetworkAccessManager`. Кэш читается лениво. Конструктор помечен `explicit`.
|
## Методы
|
||||||
|
|
||||||
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
- `ensureLoaded(callback, forceRefresh)` — как у манифеста версий.
|
||||||
|
- `entries(kind)` — строки одного вида, отсортированы по мажорной версии, потом по дате выхода,
|
||||||
|
новые сверху.
|
||||||
|
- `find(id)`.
|
||||||
|
- `bestFor(major)` — что скачать, если игре нужна Java `major`, а подходящей нет. Выбирается
|
||||||
|
наименьшая мажорная версия не ниже `major`. При равенстве порядок такой: Temurin JDK, потом
|
||||||
|
Mojang, потом Temurin JRE. JDK идёт первым, потому что установщикам Forge и NeoForge иногда нужны
|
||||||
|
инструменты из полного комплекта. Если и здесь равенство, берётся более свежая.
|
||||||
|
- `isRefreshing()`, `hasData()`.
|
||||||
|
|
||||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
Сигналы: `catalogChanged()`, `refreshingChanged()`.
|
||||||
обновление. Параметр `forceRefresh` обходит проверку свежести — так работает кнопка обновления
|
|
||||||
каталога.
|
|
||||||
|
|
||||||
Одно обновление складывается из нескольких запросов сразу к обоим источникам. Ответы приходят
|
|
||||||
вразнобой, поэтому они накапливаются, и каталог подменяется целиком только когда пришли все.
|
|
||||||
|
|
||||||
#### QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const
|
|
||||||
|
|
||||||
Сборки одной категории: `Mojang`, `Jdk` или `Jre`. Новые версии идут первыми.
|
|
||||||
|
|
||||||
#### std::optional<JavaRuntimeEntry> find(const QString &id) const
|
|
||||||
|
|
||||||
Запись по идентификатору сборки; `std::nullopt`, если такой нет. Поиск идёт по внутреннему
|
|
||||||
указателю.
|
|
||||||
|
|
||||||
#### std::optional<JavaRuntimeEntry> bestFor(int major) const
|
|
||||||
|
|
||||||
Что скачать, если для версии игры нужна Java указанной мажорной версии, а подходящей в системе
|
|
||||||
нет.
|
|
||||||
|
|
||||||
Правила выбора: точное совпадение мажорной версии предпочтительнее более новой, а JDK
|
|
||||||
предпочтительнее JRE — установщики Forge и NeoForge иногда требуют инструментов из полного
|
|
||||||
комплекта. `std::nullopt` означает, что предложить нечего.
|
|
||||||
|
|
||||||
#### bool isRefreshing() const
|
|
||||||
|
|
||||||
Идут ли сейчас запросы. Признак остаётся истинным, пока не завершится последний из них.
|
|
||||||
|
|
||||||
#### bool hasData() const
|
|
||||||
|
|
||||||
Есть ли в каталоге хоть что-то — из сети или из кэша.
|
|
||||||
|
|
||||||
## Сигналы
|
|
||||||
|
|
||||||
#### catalogChanged()
|
|
||||||
|
|
||||||
Каталог заменён новыми данными. Испускается один раз за обновление, когда пришли ответы от всех
|
|
||||||
источников, а не по каждому из них.
|
|
||||||
|
|
||||||
Обработчик перечитывает `entries()` для нужных категорий и обновляет интерфейс.
|
|
||||||
|
|
||||||
#### refreshingChanged()
|
|
||||||
|
|
||||||
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки.
|
|
||||||
|
|
||||||
## Владение и время жизни
|
|
||||||
|
|
||||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
|
||||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
|
||||||
|
|
||||||
Отложенные колбэки хранятся до завершения текущего обновления. Уничтожение сервиса с
|
|
||||||
незавершёнными запросами обрывает их вместе с менеджером сети, и колбэки не вызываются.
|
|
||||||
|
|
||||||
## Потокобезопасность
|
|
||||||
|
|
||||||
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
|
||||||
|
|
||||||
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора Java и по кнопке обновления,
|
|
||||||
а сигналы переправляет в свойства, которые читает QML. Перед отдачей в интерфейс он сводит записи
|
|
||||||
каталога с уже установленными сборками из `JavaRuntimeStore` — окно выбора показывает статус, не
|
|
||||||
считая ничего само.
|
|
||||||
|
|
||||||
[JavaInstaller](JavaInstaller.md) получает запись каталога и по ней скачивает и распаковывает
|
|
||||||
сборку. `bestFor()` используется, когда для запуска не хватает Java и лаунчер должен сам
|
|
||||||
предложить, что поставить.
|
|
||||||
|
|
||||||
Требования версий игры к Java берутся из пространства имён `JavaRequirement` в
|
|
||||||
[javaruntime.h](javaruntime.md).
|
|
||||||
|
|
||||||
## Внешнее взаимодействие
|
|
||||||
|
|
||||||
**Сеть, исходящие запросы.** Класс обращается к двум внешним каталогам: API Adoptium (сборки
|
|
||||||
Temurin) и списку рантаймов Mojang. Формат обоих — JSON поверх HTTPS, разбор разделён на две
|
|
||||||
функции.
|
|
||||||
|
|
||||||
Запросов на одно обновление несколько: список сборок Temurin запрашивается по мажорным версиям,
|
|
||||||
и на macOS с Apple Silicon неудачный запрос сборки `aarch64` может быть переспрошен для `x64` —
|
|
||||||
это и есть тот самый случай Java 8 и 16.
|
|
||||||
|
|
||||||
Общий счётчик незавершённых запросов сводит их воедино: пока он не обнулился, каталог не
|
|
||||||
подменяется, а предупреждения от отдельных источников накапливаются.
|
|
||||||
|
|
||||||
Все сигналы и колбэки приходят в поток GUI.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
auto *javaCatalog = new JavaRuntimeService(this);
|
|
||||||
connect(javaCatalog, &JavaRuntimeService::catalogChanged, this, &Backend::rebuildJavaCatalog);
|
|
||||||
|
|
||||||
javaCatalog->ensureLoaded([this, javaCatalog](bool ok, const QString &warning) {
|
|
||||||
if (!ok) {
|
|
||||||
emit javaCatalogError(warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const auto best = javaCatalog->bestFor(version.javaMajor);
|
|
||||||
if (best)
|
|
||||||
emit suggestJavaInstall(best->id, best->version);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,531 +1,371 @@
|
|||||||
# LauncherBackend
|
# LauncherBackend
|
||||||
|
|
||||||
## Обзор класса
|
`launcherbackend.h`, `launcherbackend.cpp`
|
||||||
|
|
||||||
`LauncherBackend` — единственный класс проекта, видимый из QML, и центр всего приложения. Интерфейс
|
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает
|
||||||
лаунчера ничего не знает ни о сети, ни о файлах, ни о процессах: он читает свойства этого класса,
|
методы и слушает сигналы, а к сервисам напрямую не обращается.
|
||||||
вызывает его методы и слушает его сигналы.
|
|
||||||
|
|
||||||
Сам бэкенд почти ничего не делает руками. Он владеет двенадцатью сервисами — авторизация, запуск
|
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки,
|
||||||
игры, каталоги версий, модлоадеров, Java и сезонных сборок, три установщика, загрузчик паков и
|
создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время
|
||||||
переключатель сборок — и отвечает за то, чтобы они работали в правильном порядке. Кроме того, он
|
смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера.
|
||||||
хранит состояние лаунчера: профили игрока, пользовательские сборки и настройки запуска, которые
|
Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать.
|
||||||
читает и пишет в файлы папки лаунчера.
|
|
||||||
|
|
||||||
Ещё одна его задача — приводить данные к виду, удобному QML. Каталоги отдаются в интерфейс уже
|
Экземпляр один, его объявляет [Main.qml](../qml/Main.md). Во вложенные окна он передаётся через
|
||||||
сведёнными с локальным состоянием: строка версии знает, скачана ли она, строка сезонной сборки —
|
`required property var backend`. Тип зарегистрирован через `QML_ELEMENT` в модуле `BiLauncher`;
|
||||||
установлена ли и не устарела ли. Окно показывает статус, не считая ничего само.
|
синглтоном он не сделан.
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
## Сервисы
|
||||||
|
|
||||||
Единственный экземпляр создаётся декларативно в [Main.qml](../qml/Main.md); в `main.cpp` он не
|
Все создаются в конструкторе с бэкендом в роли родителя.
|
||||||
упоминается.
|
|
||||||
|
|
||||||
Владеет двенадцатью сервисами, каждому из которых посвящена своя страница:
|
| Поле | Класс | Зачем |
|
||||||
|
|------|-------|-------|
|
||||||
| Поле | Класс | Роль |
|
| `m_auth` | [AuthService](AuthService.md) | Ely.by, офлайн, загрузка 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) | загрузка архива сезонной сборки |
|
||||||
|
|
||||||
Пути ко всем файлам состояния берутся из [launcherpaths.h](launcherpaths.md), описания версий — из
|
`VersionInstaller` получает указатель на сервис манифеста, `ModLoaderInstaller` — на сервис версий
|
||||||
[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`.
|
|
||||||
|
|
||||||
## Иерархия и роль
|
Конструктор делает следующее, порядок важен:
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, свойства, сигналы и владение по родителю. Объявлен
|
1. `LauncherPaths::ensureRootExists()` создаёт папку лаунчера и проверяет, что в неё можно писать.
|
||||||
виртуальный деструктор. Виртуальных методов базового класса не переопределяет.
|
2. `loadSettings()` заполняет настройки значениями по умолчанию и накладывает сверху
|
||||||
|
`settings.json`.
|
||||||
|
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
|
||||||
|
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
|
||||||
|
4. `loadData()` читает профили и сборки и сортирует их по `id`.
|
||||||
|
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
|
||||||
|
(см. ниже).
|
||||||
|
6. `ensureDataFiles()` создаёт недостающие файлы.
|
||||||
|
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
||||||
|
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
|
||||||
|
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
|
||||||
|
8. Подписка на сигналы сервисов.
|
||||||
|
|
||||||
## Свойства Q_PROPERTY
|
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
||||||
|
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
||||||
|
моменту QML уже подписан на сигнал.
|
||||||
|
|
||||||
### Профили и сборки
|
### Сборка из папки игры
|
||||||
|
|
||||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
|
||||||
|----------|-----|------|-------|--------|----------|
|
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
|
||||||
| `profileNames` | `QStringList` | `profileNames` | — | `profilesChanged` | Имена профилей игрока в порядке добавления. Только для чтения; модель выпадающего списка профилей |
|
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
|
||||||
| `customBuildNames` | `QStringList` | `customBuildNames` | — | `customBuildsChanged` | Имена пользовательских сборок. Только для чтения; модель списка в окне сборок |
|
пользователя «сборок нет», и импорта тогда нет.
|
||||||
| `activeBuildIndex` | `int` | `activeBuildIndex` | `setActiveBuildIndex` | `activeBuildChanged` | Сборка, которую запускает кнопка игры. Хранится по идентификатору сборки, а не по индексу: удаление соседней записи не должно переназначать активную. Запись в свойство запускает смену сборки |
|
|
||||||
| `activeBuildName` | `QString` | `activeBuildName` | — | `activeBuildChanged` | Имя активной сборки для подписи на кнопке. Только для чтения |
|
|
||||||
| `installedVersions` | `QStringList` | `installedVersions` | — | `installedVersionsChanged` | Версии, реально установленные в папке игры. Только для чтения |
|
|
||||||
|
|
||||||
### Занятость и смена сборки
|
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
|
||||||
|
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
|
||||||
|
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
|
||||||
|
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
|
||||||
|
|
||||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
|
||||||
|----------|-----|------|-------|--------|----------|
|
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
|
||||||
| `gameRunning` | `bool` | `gameRunning` | — | `gameRunningChanged` | Игра запущена. Только для чтения; выключает кнопку запуска |
|
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
|
||||||
| `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 показать нечем, и интерфейс не должен предлагать этот путь |
|
|
||||||
|
|
||||||
### Каталог версий
|
## Файлы на диске
|
||||||
|
|
||||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
||||||
|----------|-----|------|-------|--------|----------|
|
|
||||||
| `versionCatalog` | `QVariantList` | `versionCatalog` | — | `versionCatalogChanged` | Объединённый список для выбора версии: установленные, разделитель, затем весь каталог Mojang. Строка содержит поля `id`, `label`, `category`, `installed` и `search`. Только для чтения |
|
|
||||||
| `catalogLoading` | `bool` | `catalogLoading` | — | `catalogLoadingChanged` | Идёт загрузка манифеста версий. Только для чтения |
|
|
||||||
|
|
||||||
### Каталог Java
|
- `settings.json` — объект с настройками;
|
||||||
|
- `profiles.json` — массив профилей;
|
||||||
|
- `customBuilds.json` — массив сборок.
|
||||||
|
|
||||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
Если файла нет, это первый запуск. Если он не читается как JSON или в нём не тот тип (объект вместо
|
||||||
|----------|-----|------|-------|--------|----------|
|
массива и наоборот), файл переименовывается в `<имя>.bak`, а на его месте создаётся пустой.
|
||||||
| `javaCatalog` | `QVariantList` | `javaCatalog` | — | `javaCatalogChanged` | Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат в папке лаунчера. Строка содержит поля `id`, `label`, `kind`, `major`, `installed`, `downloadable`, `lts`, `sizeMb`, `detail`, `coverage` и `search`. Только для чтения |
|
Данные из `.bak` можно потом достать руками.
|
||||||
| `javaCatalogLoading` | `bool` | `javaCatalogLoading` | — | `javaCatalogLoadingChanged` | Идёт загрузка каталога Java. Только для чтения |
|
|
||||||
|
|
||||||
### Сезонные сборки
|
Перед каждой записью снова вызывается `ensureRootExists()`: папку могли удалить, пока лаунчер
|
||||||
|
работал, и без этой проверки сохранения пропадали бы без ошибки. Пишется всё через обычный `QFile`,
|
||||||
|
кроме `builds/<id>/season.json`, для которого используется `QSaveFile`.
|
||||||
|
|
||||||
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
Пароли Ely.by и токены лежат в `profiles.json` открытым текстом.
|
||||||
|----------|-----|------|-------|--------|----------|
|
|
||||||
| `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`, `javaPath`, `javaRuntime`, `minMemoryMb`,
|
| `gameDir` | пусто | папка игры; пусто — `LauncherPaths::defaultMinecraftDir()` |
|
||||||
`maxMemoryMb`, `jvmArgs`, `windowWidth`, `windowHeight`, `fullscreen`, `language` и вычисленный
|
| `javaPath` | пусто | путь к java, заданный руками |
|
||||||
`resolvedGameDir`.
|
| `javaRuntime` | пусто | id сборки Java из `<root>/java`; важнее `javaPath` |
|
||||||
|
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx` |
|
||||||
#### void updateSettings(const QVariantMap &values)
|
| `jvmArgs` | пусто | дополнительные аргументы JVM одной строкой, делятся по пробелам |
|
||||||
|
| `windowWidth`, `windowHeight` | 0, 0 | размер окна игры; 0 — не передавать |
|
||||||
Записывает настройки и сохраняет их на диск. Испускает `settingsChanged`.
|
| `fullscreen` | `false` | |
|
||||||
|
| `language` | `system` | язык интерфейса, см. [Localization](Localization.md) |
|
||||||
Смену `language` после записи пробрасывает в [Localization](Localization.md) — порядок
|
| `activeBuildId` | 0 | `id` активной сборки |
|
||||||
«сохранили → переключили» гарантирует, что выбранный язык переживёт падение сразу после
|
| `seasonalBaseUrl` | `https://launcher-builds.galeonworx.com` | сервер сезонных сборок |
|
||||||
переключения. Сам `Localization` в `settings.json` не пишет: файл ведёт только бэкенд.
|
|
||||||
|
`seasonalBaseUrl` в интерфейсе нет. Он нужен, чтобы проверять установку против локального
|
||||||
#### QStringList detectedJava() const
|
HTTP-сервера. Хвостовые слэши отрезаются.
|
||||||
|
|
||||||
Пути ко всем java, найденным в системе. Показывается справочной строкой в диалоге настроек.
|
Имя и версия лаунчера для `${launcher_name}` и `${launcher_version}` в настройках не хранятся: они
|
||||||
|
берутся из `QCoreApplication`, то есть из `project(... VERSION)` в CMake. Ключи `launcherName` и
|
||||||
### Сборки Java
|
`launcherVersion`, оставшиеся в старых `settings.json`, при загрузке выбрасываются и пропадают из
|
||||||
|
файла при следующем сохранении.
|
||||||
#### void refreshJavaCatalog(bool force = false)
|
|
||||||
|
`settings()` возвращает всю карту и добавляет к ней вычисленный `resolvedGameDir`.
|
||||||
Обновляет каталог сборок Java. Вызывается при открытии окна выбора: свежий кэш отвечает без сети.
|
`updateSettings()` вливает присланные ключи, сохраняет файл и испускает `settingsChanged`. Если
|
||||||
|
сменился язык, после записи вызывается `Localization::setLanguage()`: так выбор сохранится, даже если
|
||||||
#### void installJavaRuntime(const QString &runtimeId)
|
лаунчер упадёт сразу после переключения. Если сменилась папка игры, перестраивается каталог версий.
|
||||||
|
|
||||||
Скачивает и распаковывает сборку Java. По завершении испускается `javaRuntimeInstalled`.
|
## Профили
|
||||||
|
|
||||||
#### void removeJavaRuntime(const QString &runtimeId)
|
Профиль хранит имя, логин, пароль, тип входа (`offline`, `elyby`, `microsoft`; всё прочее читается
|
||||||
|
как `offline`) и полученные при входе данные: токены, UUID, ник с сервера, `xuid`, срок жизни токена.
|
||||||
Удаляет скачанную сборку Java из папки лаунчера.
|
|
||||||
|
- `addProfile(name, login, password, authType)` — добавить.
|
||||||
#### QVariantMap javaRuntimeInfo(const QString &runtimeId) const
|
- `updateProfile(index, ...)` — перезаписать. Если у профиля Ely.by поменялись логин или пароль,
|
||||||
|
сохранённый токен сбрасывается. Если поменялся тип входа, сбрасывается всё, что было получено
|
||||||
Описание установленной сборки: подпись, версия, путь к java и признак установки. Пустая карта
|
при входе. Переименование профиля Microsoft сессию не трогает.
|
||||||
означает, что сборки с таким идентификатором в папке лаунчера нет.
|
- `profileAt(index)` — `name`, `login`, `password`, `authType`, `resolvedName` и
|
||||||
|
`hasMicrosoftSession` (есть ли refresh-токен).
|
||||||
Метод не является привязкой и сам не пересчитывается, когда сборка докачается, — диалог настроек
|
- `removeProfile(index)` — удалить.
|
||||||
обновляет его по сигналу `javaRuntimeInstalled`.
|
|
||||||
|
Все испускают `profilesChanged`. Свойство `profileNames` отдаёт имена в порядке `id`.
|
||||||
#### int requiredJavaMajor(int buildIndex) const
|
|
||||||
|
## Сборки
|
||||||
Минимальная мажорная версия Java для версии игры выбранной сборки; `0` — версия не выбрана.
|
|
||||||
Передаётся в окно выбора Java, чтобы пометить слишком старые сборки.
|
Сборка — это имя, адрес сервера для автоподключения, версия Minecraft и, если выбран, модлоадер с
|
||||||
|
версией. После установки модлоадера в `resolvedVersionId` записывается id профиля в `versions/`,
|
||||||
### Сезонные сборки
|
который на самом деле запускается. У сезонных сборок есть ещё `seasonalId` и `seasonalRevision`.
|
||||||
|
|
||||||
#### void refreshSeasonalCatalog(bool force = false)
|
Какую папку из `versions/` запускать, решает `launchVersionIdOf()`: сначала `resolvedVersionId`,
|
||||||
|
потом `minecraftVersion`, а если и её нет, то имя сборки. Последний вариант остался от старых
|
||||||
Обновляет каталог сезонных сборок.
|
записей, у которых версия была записана в имени.
|
||||||
|
|
||||||
#### void installSeasonalBuild(const QString &seasonalId)
|
- `addCustomBuild(name, serverUrl, minecraftVersion)` — создать и сразу начать ставить версию, если
|
||||||
|
она указана и её нет на диске. Если версия пустая (так создаёт сборку кнопка «+ Новая сборка»),
|
||||||
Ставит или обновляет сборку целиком одной цепочкой: запись сборки, версия игры, модлоадер, Java и
|
ничего не ставится.
|
||||||
файлы.
|
- `updateCustomBuild(index, fields)` — изменить только переданные ключи. Карточка сборки сохраняет
|
||||||
|
правки по одному полю, поэтому установка здесь не запускается. Если сменилась версия игры, а
|
||||||
Порядок шагов жёсткий: сначала сборка делается активной, затем ставится Java — она нужна
|
`loaderVersion` в том же вызове не прислали, `loaderVersion` и `resolvedVersionId` сбрасываются;
|
||||||
установщику Forge, — затем модлоадер, затем качается пак и только в конце его файлы раскатываются
|
сам `loader` остаётся.
|
||||||
поверх `.minecraft`. Раскатывать файлы имеет смысл только когда всё остальное на месте.
|
- `customBuildAt(index)` — все поля плюс `launchVersionId`.
|
||||||
|
- `customBuildRemovalInfo(index)` — `name`, `hasArchive`, `active`, `lastOne` для текста
|
||||||
Строка каталога копируется на момент старта: обновление списка посреди установки не должно менять
|
предупреждения.
|
||||||
то, что ставится. По завершении испускается `seasonalInstallFinished`.
|
- `removeCustomBuild(index)` — удалить запись и архив. Пока лаунчер занят или игра запущена,
|
||||||
|
отказывает. Если удалили активную, активной становится первая из оставшихся, и запускается
|
||||||
#### void cancelSeasonalInstall()
|
переключение.
|
||||||
|
- `installCustomBuild(index)` — поставить, чего не хватает. Для ванильной сборки это версия игры,
|
||||||
Отменяет установку сезонной сборки.
|
для сборки с модлоадером — модлоадер вместе с версией. Вызывает `installLoaderForBuild()`.
|
||||||
|
- `installLoaderForBuild(index)` — то же самое; по завершении записывает `resolvedVersionId`.
|
||||||
### Папки
|
- `checkInstallation(index)` — список того, чего не хватает для запуска; пустой список значит, что
|
||||||
|
всё на месте.
|
||||||
#### void openMinecraftFolder()
|
|
||||||
|
### Активная сборка
|
||||||
Открывает папку модов Minecraft в файловом менеджере системы.
|
|
||||||
|
`activeBuildIndex` хранится в настройках как `activeBuildId`, а не как индекс. Если этот `id`
|
||||||
#### void openGameFolder()
|
пропал, активной считается первая сборка.
|
||||||
|
|
||||||
Открывает корневую папку игры в файловом менеджере системы.
|
Запись в свойство запускает `BuildSwitcher::switchTo()`: содержимое `.minecraft` уезжает в архив
|
||||||
|
прежней сборки, на его место распаковывается архив новой. Смена отклоняется, если уже идёт другая
|
||||||
## Сигналы
|
смена, запущена игра или работает любой установщик. `activeBuildId` меняется только в обработчике
|
||||||
|
`BuildSwitcher::finished`, поэтому после неудачи активной остаётся прежняя сборка.
|
||||||
### Сигналы уведомления свойств
|
|
||||||
|
Пока идёт смена, выставлено `switching`, а ход виден в `switchProgress` (от 0 до 1, или −1, пока
|
||||||
Эти сигналы объявлены как `NOTIFY` соответствующих свойств; обработчик перечитывает свойство.
|
объём неизвестен), `switchStage` и `switchStatus`.
|
||||||
`profilesChanged`, `customBuildsChanged`, `activeBuildChanged`, `switchChanged`,
|
|
||||||
`installedVersionsChanged`, `settingsChanged`, `gameRunningChanged`, `busyChanged`,
|
## Версии игры
|
||||||
`versionCatalogChanged`, `catalogLoadingChanged`, `javaCatalogChanged`,
|
|
||||||
`javaCatalogLoadingChanged`, `seasonalCatalogChanged`, `seasonalCatalogLoadingChanged`,
|
- `versionCatalog` — плоский список строк `{id, type, category, label, installed, search}` в порядке
|
||||||
`seasonalInstallingChanged` и `downloadChanged`.
|
манифеста, новые сверху. `category` — `release`, `snapshot`, `old_beta`, `old_alpha` или `other`.
|
||||||
|
В конец добавлены установленные версии, которых нет в манифесте (профили модлоадеров), по
|
||||||
#### loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion)
|
алфавиту, с категорией `other`. Разделы и поиск делает
|
||||||
|
[VersionPickerDialog](../qml/VersionPickerDialog.md).
|
||||||
Список версий модлоадера изменился. Параметры сужают событие до конкретной пары, поэтому
|
- `catalogLoading` — идёт запрос манифеста.
|
||||||
обработчик обязан сверить их со своим текущим состоянием: обновление может относиться к другой
|
- `refreshVersionCatalog(force)` — сбросить кэш строк и попросить манифест. Свежий кэш манифеста
|
||||||
строке лоадера или к прошлой версии игры. Именно так поступает [LoaderRow](../qml/LoaderRow.md).
|
отвечает без сети.
|
||||||
|
- `installedVersions`, `isVersionInstalled(id)` — что лежит в `versions/`.
|
||||||
### События установки
|
- `installVersion(id)` — поставить; если эта версия уже ставится или стоит в очереди, ничего не
|
||||||
|
делает.
|
||||||
#### seasonalInstallFinished(const QString &seasonalId, const QString &buildName)
|
- `versionRemovalInfo(id)` — `id`, `installed`, `sizeMb`, `dependents` (профили модлоадеров поверх
|
||||||
|
этой версии), `builds` (имена сборок, которые на неё ссылаются).
|
||||||
Сезонная сборка установлена и активна — можно запускать игру. Обработчик показывает сообщение
|
- `removeVersion(id)` — удалить папку версии. Библиотеки и ресурсы общие, их не трогает. Пока
|
||||||
пользователю.
|
лаунчер занят или запущена игра, отказывает. Сборки, ссылающиеся на версию, не меняются:
|
||||||
|
ссылка на неустановленную версию — нормальное состояние.
|
||||||
#### javaRuntimeInstalled(const QString &runtimeId)
|
|
||||||
|
Список строк каталога строится лениво и кэшируется: QML читает свойство много раз за кадр, пока
|
||||||
Сборка Java установлена. Диалог настроек по этому сигналу обновляет подпись выбранной сборки, не
|
открыт список. То же сделано для каталогов Java и сезонных сборок.
|
||||||
переоткрываясь.
|
|
||||||
|
## Модлоадеры
|
||||||
### Запуск игры
|
|
||||||
|
- `loaderVersions(loaderKey, gameVersion)` — список `{version, label, recommended, stable}` для этой
|
||||||
#### launchProgress(const QString &message)
|
версии игры, новые сверху. Несовместимых строк в нём не бывает. Пустой список означает, что
|
||||||
|
модлоадер эту версию не поддерживает.
|
||||||
Описание текущего шага запуска. Обработчик показывает сообщение без таймаута: шаг может занять
|
- `refreshLoaderVersions(loaderKey, gameVersion, force)` — обновить список. По окончании всегда
|
||||||
заметное время, и сообщение должно держаться до следующего.
|
приходит `loaderVersionsChanged(loaderKey, gameVersion)`, даже если ответил кэш. Обработчик должен
|
||||||
|
сверить пару со своей: сигнал может относиться к другому модлоадеру или к прошлой версии игры.
|
||||||
#### launched(const QString &profileName, const QString &buildName, const QString &serverUrl)
|
- `loaderVersionsLoading(loaderKey, gameVersion)` — помогает отличить «ещё грузится» от «не
|
||||||
|
поддерживается».
|
||||||
Игра запущена. Обработчик сообщает пользователю, какой профиль и какая сборка стартовали.
|
|
||||||
|
Ключи: `forge`, `fabric`, `neoforge`, `quilt`.
|
||||||
#### launchError(const QString &message)
|
|
||||||
|
Установщику модлоадера передаётся та же Java, что пойдёт в игру: сначала скачанная лаунчером
|
||||||
Запуск не удался либо произошла ошибка, о которой нужно сказать пользователю. Через этот же сигнал
|
сборка, потом `javaPath`.
|
||||||
сообщается о проблемах записи файлов лаунчера.
|
|
||||||
|
## Java
|
||||||
#### twoFactorRequired(const QString &profileName)
|
|
||||||
|
- `javaCatalog` — строки `{id, kind, major, version, label, detail, coverage, sizeMb, installed, lts,
|
||||||
Ely.by отклонил пароль с пометкой two factor. Обработчик открывает диалог ввода кода и передаёт
|
downloadable, search}`. Сначала то, что уже скачано в `<root>/java`, потом каталог Mojang, Temurin
|
||||||
введённое значение в `submitTwoFactorCode()`; отказ должен вызвать `cancelPendingLaunch()`, иначе
|
JDK и Temurin JRE.
|
||||||
запуск останется висеть в ожидании.
|
- `javaCatalogLoading`, `refreshJavaCatalog(force)` — как у версий.
|
||||||
|
- `installJavaRuntime(id)` — скачать. Пока работает другой установщик или идёт смена сборки,
|
||||||
#### gameOutput(const QString &line)
|
отказывает. По завершении приходит `javaRuntimeInstalled(id)`.
|
||||||
|
- `removeJavaRuntime(id)` — удалить из папки лаунчера. Если это была выбранная в настройках сборка,
|
||||||
Строка вывода процесса игры. Обработчик пишет её в журнал.
|
`javaRuntime` очищается.
|
||||||
|
- `javaRuntimeInfo(id)` — `id`, `kind`, `major`, `version`, `label`, `javaPath`, `installed`.
|
||||||
#### gameFinished(int exitCode, bool crashed)
|
Если сборка выбрана, но не скачана, `installed` будет `false` и `javaPath` пустым. Для пустого
|
||||||
|
`id` возвращается пустая карта. Метод не пересчитывается сам, поэтому окно настроек перечитывает
|
||||||
Игра завершилась. Обработчик сообщает итог: ненулевой код или выставленный `crashed` показываются
|
его по `javaRuntimeInstalled`.
|
||||||
как ошибка.
|
- `requiredJavaMajor(buildIndex)` — минимальная Java для версии игры сборки, 0 если версия не
|
||||||
|
выбрана.
|
||||||
### Вход через Microsoft
|
- `detectedJava()` — найденные в системе java строками вида `Java 17 — /путь`, для справки в
|
||||||
|
настройках.
|
||||||
#### microsoftLoginUrlReady(const QString &url)
|
|
||||||
|
## Запуск игры
|
||||||
Окну входа Microsoft: открыться на этом адресе. Обработчик создаёт окно (в сборке с Qt WebEngine)
|
|
||||||
и открывает его.
|
`launchGame(profileIndex, buildIndex)` сначала проверяет всё, что можно проверить синхронно:
|
||||||
|
|
||||||
#### microsoftLoginSucceeded(const QString &playerName)
|
- не работает ни один установщик, не идёт другой запуск и игра не запущена;
|
||||||
|
- профиль и сборка выбраны, папка игры существует;
|
||||||
Вход выполнен. Обработчик сообщает об этом пользователю, но не трогает выбор в списке профилей:
|
- версия сборки есть в `versions/`;
|
||||||
новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль.
|
- описание версии читается (`VersionLoader::load`), и все нужные файлы на месте
|
||||||
|
(`GameLauncher::missingFiles`).
|
||||||
#### microsoftLoginFailed(const QString &message)
|
|
||||||
|
После этого выставляется `busy`, а сборка запоминается по `id`, а не по индексу: пока идёт вход,
|
||||||
Вход не удался.
|
её могут удалить в другом окне.
|
||||||
|
|
||||||
#### microsoftReloginRequired(int profileIndex)
|
Дальше зависит от типа профиля:
|
||||||
|
|
||||||
Сессия профиля протухла настолько, что нужен повторный вход руками. Обработчик обычно сразу
|
- **офлайн** — ник из логина или имени профиля, UUID считается из ника;
|
||||||
вызывает `startMicrosoftLogin()` для этого профиля.
|
- **Ely.by** — сначала докачивается `authlib-injector` в `<root>/runtime`, потом вход. Если Ely.by
|
||||||
|
просит второй фактор, `busy` снимается и приходит `twoFactorRequired(profileName)`. Окно должно
|
||||||
## Владение и время жизни
|
вызвать `submitTwoFactorCode(code)` или `cancelPendingLaunch()`, иначе запуск так и останется
|
||||||
|
в ожидании;
|
||||||
Класс наследует `QObject` и принимает `parent`. Экземпляр создаётся декларативно в QML, поэтому
|
- **Microsoft** — если refresh-токена нет, запуск прерывается с `microsoftReloginRequired(index)`.
|
||||||
временем его жизни управляет движок QML: объект живёт столько же, сколько главное окно.
|
Если сохранённый токен Minecraft живёт ещё дольше двух минут, сеть не трогается. Иначе сессия
|
||||||
|
продлевается по refresh-токену. Если продлить не вышло, тоже `microsoftReloginRequired`. Если у
|
||||||
Все двенадцать сервисов создаются в конструкторе с бэкендом в роли родителя и уничтожаются вместе
|
аккаунта нет лицензии, просто ошибка.
|
||||||
с ним. Порядок создания важен для двоих: [VersionInstaller](VersionInstaller.md) принимает в
|
|
||||||
конструктор сервис манифеста, а [ModLoaderInstaller](ModLoaderInstaller.md) — сервис версий
|
Полученные при входе ник, UUID и токены сохраняются в профиль. Java выбирается так: скачанная
|
||||||
лоадеров и установщик версий; эти указатели не переходят во владение принимающей стороны.
|
лаунчером сборка из `javaRuntime`, иначе `javaPath`, иначе её ищет `GameLauncher`. Если
|
||||||
|
`javaRuntime` задан, но такой сборки на диске нет, об этом будет сообщение в `launchProgress`.
|
||||||
Кэши каталогов помечены `mutable` и пересобираются лениво из константных геттеров: QML читает
|
|
||||||
свойства помногу раз за кадр, пока открыт список, и пересборка по каждому чтению обошлась бы
|
Ход виден по сигналам `launchProgress`, `launched(profileName, buildName, serverUrl)` и
|
||||||
дорого.
|
`launchError`. `stopGame()` завершает процесс. Пока игра работает, `gameRunning` равно `true`;
|
||||||
|
вывод процесса приходит построчно в `gameOutput`, конец — в `gameFinished(exitCode, crashed)`.
|
||||||
## Потокобезопасность
|
|
||||||
|
## Вход через Microsoft из окна профиля
|
||||||
Только поток GUI. Единственная работа в другом потоке — файловые операции над содержимым
|
|
||||||
`.minecraft`, и она полностью инкапсулирована в [BuildSwitcher](BuildSwitcher.md): сам бэкенд
|
1. `startMicrosoftLogin(profileIndex)` испускает `microsoftLoginUrlReady(url)`. `-1` значит, что
|
||||||
общается с ним обычными сигналами и слотами.
|
профиля ещё нет и его нужно создать по нику из аккаунта. Без WebEngine сразу приходит
|
||||||
|
`microsoftLoginFailed`.
|
||||||
## Доступ из QML
|
2. [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) открывает страницу и на каждую смену
|
||||||
|
адреса вызывает `inspectMicrosoftRedirect(url)`. Ответ: `{matched, code, error}`. Разбор сделан
|
||||||
Класс зарегистрирован макросом `QML_ELEMENT` в модуле `Minecraft_launcher`, объявленном через
|
в C++, чтобы правила совпадали с тем, как `MsaAuthService` строит `redirect_uri`.
|
||||||
`qt_add_qml_module` в `CMakeLists.txt`. Имя типа в QML совпадает с именем класса —
|
3. Код уходит в `finishMicrosoftLogin(code)`. Итог приходит в `microsoftLoginSucceeded(playerName)`
|
||||||
`LauncherBackend`.
|
или `microsoftLoginFailed(message)`.
|
||||||
|
|
||||||
Из QML доступны все 26 свойств, все 42 метода `Q_INVOKABLE` и все сигналы, перечисленные выше.
|
`cancelMicrosoftLogin()` забывает начатый вход. Если окно закрыли, пока шёл обмен кода, результат
|
||||||
Синглтоном тип не объявлен: экземпляр создаётся декларативно в [Main.qml](../qml/Main.md) и
|
будет выброшен.
|
||||||
передаётся во вложенные диалоги через их свойство `backend`. Все диалоги проекта объявляют его как
|
|
||||||
`required property var backend`.
|
`microsoftAvailable` — константа: собран ли лаунчер с Qt WebEngine.
|
||||||
|
|
||||||
Объект, созданный из QML, принадлежит движку QML — удалять его из C++ нельзя.
|
## Сезонные сборки
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
- `seasonalCatalog` — строки каталога с полями сервера (`id`, `name`, `minecraftVersion`, `loader`,
|
||||||
|
`loaderTitle`, `loaderVersion`, `modCount`, `seasonStart`, `seasonEnd` в виде `дд.мм.гггг`,
|
||||||
**Вниз, к сервисам.** Бэкенд подписан на сигналы всех двенадцати сервисов и сводит их к своим
|
`sizeBytes`, `revision`, `description`, `serverUrl`) и локальным состоянием (`installed`,
|
||||||
свойствам. Четыре разных источника загрузки — установщик версий, установщик модлоадеров,
|
`installedRevision`, `updateAvailable`, `status`). Сборка считается установленной, если есть
|
||||||
установщик Java и загрузчик паков — отображаются в одну группу свойств `download*`, поэтому панель
|
запись с таким `seasonalId`. Обновление доступно, если её ревизия меньше серверной.
|
||||||
в интерфейсе не различает, кто работает; какой из источников показывать, бэкенд решает сам.
|
- `seasonalCatalogLoading`, `seasonalCatalogError`.
|
||||||
|
- `refreshSeasonalCatalog(force)` — перед запросом заново читает `seasonalBaseUrl`.
|
||||||
**Вверх, к QML.** Интерфейс не обращается ни к одному сервису напрямую. Каталоги отдаются уже
|
- `installSeasonalBuild(id)` — поставить или обновить.
|
||||||
сведёнными с локальным состоянием: строка версии знает про `installed`, строка сезонной сборки —
|
- `cancelSeasonalInstall()` — отменить.
|
||||||
про установленную ревизию и доступное обновление.
|
- `seasonalInstalling` — идёт установка.
|
||||||
|
|
||||||
**Состояние на диске.** Профили, сборки и настройки читаются при создании и пишутся при каждом
|
Установка — цепочка шагов. Каждый шаг либо запускает асинхронную работу и ждёт сигнала, либо
|
||||||
изменении. Отсутствие файла — норма (первый запуск), а повреждённое содержимое отводится в файл с
|
решает, что делать нечего, и сразу передаёт ход следующему (`seasonAdvance()` со сквозными `case`).
|
||||||
расширением `.bak`, чтобы рабочий файл создался заново. Проблемы хранилища, замеченные на старте,
|
|
||||||
накапливаются и показываются одним сообщением, когда интерфейс уже подключился к сигналам.
|
1. **Запись.** Если сборки с таким `seasonalId` нет, она создаётся. Если есть, в неё записываются
|
||||||
|
новые версия, модлоадер и адрес сервера. Ревизию пока не трогаем: её запишем, только когда файлы
|
||||||
Отдельно предусмотрена миграция: файл `versions.json` от прежней схемы именования переносится в
|
лягут на диск, иначе оборванная установка выглядела бы законченной.
|
||||||
`customBuilds.json` при первом запуске после переименования.
|
2. **Switching.** Сборка становится активной: пак раскатывается поверх текущей `.minecraft`.
|
||||||
|
3. **Java.** Нужная версия берётся из `javaMajor` каталога или из таблицы требований. Если
|
||||||
**Восстановление после сбоя.** При старте бэкенд спрашивает у переключателя сборок, не было ли
|
подходящая java уже есть (своя, системная или скачанная раньше), шаг пропускается. Иначе
|
||||||
прервано переключение, и предлагает доиграть его.
|
загружается каталог Java и ставится лучшая сборка для этой версии. Java ставится раньше
|
||||||
|
модлоадера, потому что она нужна установщику Forge.
|
||||||
## Внешнее взаимодействие
|
4. **Loader.** Для ванильной сборки ставится версия игры, для остальных — модлоадер. Если всё уже
|
||||||
|
установлено и укомплектовано, шаг пропускается.
|
||||||
Собственных сетевых обращений и дочерних процессов у класса нет: всё внешнее взаимодействие
|
5. **Downloading.** Архив качается в `builds/<id>/.pack-r<ревизия>.zip`. Точка в начале имени нужна,
|
||||||
делегировано сервисам — сеть у каталогов, установщиков и служб авторизации, процессы у
|
чтобы `BuildSwitcher` не принял файл за архив сборки и не стёр его.
|
||||||
[GameLauncher](GameLauncher.md), [ModLoaderInstaller](ModLoaderInstaller.md) и
|
6. **Applying.** `BuildSwitcher::applyPack()` удаляет всё, что принесла прошлая ревизия (список из
|
||||||
[JavaInstaller](JavaInstaller.md), файловые операции над `.minecraft` у
|
`season.json`), и распаковывает новую. Миры и скриншоты в этот список не попадают, потому что
|
||||||
[BuildSwitcher](BuildSwitcher.md).
|
пак их не приносил.
|
||||||
|
|
||||||
Единственное прямое обращение к системе — открытие папки игры в файловом менеджере методами
|
После раскатки `recordSeasonalPack()` пишет `builds/<id>/season.json` (`seasonalId`, `revision`,
|
||||||
`openMinecraftFolder()` и `openGameFolder()`.
|
`sha256`, `installedAt`, `entries`) и проставляет ревизию в запись сборки. Это делается и тогда,
|
||||||
|
когда раскатка была доиграна после перезапуска лаунчера. Конец цепочки — `seasonalInstallFinished
|
||||||
## Пример использования
|
(seasonalId, buildName)`.
|
||||||
|
|
||||||
Класс предназначен для создания из QML, а не из C++:
|
При ошибке на любом шаге скачанный архив удаляется, и приходит `launchError`. На шагах Switching и
|
||||||
|
Applying отменить нельзя: `.minecraft` уже разобрана, операцию нужно довести до конца.
|
||||||
|
|
||||||
|
Строка каталога копируется в `m_season.entry` при старте. Если каталог обновится во время установки,
|
||||||
|
ставиться будет всё равно то, что выбрали.
|
||||||
|
|
||||||
|
## Панель загрузки
|
||||||
|
|
||||||
|
Источников загрузки четыре: установщик версий, установщик модлоадера, установщик Java и загрузчик
|
||||||
|
пака. Наружу они видны через одну группу свойств: `downloading`, `downloadProgress`,
|
||||||
|
`downloadVersion` (что качается), `downloadStatus` (`этап — файл`), `downloadBytesDone`,
|
||||||
|
`downloadBytesTotal`. Все обновляются одним сигналом `downloadChanged`.
|
||||||
|
|
||||||
|
Чьи числа показывать, решается так. Пак качается, только когда остальные уже закончили. Java не
|
||||||
|
качается одновременно с версией или модлоадером. Во время установки модлоадера доля берётся у
|
||||||
|
`VersionInstaller`, если он сейчас качает файлы версии, потому что его оценка точнее.
|
||||||
|
|
||||||
|
`cancelDownload()` останавливает первое, что работает, в порядке: модлоадер (он сам остановит
|
||||||
|
вложенную установку версии), версия, Java, пак.
|
||||||
|
|
||||||
|
`busy` равно `true`, пока идёт вход или запуск, работает любой установщик, идёт смена сборки или
|
||||||
|
качается пак. На это время в интерфейсе гаснет кнопка запуска.
|
||||||
|
|
||||||
|
## Папки
|
||||||
|
|
||||||
|
- `openMinecraftFolder()` открывает `<gameDir>/mods` (создаёт, если её нет).
|
||||||
|
- `openGameFolder()` открывает саму папку игры.
|
||||||
|
|
||||||
|
## Потоки
|
||||||
|
|
||||||
|
Весь класс работает в GUI-потоке. В отдельном потоке выполняются только файловые операции
|
||||||
|
`BuildSwitcher`, и этот поток скрыт внутри него.
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
```qml
|
```qml
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Minecraft_launcher
|
import BiLauncher
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
# Localization
|
# Localization
|
||||||
|
|
||||||
`localization.h` / `localization.cpp`
|
`localization.h`, `localization.cpp`
|
||||||
|
|
||||||
Единственный источник всех текстов интерфейса. Каталог — [`i18n/translations.json`](#каталог),
|
Все тексты интерфейса на всех языках лежат в одном файле, `i18n/translations.json`, и читаются
|
||||||
он лежит в ресурсах и правится руками. Штатных `.ts`/`.qm` в проекте нет намеренно: `lupdate`,
|
через этот класс. Стандартные `.ts`/`.qm`, `lupdate`, `lrelease` и Linguist в проекте не
|
||||||
`lrelease` и Linguist не нужны, а все переводы видны в одном файле.
|
используются. Язык переключается без перезапуска.
|
||||||
|
|
||||||
Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.
|
## Из QML
|
||||||
|
|
||||||
## Доступ из QML
|
Синглтон `Loc` (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) доступен в любом файле модуля без
|
||||||
|
импорта.
|
||||||
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
|
|
||||||
в любом файле модуля без импорта.
|
|
||||||
|
|
||||||
```qml
|
```qml
|
||||||
Text { text: Loc.t.settings.title }
|
Text { text: Loc.t.settings.title }
|
||||||
@@ -19,31 +17,22 @@ Text { text: Loc.t.java.progress.downloading.arg(label) }
|
|||||||
DarkCombo { model: Loc.t.profile.authTypes }
|
DarkCombo { model: Loc.t.profile.authTypes }
|
||||||
```
|
```
|
||||||
|
|
||||||
Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
|
`Loc.t` — дерево JS-объектов, собранное из плоских ключей каталога разбиением по точке.
|
||||||
плоских ключей каталога разбиением по точке.
|
|
||||||
|
|
||||||
**Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
|
Почему `t` сделано свойством, а не методом `Loc.t("ключ")`. Вызов `Q_INVOKABLE` не регистрирует
|
||||||
`text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
|
зависимость привязки, и текст не обновился бы при смене языка. Чтение `Q_PROPERTY` с `NOTIFY`
|
||||||
`NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
|
регистрирует, поэтому `languageChanged` перевычисляет все привязки, где читали `Loc.t`. Дальнейшие
|
||||||
`Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
|
`.settings.title` — обычные обращения к полям JS-объекта. Следить за ними не нужно, потому что при
|
||||||
потому что при смене языка дерево заменяется целиком.
|
смене языка дерево заменяется целиком.
|
||||||
|
|
||||||
Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
|
Тип свойства — `QJSValue`. С `QVariantMap` движок собирал бы новый JS-объект при каждом чтении, а
|
||||||
при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
|
привязок в проекте около полутора сотен. `QJSValue` строится один раз на каждую смену языка.
|
||||||
языка.
|
|
||||||
|
|
||||||
Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
|
Кроме `t` есть `language` (что выбрано: `system`, `ru`, `en`), `resolvedLanguage` (во что это
|
||||||
|
превратилось, всегда `ru` или `en`) и `availableLanguages()`. Последний сейчас не используется:
|
||||||
|
список языков в настройках [Main.qml](../qml/Main.md) задан руками (`stLanguage`).
|
||||||
|
|
||||||
```qml
|
## Из C++
|
||||||
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"
|
||||||
@@ -53,29 +42,34 @@ emit launchProgress(Loc::text("java.progress.downloading").arg(label));
|
|||||||
const QStringList kinds = Loc::list("profile.authTypes");
|
const QStringList kinds = Loc::list("profile.authTypes");
|
||||||
```
|
```
|
||||||
|
|
||||||
Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
|
Это свободные функции, потому что их вызывают и из пространств имён без `QObject`
|
||||||
`launch.error.noProfile` в интерфейсе сразу бросается в глаза.
|
([launcherpaths.h](launcherpaths.md), [javalocator.h](javalocator.md),
|
||||||
|
[zlibreference.h](zlibreference.md)).
|
||||||
|
|
||||||
|
Если ключа нет, возвращается сам ключ, а в отладочной сборке ещё пишется `qWarning`. В интерфейсе
|
||||||
|
такую строку легко заметить.
|
||||||
|
|
||||||
### Потоки
|
### Потоки
|
||||||
|
|
||||||
`Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
|
`Loc::text()` и `Loc::list()` можно вызывать из любого потока. Каталог заполняется один раз в
|
||||||
в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
|
`load()`, до того как [BuildSwitcher](BuildSwitcher.md) запустит свой поток, и дальше только
|
||||||
создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
|
читается. На ходу меняется лишь индекс текущего языка, а он `QAtomicInt`. В худшем случае строка,
|
||||||
себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
|
собранная в момент переключения, окажется на прежнем языке.
|
||||||
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
|
|
||||||
|
|
||||||
`setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
|
`setLanguage()` вызывается только из GUI-потока, это проверяет `Q_ASSERT`.
|
||||||
|
|
||||||
## Каталог
|
## Каталог
|
||||||
|
|
||||||
`i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
|
Файл попадает в ресурсы через `RESOURCES` в `qt_add_qml_module` и читается из
|
||||||
поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
|
`:/qt/qml/BiLauncher/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" },
|
||||||
@@ -88,77 +82,92 @@ const QStringList kinds = Loc::list("profile.authTypes");
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Ключи плоские, языки рядом: забытый перевод виден на соседней строке, правка пары — один участок
|
Ключи плоские, языки записаны рядом. Так сразу видно, если перевод забыли, а новый язык
|
||||||
файла, а третий язык добавляется колонкой без правок загрузчика.
|
добавляется ещё одним полем без правок загрузчика. `_readme` и `_glossary` — подсказки для тех, кто
|
||||||
|
правит файл. Загрузчик их не читает.
|
||||||
|
|
||||||
**Имя ключа — `<домен>.<вид>.<имя>`**, сегменты в lowerCamelCase.
|
Значение — либо строка, либо список строк (для моделей выпадающих списков). Тип определяется по
|
||||||
|
первому языку.
|
||||||
|
|
||||||
- **Домен** — область, а не имя файла: `app`, `common`, `settings`, `profile`, `build`,
|
### Имена ключей
|
||||||
`seasonal`, `version`, `java`, `loader`, `auth` (с `auth.ely.*`, `auth.msa.*`), `launch`,
|
|
||||||
`game`, `switch`, `storage`, `zlib`.
|
|
||||||
- **Вид** — `title`, `label`, `button`, `placeholder`, `hint`, `header`; для сообщений `error`,
|
|
||||||
`progress`, `status`, `warning`.
|
|
||||||
- **Имя** описывает условие, а не формулировку, чтобы перевод не переименовывал ключ:
|
|
||||||
`gameRunning`, `downloadFailed`, `checksumMismatch`.
|
|
||||||
|
|
||||||
Литерал, который нужен в двух и более файлах, живёт в `common.*`.
|
`<домен>.<вид>.<имя>`, сегменты в lowerCamelCase.
|
||||||
|
|
||||||
Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
|
- **Домен** — область интерфейса, а не файл: `app`, `common`, `settings`, `profile`, `build`,
|
||||||
QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
|
`seasonal`, `version`, `java`, `loader`, `auth` (`auth.ely.*`, `auth.msa.*`), `launch`, `game`,
|
||||||
отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
|
`switch`, `storage`, `zlib`.
|
||||||
строки, которой оно требуется.
|
- **Вид**: `title`, `label`, `button`, `placeholder`, `hint`, `header`, `tooltip`; для сообщений
|
||||||
|
`error`, `progress`, `status`, `warning`.
|
||||||
|
- **Имя** описывает ситуацию, а не формулировку: `gameRunning`, `downloadFailed`,
|
||||||
|
`checksumMismatch`. Тогда при правке перевода ключ не придётся переименовывать.
|
||||||
|
|
||||||
## Как добавить строку
|
Строка, которая нужна в двух и более местах, кладётся в `common.*`.
|
||||||
|
|
||||||
1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
|
`%1`, `%2` пишутся как есть: их одинаково понимают `QString::arg()` и `String.arg()` в QML. Набор
|
||||||
2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
|
`%N` в `ru` и `en` должен совпадать, порядок может быть разным. Множественного числа формат не
|
||||||
3. Прогнать `python3 tools/check_translations.py`.
|
поддерживает, и пока ни одна строка его не требует.
|
||||||
|
|
||||||
## Как добавить язык
|
## Добавить строку
|
||||||
|
|
||||||
1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
|
1. Запись в `strings` сразу с `ru` и `en`.
|
||||||
2. Добавить колонку с этим кодом в каждую запись `strings`.
|
2. `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
|
||||||
3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
|
3. `python3 tools/check_translations.py`.
|
||||||
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
|
|
||||||
одинаково во всех колонках).
|
|
||||||
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
|
|
||||||
русский для русской системной локали и английский во всех остальных случаях.
|
|
||||||
|
|
||||||
## Выбор языка и его хранение
|
## Добавить язык
|
||||||
|
|
||||||
Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
|
1. Код в `_meta.languages`, название в `_meta.displayNames`.
|
||||||
Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
|
2. Значение для этого кода в каждой записи `strings`.
|
||||||
диалога «Настройки запуска», применяется по кнопке «Сохранить».
|
3. Ключ `settings.language.<код>` с названием языка на нём самом, одинаковым во всех колонках.
|
||||||
|
4. Пункт в модели `stLanguage` и код в `stLanguage.codes` в [Main.qml](../qml/Main.md).
|
||||||
|
5. Если нужно, поправить `systemLanguage()`: сейчас при русской системной локали выбирается
|
||||||
|
русский, при любой другой — английский.
|
||||||
|
|
||||||
Круг замкнут в одну сторону, циклической зависимости нет:
|
## Где хранится выбранный язык
|
||||||
|
|
||||||
|
Ключ `language` в `settings.json`, по умолчанию `system`. Меняется в окне настроек и применяется
|
||||||
|
по кнопке «Сохранить».
|
||||||
|
|
||||||
```
|
```
|
||||||
main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
|
main.cpp ──► Localization::load() ──► читает settings.json (один раз)
|
||||||
LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
|
LauncherBackend::updateSettings() ──► Localization::setLanguage()
|
||||||
```
|
```
|
||||||
|
|
||||||
`Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
|
`settings.json` записывает только [LauncherBackend](LauncherBackend.md). Localization его только
|
||||||
по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
|
читает, и только при старте: язык нужен раньше первой привязки QML, а бэкенд создаётся вместе с
|
||||||
чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
|
движком. Обратной зависимости от бэкенда у Localization нет.
|
||||||
|
|
||||||
Неизвестное значение (файл правили руками) откатывается на `"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` — только чтение, ненулевой код возврата при любой ошибке:
|
## Проверка каталога
|
||||||
|
|
||||||
- каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
|
`tools/check_translations.py` ничего не меняет и возвращает ненулевой код при любой ошибке.
|
||||||
- каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
|
Проверяет:
|
||||||
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
|
|
||||||
- наборы `%N` совпадают по языкам, длины списков равны;
|
- у всех языков одинаковый набор ключей и типы, пустых значений нет, длины списков равны;
|
||||||
- не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
|
- набор `%N` совпадает по языкам;
|
||||||
- ключи, на которые никто не ссылается, — предупреждением.
|
- каждый `Loc::text("…")` и `Loc::list("…")` в C++ есть в каталоге с нужным типом;
|
||||||
|
- каждый `Loc.t.a.b.c` в QML ведёт к существующему ключу (последний сегмент может быть методом
|
||||||
|
JS, например `.arg`);
|
||||||
|
- нигде не осталось `tr(`, `qsTr(`, `QCoreApplication::translate`.
|
||||||
|
|
||||||
|
Ключи, на которые никто не ссылается, выводятся предупреждением.
|
||||||
|
|
||||||
|
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
|
||||||
|
будет поправить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -1,190 +1,77 @@
|
|||||||
# ModLoaderInstaller
|
# ModLoaderInstaller
|
||||||
|
|
||||||
## Обзор класса
|
`modloaderinstaller.h`, `modloaderinstaller.cpp`
|
||||||
|
|
||||||
`ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно
|
Ставит модлоадер в `.minecraft`. Fabric и Quilt ставятся иначе, чем Forge и NeoForge, но снаружи у
|
||||||
разных пути установки.
|
них одинаковый интерфейс.
|
||||||
|
|
||||||
**Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в
|
- **Fabric и Quilt** отдают готовое описание версии. Его достаточно положить в
|
||||||
`versions/<id>/<id>.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по
|
`versions/<id>/<id>.json`, а остальное докачает [VersionInstaller](VersionInstaller.md).
|
||||||
полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера.
|
- **Forge и NeoForge** при установке патчат клиентский jar, поэтому запускается их официальный
|
||||||
|
`installer.jar` без окна. После него проверяется, какой профиль появился в `versions/`.
|
||||||
|
|
||||||
**Forge и NeoForge** так не умеют: их установка — это патч клиентского jar. Поэтому лаунчер
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса устроены так же, как у
|
||||||
запускает официальный `installer.jar` найденной Java в headless-режиме и смотрит, какой профиль
|
`VersionInstaller` (`label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`), и
|
||||||
появился в `versions`. Процессу установщика при этом подсовывается эталонный zlib — см.
|
панель загрузки читает их одинаково.
|
||||||
[ZlibReference](zlibreference.md).
|
|
||||||
|
|
||||||
Оба пути начинаются одинаково: пока ванильная версия не скачана целиком, ставить лоадер некуда.
|
## install(gameDir, loader, gameVersion, loaderVersion, javaPreference)
|
||||||
|
|
||||||
Набор геттеров прогресса намеренно повторяет `VersionInstaller`: панель загрузки в интерфейсе
|
Если установка уже идёт или не указана версия, сразу приходит `failed`. Иначе:
|
||||||
читает их одинаково, независимо от того, кто сейчас работает.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
1. Список версий модлоадера через [ModLoaderVersionService](ModLoaderVersionService.md).
|
||||||
|
2. **Базовая версия игры.** Если её нет или не хватает файлов, она ставится через
|
||||||
|
`VersionInstaller`, и ход идёт дальше только после её `finished`. Без целой ванильной версии
|
||||||
|
модлоадер ставить некуда.
|
||||||
|
3. Дальше один из двух путей.
|
||||||
|
|
||||||
Подключает [modloader.h](modloader.md). В конструктор принимает
|
### Fabric и Quilt
|
||||||
[ModLoaderVersionService](ModLoaderVersionService.md) — за адресом установщика — и
|
|
||||||
[VersionInstaller](VersionInstaller.md) — за базовой версией и за докачиванием того, что
|
|
||||||
`installer.jar` не положил.
|
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md).
|
Описание скачивается из `meta.fabricmc.net/v2/versions/loader/<игра>/<лоадер>/profile/json`
|
||||||
|
(у Quilt `meta.quiltmc.org/v3/...`). Проверяется, что в нём есть безопасный `id` и что
|
||||||
|
`inheritsFrom` совпадает с выбранной версией игры. Файл сохраняется, и `VersionInstaller` получает
|
||||||
|
этот `id`.
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QProcess`, `QFile`) и `Qt6::Network` (`QNetworkAccessManager`,
|
### Forge и NeoForge
|
||||||
`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/`, удаляются. В текст ошибки идут код выхода,
|
||||||
оба обязаны пережить его.
|
хвост журнала и путь к полному журналу.
|
||||||
|
|
||||||
#### bool isRunning() const
|
Если подменить zlib не удалось, а в журнале есть `Processor failed` или `invalid outputs`, к
|
||||||
|
сообщению добавляется прямая подсказка про zlib-ng. Почти наверняка дело в нём.
|
||||||
|
|
||||||
Идёт ли установка прямо сейчас.
|
## cancel()
|
||||||
|
|
||||||
#### QString label() const
|
Обрывает загрузку, убивает процесс установщика и удаляет то, что он создал. Если ход уже передан
|
||||||
|
`VersionInstaller`, отменяется и он, а `canceled` придёт после его отчёта.
|
||||||
|
|
||||||
Подпись текущей установки для интерфейса — название лоадера с версиями.
|
Деструктор убивает процесс установщика, если тот ещё работает.
|
||||||
|
|
||||||
#### QString stage() const
|
|
||||||
|
|
||||||
Текущий этап словами.
|
|
||||||
|
|
||||||
#### QString currentFile() const
|
|
||||||
|
|
||||||
Файл, который обрабатывается сейчас.
|
|
||||||
|
|
||||||
#### qint64 bytesDone() const
|
|
||||||
|
|
||||||
Сколько байт уже получено.
|
|
||||||
|
|
||||||
#### qint64 bytesTotal() const
|
|
||||||
|
|
||||||
Ожидаемый общий объём; `0` — неизвестен.
|
|
||||||
|
|
||||||
#### double fraction() const
|
|
||||||
|
|
||||||
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. У пути с `installer.jar` доля
|
|
||||||
почти всё время равна `-1`: сколько работы осталось внутри чужого процесса, лаунчер не знает.
|
|
||||||
|
|
||||||
#### void install(const QString &gameDir, ModLoader loader, const QString &gameVersion, const QString &loaderVersion, const QString &javaPreference)
|
|
||||||
|
|
||||||
Ставит модлоадер. Параметр `javaPreference` — путь к java, указанный пользователем в настройках;
|
|
||||||
он проверяется первым, а при пустом или неподходящем значении java ищется сама. Java нужна даже на
|
|
||||||
пути Fabric и Quilt, потому что перед установкой лоадера скачивается базовая версия игры.
|
|
||||||
|
|
||||||
Перед запуском `installer.jar` установщик записывает заглушку `launcher_profiles.json`: без этого
|
|
||||||
файла официальные установщики Forge и NeoForge отказываются работать.
|
|
||||||
|
|
||||||
#### void cancel()
|
|
||||||
|
|
||||||
Отменяет установку. Если `installer.jar` уже успел отработать, за появившиеся в `versions` папки
|
|
||||||
отвечает лаунчер, и при отмене они убираются.
|
|
||||||
|
|
||||||
## Сигналы
|
|
||||||
|
|
||||||
#### started(const QString &label)
|
|
||||||
|
|
||||||
Установка началась. Обработчик показывает панель прогресса.
|
|
||||||
|
|
||||||
#### progressChanged()
|
|
||||||
|
|
||||||
Изменились числа прогресса. Обработчик перечитывает геттеры — тот же набор, что у
|
|
||||||
[VersionInstaller](VersionInstaller.md).
|
|
||||||
|
|
||||||
#### finished(const QString &loaderKey, const QString &gameVersion, const QString &loaderVersion, const QString &producedVersionId)
|
|
||||||
|
|
||||||
Модлоадер установлен. Последний параметр — идентификатор появившегося профиля `versions/<id>`.
|
|
||||||
Он важен именно для Forge: заранее этот идентификатор неизвестен, поэтому новый профиль ищется
|
|
||||||
разницей между списком версий до и после запуска установщика, а не угадыванием строки —
|
|
||||||
идентификаторы Forge отличаются по эпохам.
|
|
||||||
|
|
||||||
Обработчик записывает `producedVersionId` в сборку: именно этот профиль будет запускаться.
|
|
||||||
|
|
||||||
#### failed(const QString &label, const QString &message)
|
|
||||||
|
|
||||||
Установка не удалась. Текст ошибки по возможности объясняет причину: отдельно распознаётся случай,
|
|
||||||
когда в системе стоит zlib-ng, а подменить его было нечем — это почти наверняка причина
|
|
||||||
расхождения sha1 у Forge, и о ней стоит сказать прямым текстом.
|
|
||||||
|
|
||||||
#### canceled(const QString &label)
|
|
||||||
|
|
||||||
Установка отменена пользователем.
|
|
||||||
|
|
||||||
#### log(const QString &line)
|
|
||||||
|
|
||||||
Строка вывода процесса `installer.jar`. Обработчик пишет её в журнал; вывод также сохраняется в
|
|
||||||
файл, чтобы разбираться с неудачной установкой после закрытия лаунчера.
|
|
||||||
|
|
||||||
## Владение и время жизни
|
|
||||||
|
|
||||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
|
||||||
|
|
||||||
Указатели на `ModLoaderVersionService` и `VersionInstaller`, переданные в конструктор, **не
|
|
||||||
принадлежат** установщику: оба сервиса создаются раньше и живут дольше.
|
|
||||||
|
|
||||||
`QNetworkAccessManager` создаётся в конструкторе с установщиком в роли родителя; сетевой ответ,
|
|
||||||
процесс установщика и файл журнала создаются по ходу работы и закрываются в деструкторе.
|
|
||||||
|
|
||||||
Установка асинхронна и состоит из вложенных продолжений: `ensureBaseVersion()` принимает функцию,
|
|
||||||
которая будет вызвана после появления базовой версии. Уничтожение установщика посреди этой цепочки
|
|
||||||
обрывает её.
|
|
||||||
|
|
||||||
## Потокобезопасность
|
|
||||||
|
|
||||||
Только поток GUI. Дочерний процесс `installer.jar` работает параллельно, но общение с ним идёт
|
|
||||||
через сигналы `QProcess`, которые приходят в поток GUI.
|
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
|
||||||
|
|
||||||
`LauncherBackend` вызывает `install()` при установке сборки с модлоадером и переправляет сигналы
|
|
||||||
прогресса в те же свойства, что и у `VersionInstaller`, — панель загрузки не различает, кто
|
|
||||||
работает. По сигналу `finished` бэкенд записывает `producedVersionId` в поле сборки
|
|
||||||
`resolvedVersionId`, которое до установки пусто и означает запуск на чистой ванили.
|
|
||||||
|
|
||||||
[ModLoaderVersionService](ModLoaderVersionService.md) даёт адрес `installer.jar`,
|
|
||||||
[VersionInstaller](VersionInstaller.md) ставит базовую версию до начала работы и докачивает
|
|
||||||
недостающие библиотеки после неё, [ZlibReference](zlibreference.md) готовит окружение процесса.
|
|
||||||
|
|
||||||
## Внешнее взаимодействие
|
|
||||||
|
|
||||||
**Сеть, исходящие запросы.** Загрузка `installer.jar` по HTTPS для Forge и NeoForge. Для Fabric и
|
|
||||||
Quilt сеть используется только через сервис версий и `VersionInstaller`.
|
|
||||||
|
|
||||||
**Дочерний процесс.** `installer.jar` запускается java в headless-режиме через `QProcess`.
|
|
||||||
Окружение процесса готовится `ZlibReference::applyTo()`: на Linux с zlib-ng туда дописывается
|
|
||||||
`LD_PRELOAD` с эталонной библиотекой, иначе установщик Forge падает с сообщением «Processor
|
|
||||||
failed, invalid outputs» — он сверяет sha1 собранных им же jar-файлов с эталоном, посчитанным на
|
|
||||||
обычном zlib.
|
|
||||||
|
|
||||||
Вывод процесса читается построчно, отдаётся сигналом `log` и параллельно пишется в файл. Результат
|
|
||||||
установки определяется не кодом выхода, а появлением новой папки в `versions`.
|
|
||||||
|
|
||||||
Все сигналы приходят в поток GUI.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
auto *loaderInstaller = new ModLoaderInstaller(loaderVersions, versionInstaller, this);
|
|
||||||
|
|
||||||
connect(loaderInstaller, &ModLoaderInstaller::finished, this,
|
|
||||||
[this](const QString &key, const QString &game,
|
|
||||||
const QString &loaderVersion, const QString &producedVersionId) {
|
|
||||||
m_build.resolvedVersionId = producedVersionId;
|
|
||||||
saveBuilds();
|
|
||||||
});
|
|
||||||
connect(loaderInstaller, &ModLoaderInstaller::failed, this,
|
|
||||||
[this](const QString &, const QString &message) { emit launchError(message); });
|
|
||||||
|
|
||||||
loaderInstaller->install(gameDir, ModLoader::Forge,
|
|
||||||
QStringLiteral("1.20.1"), QStringLiteral("47.4.0"),
|
|
||||||
settings.javaPath);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,152 +1,71 @@
|
|||||||
# ModLoaderVersionService
|
# ModLoaderVersionService
|
||||||
|
|
||||||
## Обзор класса
|
`modloaderversionservice.h`, `modloaderversionservice.cpp`
|
||||||
|
|
||||||
Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService`
|
Списки версий Forge, Fabric, NeoForge и Quilt. Устроен так же, как
|
||||||
приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока
|
[VersionManifestService](VersionManifestService.md): качает, хранит копию в
|
||||||
тот не устарел. Устроен так же, как [VersionManifestService](VersionManifestService.md).
|
`<root>/cache/loader_<ключ>.json` и отвечает из неё, пока она моложе 6 часов.
|
||||||
|
|
||||||
Главная особенность класса — в том, чего в нём нет: отдельной проверки совместимости с версией
|
Совместимость с версией игры отдельно не проверяется: данные с самого начала разложены по версиям
|
||||||
игры. Совместимость заложена в структуру данных. Fabric и Quilt отдают список сразу под нужную
|
игры. Под версию, для которой сборок нет, лежит пустой список, и выбрать несовместимый модлоадер
|
||||||
версию игры, а `maven-metadata` Forge и NeoForge раскладывается по версиям игры при разборе.
|
нечем.
|
||||||
Версии игры, под которую сборок нет, соответствует пустой список — выбрать несовместимый лоадер
|
|
||||||
физически нечем.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). [ModLoaderInstaller](ModLoaderInstaller.md)
|
||||||
|
берёт отсюда ссылку на `installer.jar` через `find()`.
|
||||||
|
|
||||||
Подключает [modloader.h](modloader.md): перечисление `ModLoader` и структура `LoaderVersionEntry`
|
## Откуда берутся данные
|
||||||
приходят оттуда.
|
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
|
**Fabric и Quilt** спрашиваются отдельно под каждую версию игры:
|
||||||
[ModLoaderInstaller](ModLoaderInstaller.md) — из записи списка он берёт адрес `installer.jar`.
|
`meta.fabricmc.net/v2/versions/loader/<игра>` и `meta.quiltmc.org/v3/versions/loader/<игра>`.
|
||||||
|
Признак стабильности берётся из ответа, а если его нет, угадывается по имени.
|
||||||
|
|
||||||
Путь к файлу кэша даёт `LauncherPaths::loaderCacheFile()` из [launcherpaths.h](launcherpaths.md).
|
**Forge** — один `maven-metadata.xml` на все версии игры. Строка `1.20.1-47.4.0` делится по первому
|
||||||
|
дефису. Параллельно запрашивается `promotions_slim.json`, оттуда берутся отметки recommended. Если
|
||||||
|
он не ответил, список всё равно сохраняется, только без отметок.
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
|
**NeoForge** тоже из `maven-metadata.xml`, но версия игры в номере не записана. Её приходится
|
||||||
(`QNetworkAccessManager`).
|
выводить: `21.1.248` → `1.21.1`, `20.4.80` → `1.20.4`, `21.0.x` → `1.21`. Для четырёхчастных
|
||||||
|
номеров вроде `25.1.0.1` правило другое: `25.1.0.1` → `25.1`. Вторым запросом читается старый
|
||||||
|
репозиторий `net/neoforged/forge` с первыми сборками NeoForge под 1.20.1, которые выходили под
|
||||||
|
именем артефакта `forge` и номерами в стиле Forge.
|
||||||
|
|
||||||
## Иерархия и роль
|
Ссылка на установщик собирается по схеме maven:
|
||||||
|
`<репозиторий>/<версия>/<артефакт>-<версия>-installer.jar`.
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
Версии внутри каждой версии игры отсортированы от новых к старым. Числа сравниваются как числа,
|
||||||
методов базового класса не переопределяет.
|
поэтому `47.10` новее `47.9`.
|
||||||
|
|
||||||
## Псевдонимы типов
|
## ensureLoaded(loader, gameVersion, callback, forceRefresh)
|
||||||
|
|
||||||
`ModLoaderVersionService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
Логика та же, что у манифеста версий. Отличие в том, что свежесть у Fabric и Quilt считается для
|
||||||
Как и у сервиса манифеста, `ok == true` с непустым `warning` означает, что данные отдали из
|
каждой версии игры отдельно, а у Forge и NeoForge для модлоадера целиком. По этому же ключу
|
||||||
устаревшего кэша.
|
объединяются одновременные запросы.
|
||||||
|
|
||||||
## Публичные методы
|
- Пустая `gameVersion` — сразу `ok` и пустой список.
|
||||||
|
- При первом обращении к модлоадеру читается кэш, и если он есть, испускается
|
||||||
|
`versionsChanged(key, gameVersion)`.
|
||||||
|
- Если сеть не ответила, но какие-то данные по модлоадеру есть, колбэк получит `ok = true` и
|
||||||
|
`warning`.
|
||||||
|
- Если ответ не разобрался, хороший кэш не затирается.
|
||||||
|
|
||||||
#### explicit ModLoaderVersionService(QObject \*parent = nullptr)
|
`versionsChanged(loaderKey, gameVersion)` испускается после каждого успешного обновления.
|
||||||
|
`refreshingChanged()` — в начале и в конце запроса. `isRefreshing(loader, gameVersion)` отвечает,
|
||||||
|
идёт ли запрос по этому ключу.
|
||||||
|
|
||||||
Создаёт сервис и его `QNetworkAccessManager`. Кэши читаются лениво, при первом обращении к
|
## Формат кэша
|
||||||
конкретному лоадеру. Конструктор помечен `explicit`.
|
|
||||||
|
|
||||||
#### void ensureLoaded(ModLoader loader, const QString &gameVersion, Callback callback, bool forceRefresh = false)
|
```json
|
||||||
|
{
|
||||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
"fetchedAt": "...",
|
||||||
один сетевой запрос на всех, кто успел попросить.
|
"games": {
|
||||||
|
"1.20.1": { "fetchedAt": "...", "entries": [ { "loaderVersion": "47.4.0", ... } ] }
|
||||||
Свежесть считается по-разному в зависимости от лоадера: Fabric и Quilt спрашиваются по каждой
|
|
||||||
версии игры отдельно, поэтому отметка времени у них своя на каждую версию; Forge и NeoForge
|
|
||||||
приходят одним `maven-metadata` на все версии сразу, и отметка у них одна на весь лоадер.
|
|
||||||
|
|
||||||
Параметр `forceRefresh` обходит проверку свежести.
|
|
||||||
|
|
||||||
#### QList<LoaderVersionEntry> versions(ModLoader loader, const QString &gameVersion) const
|
|
||||||
|
|
||||||
Список сборок лоадера под конкретную версию игры. Новые сборки идут первыми, поэтому первая строка
|
|
||||||
— самая свежая; именно её интерфейс подставляет по умолчанию.
|
|
||||||
|
|
||||||
Пустой список означает, что лоадер эту версию игры не поддерживает.
|
|
||||||
|
|
||||||
#### bool isRefreshing(ModLoader loader, const QString &gameVersion) const
|
|
||||||
|
|
||||||
Идёт ли сейчас запрос по этой паре. Интерфейс по этому признаку отличает «ещё грузим» от «не
|
|
||||||
поддерживается» — оба случая выглядят пустым списком.
|
|
||||||
|
|
||||||
#### std::optional<LoaderVersionEntry> find(ModLoader loader, const QString &gameVersion, const QString &loaderVersion) const
|
|
||||||
|
|
||||||
Запись по версии лоадера; `std::nullopt`, если такой нет. Из неё установщик берёт ссылку на
|
|
||||||
`installer.jar`.
|
|
||||||
|
|
||||||
## Сигналы
|
|
||||||
|
|
||||||
#### versionsChanged(const QString &loaderKey, const QString &gameVersion)
|
|
||||||
|
|
||||||
Список версий изменился. Параметры сужают событие до конкретной пары: `loaderKey` принимает
|
|
||||||
значения `forge`, `fabric`, `neoforge`, `quilt`.
|
|
||||||
|
|
||||||
Обработчик должен сверить оба параметра со своим текущим состоянием и перечитать `versions()`,
|
|
||||||
только если они совпадают, — иначе обновление относится к другой строке лоадера. Именно так
|
|
||||||
поступает [LoaderRow](../qml/LoaderRow.md).
|
|
||||||
|
|
||||||
#### refreshingChanged()
|
|
||||||
|
|
||||||
Изменился признак сетевого обновления у какой-либо пары. Обработчик перечитывает
|
|
||||||
`isRefreshing()` для интересующей его пары.
|
|
||||||
|
|
||||||
## Владение и время жизни
|
|
||||||
|
|
||||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
|
||||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
|
||||||
|
|
||||||
Отложенные колбэки хранятся по ключу запроса до завершения соответствующего обращения к сети.
|
|
||||||
Уничтожение сервиса с незавершённым запросом обрывает его, и накопленные колбэки не вызываются.
|
|
||||||
|
|
||||||
Ссылку на сервис держит установщик модлоадеров; уничтожать сервис раньше установщика нельзя.
|
|
||||||
|
|
||||||
## Потокобезопасность
|
|
||||||
|
|
||||||
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
|
||||||
|
|
||||||
`LauncherBackend` оборачивает сервис тремя методами, доступными из QML: получить список, запросить
|
|
||||||
обновление и узнать, идёт ли загрузка. Сигнал `versionsChanged` он переправляет в QML под тем же
|
|
||||||
именем, поэтому строка лоадера в карточке сборки подписывается прямо на него.
|
|
||||||
|
|
||||||
[ModLoaderInstaller](ModLoaderInstaller.md) обращается к `find()` за адресом установщика перед
|
|
||||||
началом установки.
|
|
||||||
|
|
||||||
## Внешнее взаимодействие
|
|
||||||
|
|
||||||
**Сеть, исходящие запросы.** Класс обращается к четырём разным источникам метаданных, и форматы
|
|
||||||
ответов различаются: у Fabric и Quilt это JSON, у Forge и NeoForge — XML `maven-metadata`. Разбор
|
|
||||||
разделён на две функции соответственно.
|
|
||||||
|
|
||||||
Испорченный ответ разбирается в пустой результат, и хороший кэш им не затирается — это сознательное
|
|
||||||
решение: лучше показать вчерашний список, чем стереть его из-за сбоя на сервере.
|
|
||||||
|
|
||||||
При недоступной сети данные отдаются из устаревшего кэша с `ok == true` и заполненным `warning`.
|
|
||||||
|
|
||||||
Все сигналы и колбэки приходят в поток GUI.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
auto *loaders = new ModLoaderVersionService(this);
|
|
||||||
|
|
||||||
connect(loaders, &ModLoaderVersionService::versionsChanged,
|
|
||||||
this, [this](const QString &key, const QString &game) {
|
|
||||||
if (key == loaderKey(ModLoader::Fabric) && game == m_gameVersion)
|
|
||||||
emit fabricVersionsChanged();
|
|
||||||
});
|
|
||||||
|
|
||||||
loaders->ensureLoaded(ModLoader::Fabric, QStringLiteral("1.21.1"),
|
|
||||||
[this, loaders](bool ok, const QString &warning) {
|
|
||||||
if (!ok) {
|
|
||||||
showStatus(warning);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const auto list = loaders->versions(ModLoader::Fabric,
|
}
|
||||||
QStringLiteral("1.21.1"));
|
|
||||||
if (!list.isEmpty())
|
|
||||||
selectVersion(list.first().loaderVersion);
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Верхний `fetchedAt` используется для Forge и NeoForge, `fetchedAt` внутри версии игры — для Fabric и
|
||||||
|
Quilt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
При создании этого документа использовался ИИ.
|
При создании этого документа использовался ИИ.
|
||||||
@@ -1,160 +1,80 @@
|
|||||||
# MsaAuthService
|
# MsaAuthService
|
||||||
|
|
||||||
## Обзор класса
|
`msaauthservice.h`, `msaauthservice.cpp`
|
||||||
|
|
||||||
`MsaAuthService` — авторизация через учётную запись Microsoft, то есть вход с лицензионной копией
|
Вход через аккаунт Microsoft, то есть с лицензионной копией игры. Цепочка такая же, как у
|
||||||
игры. Это та же цепочка, что и в официальном лаунчере: OAuth2 → Xbox Live → XSTS → Minecraft
|
официального лаунчера: OAuth2 → Xbox Live → XSTS → Minecraft Services → проверка лицензии →
|
||||||
Services → проверка лицензии.
|
профиль. Результат приходит в `AuthResult` из [AuthService](AuthService.md).
|
||||||
|
|
||||||
Результат отдаётся тем же `AuthResult`, что и [AuthService](AuthService.md) для Ely.by и офлайна,
|
Окно входа сервис не показывает. Страницу Microsoft открывает
|
||||||
поэтому запуск игры дальше идёт по общему пути и ничего не знает о способе входа.
|
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), а сервис даёт адрес этой страницы и умеет
|
||||||
|
узнать адрес возврата. Класс компилируется всегда, но без Qt WebEngine показать страницу нечем, и
|
||||||
|
[LauncherBackend](LauncherBackend.md) сразу отвечает ошибкой.
|
||||||
|
|
||||||
Класс не показывает окно входа сам: страницу Microsoft открывает
|
## Чьё это приложение
|
||||||
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) на стороне QML, а сервис даёт ему адрес
|
|
||||||
страницы и разбирает адрес возврата.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
Сейчас используется `client_id` официального лаунчера Mojang (`00000000402b5328`). Это старое
|
||||||
|
приложение Live Connect, поэтому и адреса от `login.live.com`:
|
||||||
|
|
||||||
Подключает `authservice.h` — ради общей структуры `AuthResult`. Экземпляр создаётся и принадлежит
|
| Константа | Значение |
|
||||||
[LauncherBackend](LauncherBackend.md).
|
|-----------|----------|
|
||||||
|
| `kAuthorizeUrl` | `https://login.live.com/oauth20_authorize.srf` |
|
||||||
|
| `kTokenUrl` | `https://login.live.com/oauth20_token.srf` |
|
||||||
|
| `kRedirectUri` | `https://login.live.com/oauth20_desktop.srf` |
|
||||||
|
| `kScope` | `service::user.auth.xboxlive.com::MBI_SSL` |
|
||||||
|
| `kRpsTicketPrefix` | пусто |
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QJsonObject`, `QString`, `QUrl`) и `Qt6::Network`
|
Это временное решение. Чтобы перейти на своё приложение в Azure (с включённым «Allow public client
|
||||||
(`QNetworkAccessManager`).
|
flows»), меняются эти шесть констант. Что именно подставить, написано в комментарии в начале
|
||||||
|
`msaauthservice.cpp`. Главное, что легко пропустить: у нового приложения в `RpsTicket` нужна
|
||||||
|
приставка `d=`.
|
||||||
|
|
||||||
Сам класс собирается всегда и не зависит от Qt WebEngine — от наличия WebEngine зависит только
|
`clientId()` отдаёт этот id наружу. Бэкенд подставляет его в `${clientid}`, потому что официальный
|
||||||
окно, в котором показывается страница входа. Поэтому в сборке без WebEngine сервис существует, но
|
лаунчер передаёт туда id приложения, а не случайный токен.
|
||||||
воспользоваться им нельзя: показать страницу нечем.
|
|
||||||
|
|
||||||
## Иерархия и роль
|
## Окно входа
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, сигнал `progress`, владение по родителю. Виртуальных
|
`authorizationUrl()` — адрес страницы входа с `client_id`, `response_type=code`, `redirect_uri`,
|
||||||
методов базового класса не переопределяет.
|
`scope` и `prompt=select_account`. Последний параметр нужен, чтобы при повторном входе можно было
|
||||||
|
выбрать другой аккаунт.
|
||||||
|
|
||||||
## Псевдонимы типов
|
`matchRedirect(url, &code, &error)` возвращает `true`, только если хост и путь совпадают с
|
||||||
|
`kRedirectUri`. Код или ошибка ищутся сначала в query, потом во фрагменте. В `error` попадает
|
||||||
|
`error_description`, а если его нет, то `error`. Эта проверка сделана в C++, а не в QML, чтобы она
|
||||||
|
всегда совпадала с тем адресом, который сервис сам передаёт в `redirect_uri`.
|
||||||
|
|
||||||
`MsaAuthService::Callback` — `std::function<void(const AuthResult &)>`. Колбэк вызывается ровно
|
## Цепочка
|
||||||
один раз и в потоке GUI.
|
|
||||||
|
|
||||||
## Публичные методы
|
`loginWithCode(code, callback)` меняет код на токен (`grant_type=authorization_code`).
|
||||||
|
`loginWithRefreshToken(refreshToken, callback)` делает то же по refresh-токену. Дальше путь общий:
|
||||||
|
|
||||||
#### explicit MsaAuthService(QObject \*parent = nullptr)
|
1. **Токен Microsoft.** Форма на `kTokenUrl`. Из ответа берутся `access_token` и `refresh_token`.
|
||||||
|
2. **Xbox Live.** `user.auth.xboxlive.com/user/authenticate`, `RpsTicket` = приставка + токен
|
||||||
|
Microsoft.
|
||||||
|
3. **XSTS.** `xsts.auth.xboxlive.com/xsts/authorize` для `rp://api.minecraftservices.com/`. При 401
|
||||||
|
код `XErr` переводится в понятный текст: нет профиля Xbox, страна не поддерживается, нужна
|
||||||
|
проверка возраста, детский аккаунт без разрешения взрослого. Отсюда же берутся `uhs` и `xid`
|
||||||
|
(он станет `xuid`).
|
||||||
|
4. **Minecraft Services.** `login_with_xbox` с `XBL3.0 x=<uhs>;<xsts>`. В ответе токен Minecraft и
|
||||||
|
`expires_in`, обычно сутки; от него считается `expiresAt`.
|
||||||
|
5. **Лицензия.** `entitlements/mcstore`. Результат только запоминается и сам вход не останавливает.
|
||||||
|
6. **Профиль.** `minecraft/profile`. Ответы 404 и 401 означают, что профиля нет: выставляется
|
||||||
|
`licenseMissing`. Текст ошибки зависит от шага 5. Если игра куплена, значит, игрок ещё не выбрал
|
||||||
|
ник. Если не куплена, значит, лицензии нет.
|
||||||
|
|
||||||
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
`userType` = `msa`, `clientToken` = id приложения.
|
||||||
|
|
||||||
#### static QString clientId()
|
Каждый шаг испускает `progress(message)` до отправки запроса. Таймаут на каждый запрос 20 секунд,
|
||||||
|
повторных попыток нет. Сетевая ошибка и отказ сервера дают разные тексты.
|
||||||
|
|
||||||
Идентификатор приложения лаунчера в Microsoft. Игра ждёт его в `${clientid}`: официальный лаунчер
|
Refresh-токен Microsoft живёт намного дольше суточного токена Minecraft, поэтому при обычном
|
||||||
подставляет туда именно идентификатор приложения, а не случайный токен сессии.
|
запуске окно входа не нужно: бэкенд вызывает `loginWithRefreshToken()`. Если Microsoft не вернул
|
||||||
|
новый refresh-токен, бэкенд оставляет старый.
|
||||||
|
|
||||||
#### static QUrl authorizationUrl()
|
## Колбэки
|
||||||
|
|
||||||
Адрес страницы входа для встроенного окна браузера. В адресе уже собраны идентификатор клиента,
|
Колбэк вызывается один раз в 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +1,94 @@
|
|||||||
# SeasonalBuildService
|
# SeasonalBuildService
|
||||||
|
|
||||||
## Обзор класса
|
`seasonalbuildservice.h`, `seasonalbuildservice.cpp`
|
||||||
|
|
||||||
Кроме сборок, которые пользователь собирает сам, лаунчер умеет ставить готовые сезонные сборки с
|
Каталог сезонных сборок, то есть готовых паков, которые команда публикует на своём файловом
|
||||||
собственного файлового сервера: набор модов под конкретную версию игры и модлоадер, подготовленный
|
сервере. Сервис читает `<baseUrl>/index.json`, хранит копию в
|
||||||
заранее и выдаваемый целиком.
|
`<root>/cache/seasonal_builds.json` и отвечает из неё 15 минут.
|
||||||
|
|
||||||
`SeasonalBuildService` — каталог этих сборок: скачивает `index.json` с файлового сервера, кэширует
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Он же сводит строки каталога с
|
||||||
в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устройство повторяет
|
установленными сборками и ставит выбранную.
|
||||||
[VersionManifestService](VersionManifestService.md) — включая то, что пустой разбор считается
|
|
||||||
испорченным ответом и хороший кэш им не затирается.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
## Адрес сервера
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записи каталога
|
По умолчанию `https://launcher-builds.galeonworx.com`. Переопределяется ключом `seasonalBaseUrl` в
|
||||||
используются [SeasonalPackDownloader](SeasonalPackDownloader.md) — оттуда берётся адрес архива и
|
`settings.json`, например чтобы проверить установку на локальном HTTP-сервере. Бэкенд передаёт
|
||||||
его контрольная сумма.
|
адрес через `setBaseUrl()` при старте и перед каждым `refreshSeasonalCatalog()`.
|
||||||
|
|
||||||
Путь к файлу кэша даёт `LauncherPaths::seasonalCatalogFile()` из
|
Если адрес сменился, накопленные данные сбрасываются, а кэш с другим `baseUrl` не читается: ссылки
|
||||||
[launcherpaths.h](launcherpaths.md).
|
в нём ведут на старый сервер. Пустой адрес или адрес без хоста — сразу ошибка.
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QDate`, `QDateTime`, `QHash`, `QUrl`) и `Qt6::Network`
|
## Формат index.json
|
||||||
(`QNetworkAccessManager`).
|
|
||||||
|
|
||||||
## Иерархия и роль
|
Это контракт с теми, кто публикует сборки.
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
```json
|
||||||
методов базового класса не переопределяет.
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
## Публичные структуры
|
"builds": [
|
||||||
|
{
|
||||||
### SeasonalBuildEntry
|
"id": "season-5",
|
||||||
|
"name": "Сезон 5: Пустоши",
|
||||||
Одна готовая сборка с сервера сезонных сборок.
|
"revision": 3,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
| Поле | Тип | По умолчанию | Описание |
|
"loader": "forge",
|
||||||
|------|-----|--------------|----------|
|
"loaderVersion": "47.4.0",
|
||||||
| `id` | `QString` | — | Идентификатор вида `season-5`; он же ключ, по которому сборка узнаётся среди локальных записей |
|
"modCount": 142,
|
||||||
| `name` | `QString` | — | Название для интерфейса, например «Сезон 5: Пустоши» |
|
"seasonStart": "2026-09-01",
|
||||||
| `revision` | `int` | `0` | Растёт при каждой публикации; сравнение с установленной ревизией даёт признак доступного обновления |
|
"seasonEnd": "",
|
||||||
| `minecraftVersion` | `QString` | — | Версия игры, например `1.20.1` |
|
"serverUrl": "play.example.com:25565",
|
||||||
| `loader` | `QString` | — | Ключ модлоадера: пустая строка (чистая ваниль), `forge`, `fabric`, `neoforge` или `quilt` |
|
"javaMajor": 17,
|
||||||
| `loaderVersion` | `QString` | — | Версия модлоадера |
|
"description": "…",
|
||||||
| `modCount` | `int` | `0` | Число модов в сборке; показывается колонкой в таблице |
|
"archive": {
|
||||||
| `seasonStart` | `QDate` | — | Начало сезона |
|
"url": "https://launcher-builds.galeonworx.com/season-5/r3.zip",
|
||||||
| `seasonEnd` | `QDate` | — | Конец сезона; невалидная дата означает, что сезон ещё не закончен |
|
"size": 734003200,
|
||||||
| `serverUrl` | `QString` | — | Адрес игрового сервера — не файлового, с которого качается сборка |
|
"sha256": "…64 hex…"
|
||||||
| `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<SeasonalBuildEntry> builds() const
|
|
||||||
|
|
||||||
Текущий каталог сборок. Возвращает копию.
|
|
||||||
|
|
||||||
#### bool hasData() const
|
|
||||||
|
|
||||||
Есть ли в каталоге хоть что-то — из сети или из кэша.
|
|
||||||
|
|
||||||
#### bool isRefreshing() const
|
|
||||||
|
|
||||||
Идёт ли сейчас сетевое обновление.
|
|
||||||
|
|
||||||
#### QString lastError() const
|
|
||||||
|
|
||||||
Последняя ошибка обращения к серверу; пустая строка означает, что всё в порядке.
|
|
||||||
|
|
||||||
В отличие от остальных каталогов лаунчера, ошибка здесь хранится отдельным полем: пустой список и
|
|
||||||
ошибка выглядят одинаково пустыми, и окно каталога показывает причину прямо на месте строк.
|
|
||||||
|
|
||||||
#### std::optional<SeasonalBuildEntry> find(const QString &id) const
|
|
||||||
|
|
||||||
Запись по идентификатору сборки; `std::nullopt`, если такой нет.
|
|
||||||
|
|
||||||
#### void setBaseUrl(const QUrl &baseUrl)
|
|
||||||
|
|
||||||
Задаёт адрес сервера сборок. Адрес меняется из настроек, поэтому при смене хоста накопленные
|
|
||||||
данные и кэш сбрасываются: ссылки в них указывают на старый сервер и после смены недействительны.
|
|
||||||
|
|
||||||
#### QUrl baseUrl() const
|
|
||||||
|
|
||||||
Текущий адрес сервера сборок.
|
|
||||||
|
|
||||||
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
|
||||||
|
|
||||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
|
||||||
один сетевой запрос на всех, кто успел попросить. Параметр `forceRefresh` обходит проверку
|
|
||||||
свежести — так работает кнопка обновления списка.
|
|
||||||
|
|
||||||
Окно каталога вызывает этот метод при каждом открытии без принудительного обновления: свежий кэш
|
|
||||||
отвечает без сети, поэтому вызов ничего не стоит.
|
|
||||||
|
|
||||||
## Сигналы
|
|
||||||
|
|
||||||
#### buildsChanged()
|
|
||||||
|
|
||||||
Каталог изменился — пришли новые данные или прочитан кэш. Обработчик перечитывает `builds()` и
|
|
||||||
обновляет таблицу.
|
|
||||||
|
|
||||||
#### refreshingChanged()
|
|
||||||
|
|
||||||
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки; в окне
|
|
||||||
каталога по нему же выключается кнопка обновления списка.
|
|
||||||
|
|
||||||
## Владение и время жизни
|
|
||||||
|
|
||||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
|
||||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
|
||||||
|
|
||||||
Отложенные колбэки хранятся до завершения текущего запроса; уничтожение сервиса с незавершённым
|
|
||||||
запросом обрывает его, и колбэки не вызываются.
|
|
||||||
|
|
||||||
## Потокобезопасность
|
|
||||||
|
|
||||||
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
|
||||||
|
|
||||||
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна каталога и по кнопке обновления, а
|
|
||||||
сигналы переправляет в свойства для QML. Перед отдачей в интерфейс он сводит записи каталога с
|
|
||||||
локальными: строка таблицы уже содержит готовый статус и признак доступного обновления, поэтому
|
|
||||||
[SeasonalBuildsDialog](../qml/SeasonalBuildsDialog.md) ничего не считает сам.
|
|
||||||
|
|
||||||
Установка сезонной сборки начинается с `find()`: по записи бэкенд получает адрес архива и передаёт
|
|
||||||
его [SeasonalPackDownloader](SeasonalPackDownloader.md), а скачанный пак раскатывает
|
|
||||||
[BuildSwitcher](BuildSwitcher.md).
|
|
||||||
|
|
||||||
## Внешнее взаимодействие
|
|
||||||
|
|
||||||
**Сеть, исходящие запросы.** Класс скачивает `index.json` с файлового сервера сборок, адрес
|
|
||||||
которого задаётся через `setBaseUrl()`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
|
|
||||||
|
|
||||||
Испорченный или пустой ответ не затирает хороший кэш. При недоступной сети данные отдаются из
|
|
||||||
устаревшего кэша с `ok == true` и заполненным `warning`; текст ошибки при этом попадает и в
|
|
||||||
`lastError()`.
|
|
||||||
|
|
||||||
Все сигналы и колбэки приходят в поток GUI.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
auto *seasonal = new SeasonalBuildService(this);
|
|
||||||
seasonal->setBaseUrl(QUrl(settings.seasonalServer));
|
|
||||||
connect(seasonal, &SeasonalBuildService::buildsChanged, this, &Backend::rebuildSeasonalCatalog);
|
|
||||||
|
|
||||||
seasonal->ensureLoaded([this, seasonal](bool ok, const QString &warning) {
|
|
||||||
if (!ok) {
|
|
||||||
emit seasonalCatalogError(seasonal->lastError());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
При создании этого документа использовался ИИ.
|
При создании этого документа использовался ИИ.
|
||||||
@@ -1,161 +1,31 @@
|
|||||||
# SeasonalPackDownloader
|
# SeasonalPackDownloader
|
||||||
|
|
||||||
## Обзор класса
|
`seasonalpackdownloader.h`, `seasonalpackdownloader.cpp`
|
||||||
|
|
||||||
`SeasonalPackDownloader` скачивает один архив сезонной сборки в файл. Задача узкая и отдельная по
|
Качает один архив сезонной сборки в файл и проверяет sha256. Пак весит сотни мегабайт, поэтому
|
||||||
двум причинам: пак — это сотни мегабайт, поэтому он пишется потоком, а не держится в памяти; и его
|
пишется потоком через `QSaveFile`, а не собирается в памяти. Битый архив распаковывать поверх
|
||||||
sha256 обязательно сверяется, потому что распаковывать битую загрузку поверх рабочей `.minecraft`
|
рабочей `.minecraft` нельзя, поэтому файл появляется на диске только после совпадения хеша.
|
||||||
нельзя.
|
|
||||||
|
|
||||||
Распаковкой класс не занимается — это делает [BuildSwitcher](BuildSwitcher.md) в отдельном потоке
|
Распаковывает пак [BuildSwitcher](BuildSwitcher.md). Экземпляр держит
|
||||||
вместе с остальными операциями над содержимым папки игры.
|
[LauncherBackend](LauncherBackend.md), который и вызывает загрузку на шаге Downloading установки
|
||||||
|
сезонной сборки.
|
||||||
|
|
||||||
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md) и
|
## download(url, targetPath, sha256, expectedSize, label)
|
||||||
[JavaInstaller](JavaInstaller.md): панель загрузки в интерфейсе читает их одинаково, независимо от
|
|
||||||
того, кто сейчас работает.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
- `targetPath` — `<root>/builds/<id>/.pack-r<ревизия>.zip`. Если файл уже есть, он будет
|
||||||
|
перезаписан: недокачанный пак с прошлой попытки не должен дожить до новой.
|
||||||
|
- `expectedSize` из каталога используется для прогресса, пока сервер не сообщил настоящий размер.
|
||||||
|
- Таймаут — 60 секунд без данных. Повторных попыток нет: если загрузка оборвалась, установку
|
||||||
|
нужно начать заново.
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Адрес архива, его размер
|
Если загрузка уже идёт, сразу приходит `failed`.
|
||||||
и контрольную сумму даёт запись каталога от [SeasonalBuildService](SeasonalBuildService.md).
|
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QTimer`) и `Qt6::Network`
|
Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс), `finished(path)`,
|
||||||
(`QNetworkAccessManager`, `QNetworkReply`).
|
`failed(label, message)`, `canceled(label)`. Геттеры прогресса такие же, как у других
|
||||||
|
установщиков.
|
||||||
|
|
||||||
## Иерархия и роль
|
При ошибке и несовпадении хеша файл удаляется. `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);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,210 +1,75 @@
|
|||||||
# VersionInstaller
|
# VersionInstaller
|
||||||
|
|
||||||
## Обзор класса
|
`versioninstaller.h`, `versioninstaller.cpp`
|
||||||
|
|
||||||
Установить версию Minecraft — значит положить в `.minecraft` пять групп файлов: описание версии,
|
Ставит версию игры в `.minecraft`: описание версии, клиентский jar, библиотеки, индекс ресурсов,
|
||||||
клиентский jar, библиотеки, индекс ресурсов и сами ресурсы. Последних — десятки тысяч мелких
|
сами ресурсы и конфиг логирования. Одновременно ставится одна версия, остальные ждут в очереди.
|
||||||
файлов.
|
Всё работает в GUI-потоке на асинхронной сети.
|
||||||
|
|
||||||
`VersionInstaller` делает это фоном, не блокируя интерфейс: складывает всё нужное в очередь
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Ещё им пользуется
|
||||||
загрузок, качает несколько файлов параллельно, пишет их потоком на диск и по ходу сообщает
|
[ModLoaderInstaller](ModLoaderInstaller.md): перед установкой модлоадера нужно поставить базовую
|
||||||
прогресс. Одна версия ставится за раз, остальные ждут в очереди.
|
версию, а после установки докачать библиотеки нового профиля.
|
||||||
|
|
||||||
Класс также разворачивает цепочку наследования: если у версии есть `inheritsFrom`, родительская
|
## Интерфейс
|
||||||
версия ставится перед ней.
|
|
||||||
|
- `install(gameDir, versionId)` — поставить в очередь. Если эта версия уже ставится или ждёт,
|
||||||
## Место в проекте и зависимости
|
ничего не происходит. Если id содержит слэши, `.` или `..`, сразу приходит `failed`.
|
||||||
|
- `cancel()` — отменить текущую установку и очистить очередь.
|
||||||
Подключает [minecraftversion.h](minecraftversion.md) — по разобранной версии он и понимает, что
|
- `isRunning()`, `isQueued(id)`, `versionId()`.
|
||||||
качать. В конструктор принимает [VersionManifestService](VersionManifestService.md): оттуда
|
- Для панели прогресса: `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()`, `fraction()`
|
||||||
берётся адрес описания версии.
|
(от 0 до 1; −1, если общий объём неизвестен).
|
||||||
|
|
||||||
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). На него же опирается
|
Сигналы: `started(id)`, `progressChanged()` (не чаще раза в 100 мс), `finished(id)`,
|
||||||
[ModLoaderInstaller](ModLoaderInstaller.md) — профиль модлоадера ставится поверх установленной
|
`failed(id, message)`, `canceled(id)`.
|
||||||
версии игры.
|
|
||||||
|
`started` испускается один раз на всю серию установок из очереди. `finished` испускается для
|
||||||
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QQueue`, `QTimer`) и
|
каждой версии.
|
||||||
`Qt6::Network` (`QNetworkAccessManager`, `QNetworkReply`).
|
|
||||||
|
## Как идёт установка
|
||||||
## Иерархия и роль
|
|
||||||
|
1. **Описание версии.** У [VersionManifestService](VersionManifestService.md) берётся ссылка на
|
||||||
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
`<id>.json`, файл скачивается, sha1 сверяется с манифестом, и файл сохраняется в
|
||||||
виртуальный деструктор — класс владеет незавершёнными загрузками и обязан их закрыть.
|
`versions/<id>/`. Если версии в манифесте нет (профиль модлоадера) или сеть недоступна, берётся
|
||||||
|
файл, который уже лежит на диске. Если нет и его, установка падает.
|
||||||
## Публичные структуры
|
2. **Родитель.** Если в описании есть `inheritsFrom`, а родителя на диске нет, текущая версия
|
||||||
|
уступает место: в начало очереди встают сначала родитель, потом она сама. Глубина цепочки — до
|
||||||
### DownloadTask
|
10 звеньев.
|
||||||
|
3. **Список файлов.** `VersionLoader::load()` сводит цепочку, и из результата составляется список:
|
||||||
Один файл, который нужно положить в `.minecraft`.
|
клиентский jar, все библиотеки, индекс ресурсов и конфиг логов. Ссылки не по `https` и
|
||||||
|
библиотеки без адреса пропускаются с предупреждением.
|
||||||
| Поле | Тип | По умолчанию | Описание |
|
4. **Загрузка.** До 8 файлов одновременно. Каждый пишется потоком в `QSaveFile`, sha1 считается на
|
||||||
|------|-----|--------------|----------|
|
ходу. При временной ошибке (таймаут, обрыв, HTTP 429 или 5xx) и при несовпадении хеша файл
|
||||||
| `kind` | `DownloadTask::Kind` | `ClientJar` | Что это за файл |
|
ставится обратно в начало очереди с паузой 400 мс, потом 800 мс. После третьей неудачи
|
||||||
| `url` | `QUrl` | — | Откуда качать |
|
установка падает. Другие ошибки сразу её прерывают. Таймаут на файл 30 секунд.
|
||||||
| `path` | `QString` | — | Абсолютный путь назначения |
|
5. **Ресурсы.** Как только индекс ресурсов на диске, он разворачивается в задачи на загрузку из
|
||||||
| `sha1` | `QString` | — | Контрольная сумма; пустая означает «не проверять» |
|
`resources.download.minecraft.net/<xx>/<hash>`. До этого момента в общем объёме учитывается
|
||||||
| `size` | `qint64` | `0` | Ожидаемый размер; `0` — неизвестен |
|
`totalSize` из описания версии, а потом он заменяется точной суммой.
|
||||||
| `label` | `QString` | — | Что показать в панели прогресса |
|
6. **Старые ресурсы.** У версий до 1.6 (`virtual`, `map_to_resources`, `assets` = `legacy` или
|
||||||
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
|
`pre-1.6`) игра ищет ресурсы не в `assets/objects`, а в обычной папке с именами файлов. Они
|
||||||
|
копируются в `assets/virtual/legacy/` или в `resources/` по 64 файла за такт таймера, чтобы
|
||||||
#### Перечисление DownloadTask::Kind
|
окно не подвисало.
|
||||||
|
7. **Проверка.** `GameLauncher::missingFiles()`. Если чего-то нет, это ошибка.
|
||||||
| Значение | Описание |
|
|
||||||
|----------|----------|
|
## Уже скачанные файлы
|
||||||
| `ClientJar` | Клиентский jar версии |
|
|
||||||
| `Library` | Библиотека из `libraries/` |
|
Существующий файл считается целым, если его размер совпадает с ожидаемым (или размер неизвестен).
|
||||||
| `AssetIndex` | Индекс ресурсов |
|
Хеш не пересчитывается: перехеширование сотен мегабайт ресурсов при каждой установке обходится
|
||||||
| `AssetObject` | Один файл ресурсов |
|
дороже, чем редкий битый файл. Для будущей кнопки «Проверить файлы» есть флаг `m_verifyExisting`,
|
||||||
| `LoggingConfig` | Конфигурация журналирования log4j |
|
сейчас он всегда `false`.
|
||||||
|
|
||||||
### ActiveDownload
|
Пропуск файлов идёт пачками по 256 за такт. Иначе установка версии, у которой уже есть все
|
||||||
|
ресурсы, повесила бы окно на перебор десятков тысяч записей.
|
||||||
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
|
|
||||||
контрольная сумма и число принятых байт. Файлы пишутся потоком — держать десятки мегабайт в
|
## Ошибки и отмена
|
||||||
памяти незачем, а нескольких параллельных загрузок хватило бы на сотни.
|
|
||||||
|
При ошибке все активные загрузки обрываются, недописанные файлы откатываются (`QSaveFile`), а
|
||||||
## Публичные методы
|
**очередь очищается**: остальные версии из неё тоже не поставятся. К тексту ошибки добавляются до
|
||||||
|
трёх накопленных предупреждений.
|
||||||
#### explicit VersionInstaller(VersionManifestService \*manifest, QObject \*parent = nullptr)
|
|
||||||
|
Отмена обрывает активные запросы и ждёт, пока все они вернутся. Только после этого приходит
|
||||||
Создаёт установщик поверх сервиса манифеста. Сервис не переходит во владение установщика и обязан
|
`canceled`. Пока отмена не закончилась, `install()` отвечает отказом.
|
||||||
пережить его. Конструктор помечен `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);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,167 +1,67 @@
|
|||||||
# VersionManifestService
|
# VersionManifestService
|
||||||
|
|
||||||
## Обзор класса
|
`versionmanifestservice.h`, `versionmanifestservice.cpp`
|
||||||
|
|
||||||
Каталог версий Minecraft — это манифест Mojang: около тысячи записей от альф 2010 года до
|
Каталог версий Mojang. Скачивает `version_manifest_v2.json`, хранит его копию в папке лаунчера и
|
||||||
свежайших снапшотов. `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` | |
|
||||||
|
|
||||||
Путь к файлу кэша даёт `LauncherPaths::versionManifestFile()` из
|
Записи без `id` или с адресом не по `https` отбрасываются. Список отсортирован по `releaseTime`,
|
||||||
[launcherpaths.h](launcherpaths.md).
|
новые сверху. При одинаковом времени сортировка идёт по `id`.
|
||||||
|
|
||||||
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QList`, `QUrl`) и `Qt6::Network`
|
## ensureLoaded(callback, forceRefresh = false)
|
||||||
(`QNetworkAccessManager`).
|
|
||||||
|
|
||||||
## Иерархия и роль
|
Через этот метод сервис получает данные; других способов нет.
|
||||||
|
|
||||||
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
1. При первом вызове читается кэш `<root>/cache/version_manifest.json`. Если он разобрался,
|
||||||
методов базового класса не переопределяет.
|
сразу испускается `versionsChanged`, и окно показывает список ещё до ответа сети.
|
||||||
|
2. Если данные моложе 6 часов и `forceRefresh` не задан, колбэк вызывается через
|
||||||
|
`QTimer::singleShot(0)` с `ok = true`. Сеть не трогается.
|
||||||
|
3. Иначе колбэк встаёт в очередь. Если запрос уже идёт, второй не отправляется.
|
||||||
|
|
||||||
## Публичные структуры
|
Ответ сети разбирается, кэш перезаписывается через `QSaveFile`, испускается `versionsChanged`, и
|
||||||
|
все колбэки из очереди получают результат.
|
||||||
|
|
||||||
### RemoteVersionEntry
|
Если сеть не ответила, но старые данные есть, колбэк получит `ok = true` и непустой `warning`:
|
||||||
|
работать можно, просто список, возможно, неполный. Если данных нет совсем, придёт `ok = false`.
|
||||||
|
Бэкенд показывает `warning` пользователю только во втором случае.
|
||||||
|
|
||||||
Одна строка манифеста Mojang.
|
Таймаут запроса 20 секунд.
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
## Остальное
|
||||||
|------|-----|----------|
|
|
||||||
| `id` | `QString` | Идентификатор версии: `1.21.8`, `25w33a`, `b1.7.3` |
|
|
||||||
| `type` | `QString` | Категория: `release`, `snapshot`, `old_beta` или `old_alpha` |
|
|
||||||
| `url` | `QUrl` | Адрес `<id>.json` с описанием версии |
|
|
||||||
| `sha1` | `QString` | Контрольная сумма самого описания |
|
|
||||||
| `releaseTime` | `QDateTime` | Дата выпуска; по ней список сортируется новыми вперёд |
|
|
||||||
|
|
||||||
Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не
|
- `versions()` — весь список.
|
||||||
попавшие ни в одну из четырёх, интерфейс относит к категории «прочие».
|
- `find(id)` — запись по id (через хеш-таблицу, а не перебором).
|
||||||
|
- `hasData()`, `isRefreshing()`, `fetchedAt()`.
|
||||||
|
- `refreshingChanged` испускается в начале и в конце запроса. Бэкенд превращает его в
|
||||||
|
`catalogLoading`.
|
||||||
|
|
||||||
## Псевдонимы типов
|
## Формат кэша
|
||||||
|
|
||||||
`VersionManifestService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
```json
|
||||||
Сочетание `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<RemoteVersionEntry> versions() const
|
|
||||||
|
|
||||||
Текущий список версий. Возвращает копию; пустой список означает, что данных ещё нет.
|
|
||||||
|
|
||||||
#### bool hasData() const
|
|
||||||
|
|
||||||
Есть ли хоть какие-то данные — из сети или из кэша.
|
|
||||||
|
|
||||||
#### bool isRefreshing() const
|
|
||||||
|
|
||||||
Идёт ли сейчас сетевое обновление. Интерфейс показывает по этому признаку строку загрузки вместо
|
|
||||||
пустого списка.
|
|
||||||
|
|
||||||
#### QDateTime fetchedAt() const
|
|
||||||
|
|
||||||
Когда данные были получены. По этой отметке решается, устарел ли кэш.
|
|
||||||
|
|
||||||
#### std::optional<RemoteVersionEntry> find(const QString &id) const
|
|
||||||
|
|
||||||
Запись по идентификатору версии; `std::nullopt`, если такой версии в манифесте нет. Из неё
|
|
||||||
установщик берёт ссылку на описание версии. Поиск идёт по внутреннему указателю, а не перебором.
|
|
||||||
|
|
||||||
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
|
||||||
|
|
||||||
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без обращения к сети; иначе
|
|
||||||
запускается один сетевой запрос на всех, кто успел попросить, — колбэки накапливаются и вызываются
|
|
||||||
все разом по его завершении.
|
|
||||||
|
|
||||||
Параметр `forceRefresh` обходит проверку свежести кэша: так работает кнопка принудительного
|
|
||||||
обновления.
|
|
||||||
|
|
||||||
Колбэк вызывается ровно один раз и всегда в потоке GUI, в том числе когда данные уже есть.
|
|
||||||
|
|
||||||
## Сигналы
|
|
||||||
|
|
||||||
#### versionsChanged()
|
|
||||||
|
|
||||||
Список версий изменился — пришли новые данные из сети или прочитан кэш.
|
|
||||||
|
|
||||||
Обработчик перечитывает `versions()` и обновляет интерфейс. В лаунчере на этот сигнал завязано
|
|
||||||
свойство каталога версий, которое читает окно выбора.
|
|
||||||
|
|
||||||
#### refreshingChanged()
|
|
||||||
|
|
||||||
Изменился признак сетевого обновления. Обработчик показывает или убирает индикатор загрузки.
|
|
||||||
|
|
||||||
## Владение и время жизни
|
|
||||||
|
|
||||||
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
|
||||||
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
|
||||||
|
|
||||||
Отложенные колбэки хранятся в списке до завершения текущего запроса. Уничтожение сервиса с
|
|
||||||
незавершённым запросом обрывает его вместе с менеджером сети, и накопленные колбэки не
|
|
||||||
вызываются.
|
|
||||||
|
|
||||||
Ссылку на сервис держит установщик версий; уничтожать сервис раньше установщика нельзя.
|
|
||||||
|
|
||||||
## Потокобезопасность
|
|
||||||
|
|
||||||
Только поток GUI — так же, как [AuthService](AuthService.md). Чтение и запись кэша выполняются в
|
|
||||||
вызывающем потоке, поэтому первое обращение к манифесту делает короткую файловую операцию
|
|
||||||
синхронно.
|
|
||||||
|
|
||||||
## Взаимодействие с другими классами
|
|
||||||
|
|
||||||
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора версии и по кнопке
|
|
||||||
обновления, а сигналы `versionsChanged` и `refreshingChanged` переправляет в свойства, которые
|
|
||||||
читает QML. Список из `versions()` он сводит с установленными версиями и отдаёт в интерфейс уже
|
|
||||||
готовыми строками.
|
|
||||||
|
|
||||||
[VersionInstaller](VersionInstaller.md) обращается к `find()`, чтобы получить адрес описания
|
|
||||||
версии перед началом загрузки.
|
|
||||||
|
|
||||||
## Внешнее взаимодействие
|
|
||||||
|
|
||||||
**Сеть, исходящие запросы.** Класс скачивает манифест версий Mojang через
|
|
||||||
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
|
|
||||||
|
|
||||||
Стратегия при недоступной сети встроена в контракт колбэка: если есть устаревший кэш, он
|
|
||||||
отдаётся с `ok == true` и заполненным `warning`, и интерфейс показывает список вместо ошибки.
|
|
||||||
Полное отсутствие данных даёт `ok == false`.
|
|
||||||
|
|
||||||
**Файловый кэш.** Манифест сохраняется в файл, путь к которому даёт
|
|
||||||
`LauncherPaths::versionManifestFile()`, вместе с отметкой времени получения.
|
|
||||||
|
|
||||||
Сигналы и колбэки приходят в поток GUI.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
auto *manifest = new VersionManifestService(this);
|
|
||||||
connect(manifest, &VersionManifestService::versionsChanged, this, &Backend::rebuildCatalog);
|
|
||||||
|
|
||||||
manifest->ensureLoaded([this, manifest](bool ok, const QString &warning) {
|
|
||||||
if (!ok) {
|
|
||||||
emit catalogError(warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!warning.isEmpty())
|
|
||||||
showStatus(warning); // список из устаревшего кэша
|
|
||||||
|
|
||||||
const auto entry = manifest->find(QStringLiteral("1.21.8"));
|
|
||||||
if (entry)
|
|
||||||
startDownload(entry->url);
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Хранится только то, что нужно лаунчеру, а не весь манифест Mojang.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
При создании этого документа использовался ИИ.
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -1,76 +1,59 @@
|
|||||||
# javalocator.h — JavaLocator
|
# JavaLocator
|
||||||
|
|
||||||
## Обзор
|
`javalocator.h`, `javalocator.cpp`
|
||||||
|
|
||||||
Для запуска Minecraft нужна Java подходящей версии: старым версиям игры — восьмая, новым —
|
Поиск java, уже установленной на машине, и выбор подходящей под версию игры. Скачиванием
|
||||||
семнадцатая или двадцать первая. Лаунчер умеет скачивать сборки Java сам (см.
|
занимается [JavaInstaller](JavaInstaller.md), а здесь только смотрим, что есть.
|
||||||
[JavaInstaller](JavaInstaller.md)), но сначала стоит посмотреть, что уже есть на машине.
|
|
||||||
|
|
||||||
`JavaLocator` отвечает за поиск: собирает все доступные java, определяет их версии и выбирает
|
Это пространство имён со свободными функциями. Его используют [GameLauncher](GameLauncher.md) при
|
||||||
подходящую под требование версии игры. Пространство имён используется при подготовке запуска и при
|
запуске, [LauncherBackend](LauncherBackend.md) для списка в настройках и для шага Java в установке
|
||||||
показе списка найденных Java в настройках.
|
сезонной сборки.
|
||||||
|
|
||||||
## Пространства имён
|
## findAll(gameDir)
|
||||||
|
|
||||||
`JavaLocator` группирует четыре функции поиска и выбора. Состояния нет, но результат определения
|
Список путей к исполняемому `java` (`java.exe` на Windows) без повторов. Порядок такой:
|
||||||
версии кэшируется внутри реализации.
|
|
||||||
|
|
||||||
## Функции
|
1. сборки, скачанные лаунчером в `<root>/java` (через `JavaRuntimeStore::installed()`);
|
||||||
|
2. всё, что найдётся рекурсивно в `<gameDir>/runtime`: туда кладёт Java официальный лаунчер;
|
||||||
|
3. `$JAVA_HOME/bin`;
|
||||||
|
4. `java` из `PATH`;
|
||||||
|
5. стандартные каталоги установки. На Windows это `Program Files`, `Program Files (x86)` и
|
||||||
|
`ProgramW6432` с подпапками `Java`, `Eclipse Adoptium`, `Eclipse Foundation`, `AdoptOpenJDK`,
|
||||||
|
`Amazon Corretto`, `Zulu`, `BellSoft`, `Microsoft`, `Semeru` и JBR из Android Studio. На macOS
|
||||||
|
это `/Library/Java/JavaVirtualMachines` и такая же папка в домашнем каталоге. На Linux —
|
||||||
|
`/usr/lib/jvm`, `/usr/java`, `/opt/java`, `/opt/jdk`.
|
||||||
|
|
||||||
#### QStringList findAll(const QString &gameDir)
|
В каждом каталоге установки проверяются `bin`, `Contents/Home/bin` (так устроены JDK на macOS) и
|
||||||
|
`jre/bin`.
|
||||||
|
|
||||||
Все java, которые удалось найти, без дублей и в порядке приоритета. Просматриваются четыре
|
## majorVersion(javaPath)
|
||||||
источника: рантайм самого Minecraft внутри папки игры, переменная `JAVA_HOME`, переменная `PATH` и
|
|
||||||
стандартные каталоги установки JDK и JRE.
|
|
||||||
|
|
||||||
Параметр `gameDir` — папка игры; из неё берётся первый источник.
|
Запускает `java -version` и достаёт мажорную версию из строки `version "..."`. Старая схема
|
||||||
|
`1.8.0_x` даёт 8. Если процесс не ответил за 5 секунд или вывод не разобрался, возвращается 0.
|
||||||
|
|
||||||
Результат показывается в диалоге настроек списком «что нашлось в системе».
|
Результат кэшируется в статической таблице на всё время работы лаунчера. Если java по тому же пути
|
||||||
|
обновить, лаунчер узнает об этом только после перезапуска. Кэш не защищён от гонок, поэтому
|
||||||
|
функцию можно вызывать только из одного потока, и в проекте это GUI-поток.
|
||||||
|
|
||||||
#### int majorVersion(const QString &javaPath)
|
## select(gameDir, requiredMajor, preferred, &error)
|
||||||
|
|
||||||
Мажорная версия java по её пути: 8, 17, 21 и так далее. Возвращает `0`, если запустить
|
Выбирает java для запуска.
|
||||||
исполняемый файл не удалось — путь неверен, файл не исполняемый или это не java.
|
|
||||||
|
|
||||||
Результат кэшируется: определение версии требует запуска процесса, а один и тот же путь
|
- Если `preferred` задан, он идёт первым кандидатом. Это может быть путь к файлу или к домашней
|
||||||
проверяется многократно.
|
папке JDK (тогда берётся `bin/java`). Если по этому пути ничего нет, функция сразу возвращает
|
||||||
|
ошибку и других кандидатов не смотрит: пользователь явно указал путь, и молча подменять его
|
||||||
|
было бы неправильно.
|
||||||
|
- Дальше добавляется всё из `findAll()`.
|
||||||
|
- Возвращается первый кандидат с версией не ниже `requiredMajor`. Если таких нет, в `error`
|
||||||
|
пишется, какая самая новая java нашлась и где.
|
||||||
|
|
||||||
#### QString windowlessVariant(const QString &javaPath)
|
Функция возвращает первую подходящую, а не самую новую. Поэтому скачанная лаунчером Java 17
|
||||||
|
выиграет у системной Java 21, если игре нужна 17.
|
||||||
|
|
||||||
Заменяет `java.exe` на `javaw.exe`, чтобы игра не открывала окно консоли. На Unix возвращает вход
|
## windowlessVariant(javaPath)
|
||||||
без изменений — там разницы нет.
|
|
||||||
|
|
||||||
#### QString select(const QString &gameDir, int requiredMajor, const QString &preferred, QString \*error)
|
На Windows заменяет `java.exe` на `javaw.exe` из той же папки, если он там есть. Тогда при запуске
|
||||||
|
игры не появляется консольное окно. На других системах возвращает путь без изменений.
|
||||||
Выбирает 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);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,153 +1,94 @@
|
|||||||
# javaruntime.h — JavaRuntime, JavaRuntimeStore и JavaRequirement
|
# javaruntime.h
|
||||||
|
|
||||||
## Обзор
|
`javaruntime.h`, `javaruntime.cpp`
|
||||||
|
|
||||||
`Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру
|
Общие типы для Java: виды сборок, строка каталога, запись об установленной сборке, работа с папкой
|
||||||
запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки
|
`<root>/java` и таблица «какая Java нужна какой версии игры».
|
||||||
живут в папке лаунчера, по одной подпапке на сборку.
|
|
||||||
|
|
||||||
Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две
|
## Виды сборок
|
||||||
структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой
|
|
||||||
`<root>/java` и таблицу требований версий игры к Java.
|
|
||||||
|
|
||||||
Заголовок подключают [JavaRuntimeService](JavaRuntimeService.md) (каталог),
|
| `JavaRuntimeKind` | Ключ | Что это |
|
||||||
[JavaInstaller](JavaInstaller.md) (скачивание и распаковка) и
|
|-------------------|------|---------|
|
||||||
[LauncherBackend](LauncherBackend.md) (отдача каталога в QML).
|
| `Mojang` | `java` | сборки, которые Mojang раздаёт для своего лаунчера |
|
||||||
|
| `Jdk` | `jdk` | Eclipse Temurin JDK |
|
||||||
|
| `Jre` | `jre` | Eclipse Temurin JRE |
|
||||||
|
|
||||||
## Типы
|
`javaKindKey()`, `javaKindFromKey()`, `javaKindTitle()` переводят между перечислением, ключом и
|
||||||
|
подписью для интерфейса. Ключ хранится в `runtime.json` установленной сборки и используется в QML.
|
||||||
|
|
||||||
| Имя | Вид | Описание |
|
## JavaRuntimeEntry
|
||||||
|-----|-----|----------|
|
|
||||||
| `JavaRuntimeKind` | `enum class` | Откуда взялась сборка и что именно в ней лежит |
|
|
||||||
| `JavaRuntimeEntry` | `struct` | Строка каталога — то, что можно скачать |
|
|
||||||
| `InstalledJavaRuntime` | `struct` | Сборка, уже распакованная в папке лаунчера |
|
|
||||||
|
|
||||||
### JavaRuntimeKind
|
Строка каталога [JavaRuntimeService](JavaRuntimeService.md), то есть то, что можно скачать.
|
||||||
|
|
||||||
| Значение | Ключ | Описание |
|
| Поле | Что это |
|
||||||
|----------|------|----------|
|
|------|---------|
|
||||||
| `Mojang` | `java` | Тот же рантайм, которым игру запускает официальный лаунчер |
|
| `id` | `temurin-jdk-21.0.12.1_1`, `mojang-java-runtime-delta` |
|
||||||
| `Jdk` | `jdk` | Eclipse Temurin JDK: компилятор и инструменты в комплекте |
|
| `kind` | |
|
||||||
| `Jre` | `jre` | Eclipse Temurin JRE: только то, что нужно для запуска |
|
| `major` | 8, 17, 21… |
|
||||||
|
| `version` | `21.0.12.1+1`, `21.0.7` |
|
||||||
|
| `component` | только у Mojang: `java-runtime-delta` и т. п. |
|
||||||
|
| `released` | |
|
||||||
|
| `url` | архив Temurin или `manifest.json` компонента Mojang |
|
||||||
|
| `checksum` | sha256 архива Temurin или sha1 манифеста Mojang |
|
||||||
|
| `size` | размер загрузки, 0 если неизвестен |
|
||||||
|
| `archive` | `zip`, `tar.gz` или `mojang` |
|
||||||
|
| `architecture` | `x64`, `aarch64`; на macOS может быть не родной (см. JavaRuntimeService) |
|
||||||
|
| `lts` | |
|
||||||
|
|
||||||
Эти же ключи служат именами категорий в окне выбора Java.
|
## InstalledJavaRuntime
|
||||||
|
|
||||||
### JavaRuntimeEntry
|
Скачанная сборка: `id`, `kind` (ключ), `major`, `version`, `javaPath` (абсолютный путь к
|
||||||
|
`java`/`java.exe`), `size` (сколько заняла на диске).
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
## 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` | Версия с длительной поддержкой; помечается в списке |
|
|
||||||
|
|
||||||
Метод `isValid()` возвращает `true`, когда заполнен `id`, `major` больше нуля и `url` корректен.
|
Каждая сборка лежит в `<root>/java/<id>/`, а рядом с ней `runtime.json`:
|
||||||
|
|
||||||
### InstalledJavaRuntime
|
```json
|
||||||
|
{ "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<JavaRuntimeKind> javaKindFromKey(const QString &key)
|
|
||||||
|
|
||||||
Обратный перевод; `std::nullopt` для неизвестного ключа.
|
|
||||||
|
|
||||||
#### QString javaKindTitle(JavaRuntimeKind kind)
|
|
||||||
|
|
||||||
Человекочитаемое название вида сборки для интерфейса.
|
|
||||||
|
|
||||||
### JavaRuntimeStore — папка `<root>/java`
|
|
||||||
|
|
||||||
Устройство папки: одна подпапка на сборку плюс её описание внутри. Отдельного индекса нет
|
|
||||||
намеренно — удалённую вручную папку не пришлось бы вычищать ещё и из общего файла.
|
|
||||||
|
|
||||||
#### QString dirFor(const QString &id)
|
|
||||||
|
|
||||||
Папка конкретной сборки внутри `<root>/java`.
|
|
||||||
|
|
||||||
#### QList<InstalledJavaRuntime> installed()
|
|
||||||
|
|
||||||
Всё, что лежит в `<root>/java` и на что нашлась java. Новые версии идут первыми.
|
|
||||||
|
|
||||||
#### std::optional<InstalledJavaRuntime> find(const QString &id)
|
|
||||||
|
|
||||||
Описание одной установленной сборки; `std::nullopt`, если такой нет.
|
|
||||||
|
|
||||||
#### QString locateBinary(const QString &rootDir)
|
|
||||||
|
|
||||||
Ищет `bin/java` в распакованном дереве. Раскладка отличается между поставщиками: у Temurin на macOS
|
|
||||||
это `Contents/Home/bin`, у Mojang — `jre.bundle/Contents/Home/bin`.
|
|
||||||
|
|
||||||
#### bool writeMeta(const InstalledJavaRuntime &runtime, QString \*error)
|
|
||||||
|
|
||||||
Записывает описание сборки в её папку. Вызывается по завершении установки. При неудаче возвращает
|
|
||||||
`false` и заполняет `error`.
|
|
||||||
|
|
||||||
#### bool remove(const QString &id, QString \*error)
|
|
||||||
|
|
||||||
Удаляет папку сборки целиком. При неудаче возвращает `false` и заполняет `error`.
|
|
||||||
|
|
||||||
#### QString sanitizeId(const QString &id)
|
|
||||||
|
|
||||||
Превращает идентификатор каталога в безопасное имя папки: всё, кроме букв, цифр, точки, дефиса и
|
|
||||||
подчёркивания, заменяется. Идентификатор приходит из сети, поэтому подставлять его в путь как есть
|
|
||||||
нельзя.
|
|
||||||
|
|
||||||
### JavaRequirement — какая Java нужна какой версии игры
|
|
||||||
|
|
||||||
Точный ответ лежит в `client.json` версии (поле `javaVersion.majorVersion`) и берётся оттуда при
|
|
||||||
запуске. Эта таблица нужна раньше — когда версия ещё не скачана, а подсказку в каталоге показать
|
|
||||||
надо.
|
|
||||||
|
|
||||||
#### int minimumFor(const QString &minecraftVersionId)
|
|
||||||
|
|
||||||
Минимальная мажорная версия Java для версии игры; `0`, если разобрать идентификатор версии не
|
|
||||||
удалось.
|
|
||||||
|
|
||||||
#### QString coverage(int javaMajor)
|
|
||||||
|
|
||||||
Подпись к строке каталога вида «Minecraft 1.20.5 и новее» — какие версии игры покрывает эта версия
|
|
||||||
Java.
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
Подключает `QDateTime`, `QList`, `QString`, `QUrl` и `<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 потянет.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
При создании этого документа использовался ИИ.
|
При создании этого документа использовался ИИ.
|
||||||
@@ -1,144 +1,77 @@
|
|||||||
# launcherpaths.h — LauncherPaths
|
# launcherpaths.h
|
||||||
|
|
||||||
## Обзор
|
`launcherpaths.h`, `launcherpaths.cpp`
|
||||||
|
|
||||||
`Minecraft_launcher` хранит собственные данные отдельно от папки игры: настройки, профили, описания
|
Все пути к данным лаунчера. Пространство имён `LauncherPaths` из свободных функций, состояния нет:
|
||||||
сборок, архивы содержимого `.minecraft`, скачанные сборки Java и кэши каталогов. Пространство имён
|
каждая функция просто собирает строку.
|
||||||
`LauncherPaths` — единственное место, где эти пути вычисляются.
|
|
||||||
|
|
||||||
Собственная папка `BiLauncher` лежит рядом со стандартной `.minecraft`, а не в системном
|
Данные лаунчера лежат в папке `BiLauncher` рядом со стандартной папкой игры, а не в системном
|
||||||
каталоге данных приложения: так все файлы лаунчера остаются там же, где сама игра, и переносятся
|
каталоге данных приложения. `QStandardPaths` не используется, поэтому имя приложения и организации
|
||||||
вместе с ней.
|
из `main.cpp` на эти пути не влияют.
|
||||||
|
|
||||||
К этому заголовку обращается почти каждый сервис проекта — везде, где нужно прочитать или записать
|
## Где что лежит
|
||||||
файл в папке лаунчера.
|
|
||||||
|
|
||||||
## Пространства имён
|
`containerDir()` — папка, в которой живут и игра, и лаунчер:
|
||||||
|
|
||||||
`LauncherPaths` группирует функции, возвращающие абсолютные пути, и одну функцию создания корневой
|
| ОС | `containerDir()` | папка игры по умолчанию | `rootDir()` |
|
||||||
папки. Состояния у пространства имён нет: все функции вычисляют путь заново при каждом вызове.
|
|----|------------------|-------------------------|-------------|
|
||||||
|
| Windows | `%USERPROFILE%/AppData/Roaming` | `.minecraft` | `BiLauncher` |
|
||||||
|
| macOS | `~/Library/Application Support` | `minecraft` (без точки, как у официального лаунчера) | `BiLauncher` |
|
||||||
|
| Linux | `~` | `.minecraft` | `BiLauncher` |
|
||||||
|
|
||||||
## Функции
|
На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая.
|
||||||
|
|
||||||
### Корневые каталоги
|
## BILAUNCHER_HOME
|
||||||
|
|
||||||
#### QString containerDir()
|
`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он
|
||||||
|
заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая
|
||||||
|
папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt
|
||||||
|
(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит.
|
||||||
|
|
||||||
Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `BiLauncher`. От неё
|
Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль
|
||||||
отсчитываются и папка игры по умолчанию, и корень данных лаунчера.
|
у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего
|
||||||
|
`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на
|
||||||
|
Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp`
|
||||||
|
при старте пишет в лог предупреждение с итоговой папкой лаунчера.
|
||||||
|
|
||||||
#### QString rootDir()
|
Внутри `rootDir()`:
|
||||||
|
|
||||||
Корень данных лаунчера — `<containerDir>/BiLauncher`.
|
|
||||||
|
|
||||||
#### QString defaultMinecraftDir()
|
|
||||||
|
|
||||||
Стандартная папка игры. Используется, когда пользователь не задал свою в настройках.
|
|
||||||
|
|
||||||
#### bool ensureRootExists(QString *error = nullptr)
|
|
||||||
|
|
||||||
Создаёт папку лаунчера, если её ещё нет. Вызывается при каждом запуске и перед каждой записью.
|
|
||||||
|
|
||||||
Возвращает `false` и заполняет `error`, если папку не удалось создать или в неё не пишется. Все
|
|
||||||
остальные функции пространства имён только считают строки и в этом смысле не могут завершиться
|
|
||||||
неудачей — проверять доступность каталога нужно этой функцией.
|
|
||||||
|
|
||||||
### Файлы состояния
|
|
||||||
|
|
||||||
#### QString settingsFile()
|
|
||||||
|
|
||||||
Файл настроек запуска: папка игры, путь к Java, память, аргументы JVM, размер окна.
|
|
||||||
|
|
||||||
#### QString profilesFile()
|
|
||||||
|
|
||||||
Файл профилей игрока.
|
|
||||||
|
|
||||||
#### QString customBuildsFile()
|
|
||||||
|
|
||||||
`<root>/customBuilds.json` — пользовательские сборки.
|
|
||||||
|
|
||||||
#### QString legacyCustomBuildsFile()
|
|
||||||
|
|
||||||
`<root>/versions.json` — как сборки назывались до переименования. Читается один раз при миграции и
|
|
||||||
больше ни для чего не нужен.
|
|
||||||
|
|
||||||
### Сборки и их архивы
|
|
||||||
|
|
||||||
#### QString buildStorageDir()
|
|
||||||
|
|
||||||
`<root>/builds` — архивы содержимого `.minecraft`, по одному на сборку.
|
|
||||||
|
|
||||||
#### QString buildDir(int buildId)
|
|
||||||
|
|
||||||
`<root>/builds/<id>` — папка одной сборки: её архив и, у сезонных, скачанный пак с описанием
|
|
||||||
установленной ревизии.
|
|
||||||
|
|
||||||
#### QString seasonalStateFile(int buildId)
|
|
||||||
|
|
||||||
`<root>/builds/<id>/season.json` — какая ревизия сезонной сборки установлена и какие файлы она
|
|
||||||
принесла. Список файлов нужен, чтобы при обновлении убрать те, что из сборки ушли.
|
|
||||||
|
|
||||||
### Java
|
|
||||||
|
|
||||||
#### QString javaDir()
|
|
||||||
|
|
||||||
`<root>/java` — сборки Java, скачанные лаунчером. Каждая в своей подпапке, имя подпапки —
|
|
||||||
идентификатор сборки из каталога.
|
|
||||||
|
|
||||||
#### QString javaCatalogFile()
|
|
||||||
|
|
||||||
Слепок каталога доступных сборок Java с отметкой времени.
|
|
||||||
|
|
||||||
#### QString javaDownloadDir()
|
|
||||||
|
|
||||||
Каталог, куда качаются архивы Temurin до распаковки.
|
|
||||||
|
|
||||||
### Кэши и загрузки
|
|
||||||
|
|
||||||
#### QString cacheDir()
|
|
||||||
|
|
||||||
`<root>/cache` — данные, которые можно удалить без потерь.
|
|
||||||
|
|
||||||
#### QString versionManifestFile()
|
|
||||||
|
|
||||||
Слепок манифеста версий Mojang с отметкой времени.
|
|
||||||
|
|
||||||
#### QString seasonalCatalogFile()
|
|
||||||
|
|
||||||
Слепок каталога сезонных сборок с отметкой времени.
|
|
||||||
|
|
||||||
#### QString loaderCacheFile(const QString &loaderKey)
|
|
||||||
|
|
||||||
Слепок списка версий одного модлоадера. Параметр `loaderKey` принимает значения `forge`, `fabric`,
|
|
||||||
`neoforge` и `quilt` — те же ключи, что возвращает `loaderKey()` из [modloader.h](modloader.md).
|
|
||||||
|
|
||||||
#### QString loaderDownloadDir()
|
|
||||||
|
|
||||||
Каталог, куда качаются `installer.jar` модлоадеров.
|
|
||||||
|
|
||||||
#### QString runtimeDir()
|
|
||||||
|
|
||||||
Каталог, куда качается `authlib-injector` — библиотека, подменяющая сервер авторизации при входе
|
|
||||||
через Ely.by.
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
Единственный подключаемый заголовок — `QString`. Пространство имён не зависит ни от одного класса
|
|
||||||
проекта, поэтому его можно подключать откуда угодно без риска циклических зависимостей.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
QString error;
|
|
||||||
if (!LauncherPaths::ensureRootExists(&error)) {
|
|
||||||
qWarning() << "папка лаунчера недоступна:" << error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QFile file(LauncherPaths::customBuildsFile());
|
|
||||||
if (file.open(QIODevice::WriteOnly))
|
|
||||||
file.write(document.toJson());
|
|
||||||
```
|
```
|
||||||
|
BiLauncher/
|
||||||
|
├── settings.json settingsFile()
|
||||||
|
├── profiles.json profilesFile()
|
||||||
|
├── customBuilds.json customBuildsFile()
|
||||||
|
├── versions.json legacyCustomBuildsFile() — старое имя, читается только при миграции
|
||||||
|
├── builds/ buildStorageDir()
|
||||||
|
│ ├── index.json журнал BuildSwitcher
|
||||||
|
│ └── <id>/ buildDir(id): архив сборки, скачанный пак
|
||||||
|
│ └── season.json seasonalStateFile(id)
|
||||||
|
├── java/ javaDir(): скачанные сборки Java, каждая в <id>/
|
||||||
|
├── runtime/ runtimeDir(): authlib-injector.jar
|
||||||
|
└── cache/ cacheDir(): всё, что можно удалить без потерь
|
||||||
|
├── version_manifest.json versionManifestFile()
|
||||||
|
├── java_runtimes.json javaCatalogFile()
|
||||||
|
├── seasonal_builds.json seasonalCatalogFile()
|
||||||
|
├── loader_<ключ>.json loaderCacheFile(key)
|
||||||
|
├── loaders/ loaderDownloadDir(): installer.jar и журналы установщиков
|
||||||
|
└── java/ javaDownloadDir(): архивы Temurin до распаковки
|
||||||
|
```
|
||||||
|
|
||||||
|
Папка игры задаётся в настройках (`gameDir`). `defaultMinecraftDir()` используется, только если
|
||||||
|
там пусто.
|
||||||
|
|
||||||
|
## ensureRootExists(&error)
|
||||||
|
|
||||||
|
Создаёт `rootDir`, `runtime`, `cache`, `cache/loaders`, `builds`, `java` и `cache/java`, а затем
|
||||||
|
проверяет, что каждая из них действительно папка и в неё можно писать. `mkpath` возвращает `true`
|
||||||
|
и для существующей папки без прав на запись, и тогда, когда на месте папки лежит файл. Без этой
|
||||||
|
проверки сохранения потом молча не работали бы.
|
||||||
|
|
||||||
|
Вызывается при старте бэкенда, перед каждой записью JSON-файлов бэкенда, перед записью кэшей
|
||||||
|
каталогов и перед установками. Остальные функции только собирают строки и ошибиться не могут.
|
||||||
|
|
||||||
|
`launcherpaths.cpp` подключает `localization.h` ради текста ошибки, заголовок зависит только от
|
||||||
|
`QString`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +1,36 @@
|
|||||||
# main.cpp — точка входа
|
# main.cpp
|
||||||
|
|
||||||
## Обзор
|
Точка входа. Объектов предметной области здесь нет: `LauncherBackend` создаёт сам
|
||||||
|
[Main.qml](../qml/Main.md), а `main()` только готовит приложение и загружает QML.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt 6 Quick. Интерфейс написан на QML,
|
## По порядку
|
||||||
вся работа — авторизация, скачивание версий, установка модлоадеров и Java, запуск игры — лежит в
|
|
||||||
C++-классе [LauncherBackend](LauncherBackend.md) и его сервисах.
|
|
||||||
|
|
||||||
`main.cpp` — стартовая последовательность приложения: здесь при необходимости инициализируется
|
1. **WebEngine.** Если лаунчер собран с Qt WebEngine (`LAUNCHER_HAS_WEBENGINE`),
|
||||||
Qt WebEngine, создаётся объект приложения, читается каталог переводов, выбирается стиль
|
`QtWebEngineQuick::initialize()` вызывается до создания `QGuiApplication`. После создания
|
||||||
Qt Quick Controls, загружается QML-модуль и запускается цикл событий. Файл намеренно короткий:
|
приложения общий контекст OpenGL уже не выставить.
|
||||||
ни одного объекта предметной области он не создаёт — всё, что нужно, QML заводит сам.
|
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
||||||
|
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
|
||||||
|
(в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её
|
||||||
|
через `Qt.application.version`), организация `GaleonDev`, домен `galeonworx.com`. На
|
||||||
|
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
|
||||||
|
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
||||||
|
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
||||||
|
5. **`setDesktopFileName("bilauncher")`.** По этому имени окружения рабочего стола в Linux
|
||||||
|
связывают окно с `bilauncher.desktop` и показывают правильную иконку в панели.
|
||||||
|
6. **Тексты.** `Localization::instance().load()` читает `i18n/translations.json` и язык из
|
||||||
|
`settings.json`. Это нужно сделать до создания движка: язык должен быть известен до первой
|
||||||
|
привязки. Если каталог не загрузился, `main()` возвращает `-1` (см.
|
||||||
|
[Localization](Localization.md)).
|
||||||
|
7. **Стиль `Basic`.** Окна оформлены вручную, а нативный стиль Windows игнорирует свои
|
||||||
|
`contentItem` и `background` и засыпает журнал предупреждениями.
|
||||||
|
8. **Движок.** `QQmlApplicationEngine`. Если корневой объект не создался, приложение
|
||||||
|
завершается с `-1`. Соединение отложенное (`Qt::QueuedConnection`), чтобы выход произошёл уже в
|
||||||
|
цикле событий, а не посреди загрузки.
|
||||||
|
9. `engine.loadFromModule("BiLauncher", "Main")` — загрузка по URI модуля и имени типа.
|
||||||
|
10. `app.exec()`.
|
||||||
|
|
||||||
## Настройка приложения Qt
|
Аргументы командной строки не разбираются. Контекстных свойств нет: QML общается с C++ только
|
||||||
|
через зарегистрированные типы `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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +1,102 @@
|
|||||||
# minecraftversion.h — MinecraftVersion и VersionLoader
|
# minecraftversion.h
|
||||||
|
|
||||||
## Обзор
|
`minecraftversion.h`, `minecraftversion.cpp`
|
||||||
|
|
||||||
Каждая версия Minecraft описывается файлом `versions/<id>/<id>.json` — в нём главный класс,
|
Чтение описаний версий из `<gameDir>/versions/<id>/<id>.json` и всё, что с ними связано: правила
|
||||||
аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют
|
Mojang, нативные библиотеки под текущую платформу, список установленного, удаление.
|
||||||
цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою
|
|
||||||
очередь, может наследоваться дальше.
|
|
||||||
|
|
||||||
Заголовок `minecraftversion.h` даёт две вещи: структуры, описывающие версию в разобранном виде, и
|
Профиль модлоадера обычно содержит только отличия от базовой версии и ссылается на неё через
|
||||||
пространство имён `VersionLoader` — чтение, разворачивание цепочки наследования и работа с
|
`inheritsFrom`. `VersionLoader::load()` проходит эту цепочку и сводит её в одну структуру
|
||||||
установленными версиями на диске.
|
`MinecraftVersion`, с которой уже работают [GameLauncher](GameLauncher.md) и
|
||||||
|
[VersionInstaller](VersionInstaller.md).
|
||||||
|
|
||||||
Заголовок подключают [GameLauncher](GameLauncher.md) (собирает из версии командную строку),
|
## MinecraftLibrary
|
||||||
[VersionInstaller](VersionInstaller.md) (по списку библиотек понимает, что качать) и
|
|
||||||
[LauncherBackend](LauncherBackend.md) (проверяет комплектность и удаляет версии).
|
|
||||||
|
|
||||||
## Типы
|
Одна библиотека, к которой уже применены правила.
|
||||||
|
|
||||||
| Имя | Вид | Описание |
|
| Поле | Что это |
|
||||||
|-----|-----|----------|
|
|------|---------|
|
||||||
| `MinecraftLibrary` | `struct` | Одна библиотека из `client.json` с уже разрешёнными правилами |
|
| `name` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
|
||||||
| `MinecraftVersion` | `struct` | `client.json`, «схлопнутый» по всей цепочке `inheritsFrom` |
|
| `path` | путь относительно `libraries/` |
|
||||||
|
| `url` | откуда качать; может быть пустым |
|
||||||
|
| `sha1`, `size` | для проверки; могут отсутствовать |
|
||||||
|
| `native` | распаковывается в `natives`, в classpath не идёт |
|
||||||
|
| `extractExclude` | префиксы путей внутри jar, которые не распаковываются |
|
||||||
|
|
||||||
### MinecraftLibrary
|
## MinecraftVersion
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
| Поле | Что это |
|
||||||
|------|-----|----------|
|
|------|---------|
|
||||||
| `name` | `QString` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
|
| `id` | имя папки в `versions/` |
|
||||||
| `path` | `QString` | Путь относительно `.minecraft/libraries` |
|
| `mainClass` | |
|
||||||
| `url` | `QString` | Откуда качать, если файла нет; может быть пустым — тогда файл должен уже лежать на месте |
|
| `type` | `release`, `snapshot` и т. д.; по умолчанию `release` |
|
||||||
| `sha1` | `QString` | Контрольная сумма для проверки скачанного |
|
| `assetIndexId` | id индекса ресурсов |
|
||||||
| `size` | `qint64` | Размер в байтах; `0` — неизвестен |
|
| `assetsKind` | поле `assets`: `legacy`, `pre-1.6` или тот же id |
|
||||||
| `native` | `bool` | Библиотека распаковывается в `natives`, а не кладётся в classpath |
|
| `clientJarPath` | абсолютный путь к клиентскому jar; часто это jar родителя |
|
||||||
| `extractExclude` | `QStringList` | Префиксы путей внутри архива, которые не распаковываются |
|
| `javaMajor` | из `javaVersion.majorVersion`; по умолчанию 8 |
|
||||||
|
| `jvmArgs`, `gameArgs` | с ещё не подставленными `${...}` |
|
||||||
|
| `libraries` | |
|
||||||
|
| `loggingArgument`, `loggingConfigPath` | аргумент log4j и путь к его xml в `assets/log_configs` |
|
||||||
|
| `supportsQuickPlay` | среди аргументов игры есть `quickPlayMultiplayer` (1.20+) |
|
||||||
|
| `hasCustomResolutionArgs` | описание само передаёт `resolution_width` |
|
||||||
|
|
||||||
### MinecraftVersion
|
`isValid()` возвращает `true`, если заданы `id` и `mainClass`.
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
## Как сводится цепочка
|
||||||
|------|-----|----------|
|
|
||||||
| `id` | `QString` | Имя папки в `versions`, оно же значение аргумента `--version` |
|
|
||||||
| `mainClass` | `QString` | Главный класс, который запускает java |
|
|
||||||
| `type` | `QString` | `release`, `snapshot` или `modified`; идёт в `--versionType` |
|
|
||||||
| `assetIndexId` | `QString` | Идентификатор индекса ресурсов для `--assetIndex` |
|
|
||||||
| `assetsKind` | `QString` | Поле `assets` версии: `legacy`, `pre-1.6` либо тот же идентификатор |
|
|
||||||
| `clientJarPath` | `QString` | Абсолютный путь к `<id>.jar`; может лежать у родительской версии |
|
|
||||||
| `javaMajor` | `int` | Требуемая мажорная версия Java; по умолчанию `8` |
|
|
||||||
| `jvmArgs` | `QStringList` | Аргументы JVM ещё с неподставленными подстановками вида `${...}` |
|
|
||||||
| `gameArgs` | `QStringList` | Аргументы игры, тоже с неподставленными подстановками |
|
|
||||||
| `libraries` | `QList<MinecraftLibrary>` | Библиотеки версии с уже применёнными правилами |
|
|
||||||
| `loggingArgument` | `QString` | Аргумент вида `-Dlog4j.configurationFile=${path}` |
|
|
||||||
| `loggingConfigPath` | `QString` | Абсолютный путь к xml-конфигурации журнала; пуст, если конфигурации нет |
|
|
||||||
| `supportsQuickPlay` | `bool` | Версия 1.20 и новее понимает `--quickPlayMultiplayer` — так лаунчер подключается к серверу сразу при запуске |
|
|
||||||
| `hasCustomResolutionArgs` | `bool` | Версия принимает аргументы размера окна |
|
|
||||||
|
|
||||||
Метод `isValid()` возвращает `true`, когда заполнены и `id`, и `mainClass`: именно этим проверяется
|
`load(gameDir, versionId, features, &error)` идёт от запрошенной версии к родителям по
|
||||||
успешность чтения версии.
|
`inheritsFrom`. Ограничения: не больше 10 звеньев, без циклов. Если файл отсутствует или не
|
||||||
|
разбирается, это ошибка, и возвращается невалидный объект.
|
||||||
|
|
||||||
## Функции
|
Дальше поля применяются от корня к потомку:
|
||||||
|
|
||||||
Пространство имён `VersionLoader`.
|
- `mainClass`, `type`, `assets`, `assetIndex`, `javaVersion`, `logging` — потомок перекрывает
|
||||||
|
родителя;
|
||||||
|
- `minecraftArguments` (старый формат, одна строка) **заменяет** `gameArgs` целиком;
|
||||||
|
- `arguments.jvm` и `arguments.game` (новый формат) **дописываются** к тому, что дал родитель.
|
||||||
|
|
||||||
#### QStringList installedVersions(const QString &gameDir)
|
Библиотеки собираются в обратном порядке, от потомка к корню. Повторы убираются по ключу
|
||||||
|
`группа:артефакт[:классификатор]`, без версии. Поэтому если Forge приносит свою версию библиотеки,
|
||||||
|
она вытесняет ванильную.
|
||||||
|
|
||||||
Версии, реально установленные в `<gameDir>/versions`: есть и папка, и файл `<id>.json`. Одной
|
Если среди JVM-аргументов нет `-cp`, значит это старая версия без блока `arguments.jvm`. Тогда
|
||||||
только папки недостаточно — она остаётся после неудачной установки.
|
добавляются аргументы, которые официальный лаунчер подставляет сам: `-Djava.library.path`, бренд и
|
||||||
|
версия лаунчера, `-cp ${classpath}`.
|
||||||
|
|
||||||
#### QStringList dependentsOf(const QString &gameDir, const QString &versionId)
|
Клиентский jar берётся первый найденный по цепочке, от потомка к корню. Если его нет нигде,
|
||||||
|
указывается путь у корня. `GameLauncher::missingFiles()` потом скажет, что его не хватает.
|
||||||
|
|
||||||
Установленные профили, у которых `inheritsFrom` равен `versionId`. Без базовой версии они не
|
`features` нужен для правил вида `"features": {"has_custom_resolution": true}`. Бэкенд передаёт
|
||||||
запустятся, поэтому список показывается пользователю перед удалением версии.
|
`has_custom_resolution`, если в настройках задан размер окна.
|
||||||
|
|
||||||
#### qint64 installedSize(const QString &gameDir, const QString &versionId)
|
## Правила и натив
|
||||||
|
|
||||||
Размер `<gameDir>/versions/<id>` в байтах; `0`, если папки нет. Используется в предупреждении об
|
`rulesAllow(rules, features)` реализует алгоритм Mojang: правила проверяются по порядку,
|
||||||
удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится.
|
действует последнее совпавшее; пустой список означает «разрешено». Правило совпадает, если подошли
|
||||||
|
`features`, `os.name`, `os.arch` и регулярное выражение `os.version` (сверяется с
|
||||||
|
`QSysInfo::productVersion()`).
|
||||||
|
|
||||||
#### bool remove(const QString &gameDir, const QString &versionId, QString \*error)
|
Нативные библиотеки встречаются в двух форматах:
|
||||||
|
|
||||||
Сносит `<gameDir>/versions/<id>`. Отсутствие папки считается успехом. При неудаче возвращает
|
- старый: блок `natives` с классификатором под ОС, `${arch}` заменяется на `32` или `64`;
|
||||||
`false` и заполняет `error`.
|
- новый (LWJGL 3.3+): отдельные библиотеки с классификатором `natives-<ос>[-<арх>]`.
|
||||||
|
|
||||||
Библиотеки и ресурсы в `libraries/` и `assets/` не трогаются: они общие для всех версий.
|
`pickNativesForCurrentArch()` оставляет для каждого артефакта один классификатор. Лучше всего
|
||||||
|
точный (`nativeClassifier()`, например `natives-macos-arm64`), потом общий для ОС
|
||||||
|
(`natives-macos`), потом какой попадётся.
|
||||||
|
|
||||||
#### MinecraftVersion load(const QString &gameDir, const QString &versionId, const QSet<QString> &features, QString \*error)
|
`osName()` возвращает `windows`, `osx` или `linux`. `osArch()` возвращает архитектуру из
|
||||||
|
`QSysInfo`, причём `i386` и `i686` сводятся к `x86`.
|
||||||
|
|
||||||
Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` —
|
## Установленные версии
|
||||||
набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский
|
|
||||||
размер окна).
|
|
||||||
|
|
||||||
При ошибке возвращает объект, у которого `isValid()` даёт `false`, и заполняет `error`.
|
- `installedVersions(gameDir)` — подпапки `versions/`, в которых есть `<id>.json`, по алфавиту.
|
||||||
|
- `dependentsOf(gameDir, id)` — установленные профили с `inheritsFrom == id`. Без базовой версии они
|
||||||
#### bool rulesAllow(const QJsonArray &rules, const QSet<QString> &features = {})
|
не запустятся, поэтому их перечисляют в предупреждении об удалении.
|
||||||
|
- `installedSize(gameDir, id)` — размер папки в байтах.
|
||||||
Стандартный алгоритм Mojang для блоков `rules`: правила применяются по порядку, побеждает последнее
|
- `remove(gameDir, id, &error)` — удаляет `versions/<id>`. Сначала проверяет, что id не пустой,
|
||||||
совпавшее. Используется и для библиотек, и для аргументов запуска.
|
не начинается с точки и не содержит слэшей, а итоговый путь лежит внутри `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;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,43 @@
|
|||||||
# modloader.h — ModLoader
|
# modloader.h
|
||||||
|
|
||||||
## Обзор
|
Перечисление модлоадеров, запись одной версии модлоадера и перевод между перечислением и
|
||||||
|
строковым ключом. Только заголовок, `.cpp` нет.
|
||||||
|
|
||||||
`Minecraft_launcher` умеет ставить четыре модлоадера: Minecraft Forge, Fabric Loader, NeoForge и
|
## ModLoader
|
||||||
Quilt Loader. Заголовок `modloader.h` — общий словарь для всей этой части проекта: перечисление
|
|
||||||
самих лоадеров, описание одной их сборки и функции перевода между перечислением и строковым
|
|
||||||
ключом.
|
|
||||||
|
|
||||||
Лоадеры взаимоисключающи: игра запускается ровно с одним профилем в `<gameDir>/versions`, поэтому в
|
`Forge`, `Fabric`, `NeoForge`, `Quilt`.
|
||||||
сборке лаунчера хранится один ключ лоадера, а не набор.
|
|
||||||
|
|
||||||
Заголовок подключают [ModLoaderVersionService](ModLoaderVersionService.md),
|
Модлоадеры взаимоисключающие: игра запускается с одним профилем из `versions/`, поэтому у сборки
|
||||||
[ModLoaderInstaller](ModLoaderInstaller.md) и [LauncherBackend](LauncherBackend.md).
|
может быть не больше одного модлоадера. [LoaderRow](../qml/LoaderRow.md) снимает отметку с
|
||||||
|
остальных, когда пользователь отмечает один.
|
||||||
|
|
||||||
## Типы
|
## Ключи и названия
|
||||||
|
|
||||||
| Имя | Вид | Описание |
|
| Значение | `loaderKey()` | `loaderTitle()` |
|
||||||
|-----|-----|----------|
|
|----------|---------------|-----------------|
|
||||||
| `ModLoader` | `enum class` | Модлоадеры, которые лаунчер умеет ставить |
|
| `Forge` | `forge` | Minecraft Forge |
|
||||||
| `LoaderVersionEntry` | `struct` | Одна сборка модлоадера под конкретную версию игры |
|
| `Fabric` | `fabric` | Fabric Loader |
|
||||||
|
| `NeoForge` | `neoforge` | NeoForge |
|
||||||
|
| `Quilt` | `quilt` | Quilt Loader |
|
||||||
|
|
||||||
### ModLoader
|
Ключ используется в QML, в имени файла кэша (`cache/loader_<ключ>.json`) и в поле `loader` записи
|
||||||
|
сборки в `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` | `QString` | Версия самого лоадера — например `47.4.0`, `0.19.3` или `21.1.248` |
|
| `loaderVersion` | `47.4.0`, `0.19.3`, `21.1.248` |
|
||||||
| `gameVersion` | `QString` | Версия Minecraft, под которую эта сборка — например `1.20.1` |
|
| `gameVersion` | `1.20.1` |
|
||||||
| `versionId` | `QString` | Предсказанный идентификатор профиля `versions/<id>`. У Forge пуст: он выясняется только после работы установщика |
|
| `versionId` | ожидаемое имя профиля в `versions/`. У Fabric и Quilt оно известно заранее (`fabric-loader-<версия>-<игра>`), у Forge и NeoForge пусто: имя становится известно только после работы установщика |
|
||||||
| `installerUrl` | `QUrl` | Адрес установщика; заполнен только у Forge и NeoForge |
|
| `installerUrl` | ссылка на `installer.jar`, только у Forge и NeoForge |
|
||||||
| `recommended` | `bool` | Сборка помечена авторами как рекомендуемая; по умолчанию `false` |
|
| `recommended` | у Forge помечено как recommended в `promotions_slim.json` |
|
||||||
| `stable` | `bool` | Сборка стабильна, а не тестовая; по умолчанию `true` |
|
| `stable` | не альфа, не бета, не rc/pre |
|
||||||
|
|
||||||
## Функции
|
|
||||||
|
|
||||||
#### QString loaderKey(ModLoader loader)
|
|
||||||
|
|
||||||
Строковый ключ лоадера: `forge`, `fabric`, `neoforge` или `quilt`. Один и тот же ключ используется
|
|
||||||
в трёх местах — в интерфейсе, в имени файла кэша на диске и в поле `loader` файла
|
|
||||||
`customBuilds.json`, — поэтому менять его нельзя без миграции сохранённых сборок.
|
|
||||||
|
|
||||||
Объявлена `inline` в заголовке.
|
|
||||||
|
|
||||||
#### QString loaderTitle(ModLoader loader)
|
|
||||||
|
|
||||||
Человекочитаемое название лоадера для интерфейса: «Minecraft Forge», «Fabric Loader», «NeoForge»,
|
|
||||||
«Quilt Loader». Объявлена `inline`.
|
|
||||||
|
|
||||||
#### std::optional<ModLoader> loaderFromKey(const QString &key)
|
|
||||||
|
|
||||||
Обратный перевод: ключ в перечисление. Возвращает `std::nullopt` для неизвестного ключа, в том
|
|
||||||
числе для пустой строки — а пустая строка в сборке означает чистую ваниль без лоадера. Объявлена
|
|
||||||
`inline`.
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
Подключает `QString`, `QUrl` и `<optional>`. От классов проекта не зависит и сам подключается
|
|
||||||
всюду, где речь идёт о модлоадерах.
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
const auto loader = loaderFromKey(build.loader);
|
|
||||||
if (!loader) {
|
|
||||||
// сборка без модлоадера — запускаем чистую ваниль
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qInfo() << "ставим" << loaderTitle(*loader)
|
|
||||||
<< "версии" << entry.loaderVersion
|
|
||||||
<< "под Minecraft" << entry.gameVersion;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,62 @@
|
|||||||
# zlibreference.h — ZlibReference
|
# zlibreference.h
|
||||||
|
|
||||||
## Обзор
|
`zlibreference.h`, `zlibreference.cpp`
|
||||||
|
|
||||||
`Minecraft_launcher` умеет ставить модлоадеры Forge и NeoForge, а их установщики — это Java-программы,
|
Обход одной конкретной проблемы на Linux: Forge и NeoForge не ставятся на системах, где вместо
|
||||||
которые собирают часть jar-файлов прямо на машине пользователя и сверяют sha1 каждого собранного
|
zlib стоит zlib-ng.
|
||||||
файла с эталоном из `install_profile.json`.
|
|
||||||
|
|
||||||
Эталон посчитан на обычном zlib. Дистрибутивы вроде CachyOS и Fedora подставляют вместо него
|
## В чём проблема
|
||||||
zlib-ng: сжатие корректное, но побайтово другое — поэтому установка падает на любой версии игры
|
|
||||||
сообщением «Processor failed, invalid outputs». Сменить Java не выйдет: сборки OpenJDK под Linux
|
|
||||||
берут `libz.so.1` из системы.
|
|
||||||
|
|
||||||
`ZlibReference` — обход этой проблемы. Рядом с лаунчером лежит собранный обычный zlib, и процессу
|
Установщики Forge и NeoForge собирают часть jar прямо на машине пользователя и сверяют sha1
|
||||||
установщика он подсовывается через `LD_PRELOAD`. Системный zlib-ng при этом остаётся на месте:
|
каждого собранного файла с эталоном из `install_profile.json`. Эталон посчитан на обычном zlib.
|
||||||
подмена живёт ровно один процесс.
|
CachyOS, Fedora и некоторые другие дистрибутивы ставят вместо него zlib-ng в режиме совместимости.
|
||||||
|
Он сжимает корректно, но результат побайтово другой. Поэтому установка падает на любой версии игры
|
||||||
|
с сообщением `Processor failed, invalid outputs`.
|
||||||
|
|
||||||
Пространство имён используется установщиком модлоадеров при подготовке окружения дочернего
|
Другая Java не помогает: сборки OpenJDK под Linux берут `libz.so.1` из системы.
|
||||||
процесса Java.
|
|
||||||
|
|
||||||
## Пространства имён
|
## Что сделано
|
||||||
|
|
||||||
`ZlibReference` группирует три функции: проверку системной библиотеки, поиск собранного эталона и
|
В репозитории лежат исходники zlib 1.3.1 (каталог `zlib/`). На Linux CMake собирает из них цель
|
||||||
подготовку окружения процесса. Состояния нет.
|
`launcher_zlib_reference` (`libz.so.1`). В дереве сборки она лежит в `<папка бинарника>/zlib/`, при
|
||||||
|
установке — в `<prefix>/lib/BiLauncher/`. Сам лаунчер с ней не линкуется. Библиотека
|
||||||
|
подкладывается через `LD_PRELOAD` только процессу установщика, системный zlib-ng не трогается.
|
||||||
|
|
||||||
|
Исходники лежат в репозитории, а не скачиваются при сборке, по двум причинам: сборка не зависит от
|
||||||
|
сети, и версия зафиксирована. От версии зависит побайтовый результат сжатия.
|
||||||
|
|
||||||
## Функции
|
## Функции
|
||||||
|
|
||||||
#### bool systemIsZlibNg()
|
На Windows и macOS все три функции ничего не делают: `systemIsZlibNg()` возвращает `false`,
|
||||||
|
`bundledPath()` пустую строку, `applyTo()` — `true`.
|
||||||
|
|
||||||
Отвечает на вопрос, окажется ли `libz.so.1`, который достанется процессу java, библиотекой zlib-ng.
|
### systemIsZlibNg()
|
||||||
От ответа зависит, нужна ли подмена вообще: на системе с обычным zlib она бессмысленна.
|
|
||||||
|
|
||||||
#### QString bundledPath()
|
Открывает `libz.so.1` через `dlopen`, то есть ту же библиотеку, которую загрузчик выдаст процессу
|
||||||
|
java, и смотрит, есть ли `zlib-ng` в строке `zlibVersion()`. Результат вычисляется один раз за
|
||||||
|
запуск.
|
||||||
|
|
||||||
Путь к собранному рядом эталонному `libz.so.1` или пустая строка, если его нет. Библиотека
|
### bundledPath()
|
||||||
собирается целью `launcher_zlib_reference` в `CMakeLists.txt` из исходников каталога `zlib/`,
|
|
||||||
которые лежат в репозитории, чтобы сборка не зависела от сети, а версия была зафиксирована — от
|
|
||||||
неё зависит побайтовый результат сжатия. Собирается только на Linux: на Windows и macOS проблемы
|
|
||||||
подмены системного zlib нет.
|
|
||||||
|
|
||||||
#### bool applyTo(QProcessEnvironment &env, QString *note = nullptr)
|
Ищет эталонную библиотеку по порядку:
|
||||||
|
|
||||||
Дописывает `LD_PRELOAD` в переданное окружение, если подмена нужна. Изменяет `env` на месте.
|
1. `<applicationDirPath>/zlib/libz.so.1` — запуск из дерева сборки;
|
||||||
|
2. `<applicationDirPath>/../lib/BiLauncher/libz.so.1` — установленный пакет, tar.gz и AppImage;
|
||||||
|
3. `LAUNCHER_ZLIB_INSTALL_DIR/libz.so.1` — путь, заданный при конфигурации CMake.
|
||||||
|
|
||||||
Возвращает `true`, когда окружение готово, — в том числе в случае, когда подменять нечего:
|
Второй вариант рассчитан на `CMAKE_INSTALL_LIBDIR=lib`. Поэтому CI на Ubuntu передаёт его явно,
|
||||||
система с обычным zlib или платформа, где вопрос не стоит. `false` означает, что подмена нужна, но
|
иначе библиотека легла бы в `lib/x86_64-linux-gnu`.
|
||||||
эталонной библиотеки нет на месте.
|
|
||||||
|
|
||||||
Необязательный параметр `note` заполняется строкой, пригодной и для журнала, и для текста ошибки:
|
### applyTo(env, ¬e)
|
||||||
она объясняет, была ли подмена применена и почему.
|
|
||||||
|
|
||||||
## Зависимости
|
- zlib-ng в системе нет — подменять нечего, возвращает `true`, `note` пуст.
|
||||||
|
- zlib-ng есть, эталон найден — дописывает его путь в начало `LD_PRELOAD` (чужое содержимое
|
||||||
|
переменной сохраняется), возвращает `true`, в `note` пишет, что подставлено.
|
||||||
|
- zlib-ng есть, эталона нет — возвращает `false`, в `note` пишет предупреждение.
|
||||||
|
|
||||||
Подключает `QString`; `QProcessEnvironment` объявлен вперёд и используется только по ссылке.
|
[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()`
|
||||||
Реализация опирается на макрос `LAUNCHER_ZLIB_INSTALL_DIR`, который `CMakeLists.txt` определяет на
|
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая
|
||||||
Linux — это путь установки библиотеки в системе, помимо каталога рядом с исполняемым файлом.
|
подсказка про zlib-ng.
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
|
||||||
|
|
||||||
QString note;
|
|
||||||
if (!ZlibReference::applyTo(env, ¬e)) {
|
|
||||||
emit failed(note);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QProcess installer;
|
|
||||||
installer.setProcessEnvironment(env);
|
|
||||||
installer.start(javaPath, arguments);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,164 +1,147 @@
|
|||||||
# Minecraft Launcher — справочник по исходному коду
|
# BiLauncher: устройство проекта
|
||||||
|
|
||||||
Десктопный лаунчер Minecraft на Qt 6. Интерфейс написан на QML, вся работа — авторизация,
|
Лаунчер Minecraft на Qt 6. Интерфейс на QML. Всё остальное, то есть вход в аккаунт, загрузка
|
||||||
скачивание версий, установка модлоадеров и Java, запуск игры — лежит в C++.
|
версий, установка модлоадеров и Java, смена сборок и запуск игры, написано на C++.
|
||||||
|
|
||||||
Документация разделена на две части: [QML-компоненты](#qml-компоненты) и
|
Здесь описано, как устроен код. Как собрать проект и выпустить релиз, рассказано в
|
||||||
[C++-классы](#c-классы).
|
[build-and-release.md](build-and-release.md), как устроены и запускаются автотесты — в
|
||||||
|
[testing.md](testing.md).
|
||||||
|
|
||||||
## Как устроено приложение
|
## Общая схема
|
||||||
|
|
||||||
Слоёв четыре, и каждый общается только с соседними:
|
```
|
||||||
|
QML (Main.qml и диалоги)
|
||||||
|
│ свойства, вызовы, сигналы
|
||||||
|
▼
|
||||||
|
LauncherBackend ──────────────── Loc (Localization)
|
||||||
|
│ владеет
|
||||||
|
▼
|
||||||
|
сервисы: каталоги, установщики, авторизация, BuildSwitcher, GameLauncher
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
сеть, файлы, дочерние процессы
|
||||||
|
```
|
||||||
|
|
||||||
1. **QML.** [Main](qml/Main.md) — единственное настоящее окно и точка входа. Оно держит
|
1. **QML.** [Main.qml](qml/Main.md) — единственное окно приложения. Остальные окна — `Dialog` внутри
|
||||||
единственный экземпляр `LauncherBackend` и раздаёт его вложенным диалогам через свойство
|
него. Main создаёт один `LauncherBackend` и передаёт его диалогам через `required property var
|
||||||
`backend`. Ни один QML-файл не обращается к сервисам напрямую.
|
backend`. К сервисам QML не обращается.
|
||||||
2. **Фасад.** [LauncherBackend](cpp/LauncherBackend.md) — главный класс, видимый из QML.
|
2. **[LauncherBackend](cpp/LauncherBackend.md)** хранит профили, сборки и настройки, владеет
|
||||||
Хранит профили, сборки и настройки, владеет двенадцатью сервисами и сводит их состояние к
|
двенадцатью сервисами и следит, чтобы их операции не пересекались. Каталоги он отдаёт в QML
|
||||||
свойствам, которые читает интерфейс. Каталоги он отдаёт уже сведёнными с локальным состоянием,
|
уже сведёнными с тем, что лежит на диске, поэтому окна сами ничего не считают.
|
||||||
поэтому окна показывают статус, не считая ничего сами.
|
3. **Сервисы.** Каждый занят одним делом и про интерфейс не знает.
|
||||||
3. **Сервисы.** Каталоги (версий, модлоадеров, Java, сезонных сборок), установщики, службы
|
4. **Внешний мир.** Серверы Mojang, Ely.by, Microsoft/Xbox, Adoptium, Fabric/Quilt/Forge/NeoForge и
|
||||||
авторизации, переключатель сборок и запуск игры. Каждый занят одним делом и ничего не знает об
|
свой сервер сезонных сборок. Файлы: `.minecraft` и папка лаунчера. Процессы: java с игрой,
|
||||||
интерфейсе.
|
установщики Forge/NeoForge, `tar`.
|
||||||
Особняком стоит [Localization](cpp/Localization.md) — второй и последний тип, видимый из QML.
|
|
||||||
Это синглтон `Loc`, через который проходят все тексты интерфейса: `Loc.t.домен.вид.имя` в QML и
|
|
||||||
`Loc::text("домен.вид.имя")` в C++. Он ни от чего не зависит и доступен всем слоям сразу.
|
|
||||||
|
|
||||||
4. **Внешний мир.** Сеть (Mojang, Ely.by, Microsoft, Adoptium, файловый сервер сборок), файловая
|
Отдельно стоит [Localization](cpp/Localization.md), синглтон `Loc`. Через него идут все тексты
|
||||||
система (`.minecraft` и папка лаунчера) и дочерние процессы (java, установщики модлоадеров,
|
интерфейса: `Loc.t.домен.вид.имя` в QML и `Loc::text("домен.вид.имя")` в C++. Он ни от чего не
|
||||||
`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
|
||||||
|
|
||||||
| Класс | Описание |
|
| Файл | Что это |
|
||||||
|-------|----------|
|
|------|---------|
|
||||||
| [Localization](cpp/Localization.md) | Синглтон `Loc`: все тексты интерфейса в одном файле `i18n/translations.json`, переключение языка на лету |
|
| [Main](qml/Main.md) | главное окно: экран запуска, профили, настройки, плашка сообщений, панели прогресса |
|
||||||
| [LauncherBackend](cpp/LauncherBackend.md) | Единственный тип, видимый из QML: 26 свойств, 42 вызываемых метода, состояние лаунчера и порядок работы всех сервисов |
|
| [BuildsDialog](qml/BuildsDialog.md) | пользовательские сборки: список и карточка |
|
||||||
|
| [VersionPickerDialog](qml/VersionPickerDialog.md) | выбор версии Minecraft, удаление скачанных |
|
||||||
|
| [JavaPickerDialog](qml/JavaPickerDialog.md) | выбор и загрузка Java |
|
||||||
|
| [SeasonalBuildsDialog](qml/SeasonalBuildsDialog.md) | каталог сезонных сборок |
|
||||||
|
| [MicrosoftLoginDialog](qml/MicrosoftLoginDialog.md) | вход Microsoft во встроенном браузере; только с WebEngine |
|
||||||
|
| [LoaderRow](qml/LoaderRow.md) | строка модлоадера в карточке сборки |
|
||||||
|
| [ProgressPanel](qml/ProgressPanel.md) | плашка прогресса |
|
||||||
|
| [DarkCombo](qml/DarkCombo.md) | выпадающий список в стиле окна |
|
||||||
|
| [LabelledField](qml/LabelledField.md) | подпись и поле ввода |
|
||||||
|
|
||||||
### Авторизация и запуск
|
## C++
|
||||||
|
|
||||||
| Класс | Описание |
|
### Вход и фасад
|
||||||
|-------|----------|
|
|
||||||
| [AuthService](cpp/AuthService.md) | Вход через Ely.by и офлайн-режим; структура `AuthResult` |
|
| | |
|
||||||
| [MsaAuthService](cpp/MsaAuthService.md) | Вход через аккаунт Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
|
|---|---|
|
||||||
| [GameLauncher](cpp/GameLauncher.md) | Сборка командной строки, распаковка нативных библиотек и запуск JVM |
|
| [main.cpp](cpp/main.md) | запуск приложения |
|
||||||
|
| [LauncherBackend](cpp/LauncherBackend.md) | всё, что видит QML |
|
||||||
|
| [Localization](cpp/Localization.md) | тексты интерфейса, `i18n/translations.json` |
|
||||||
|
|
||||||
|
### Вход в аккаунт и запуск
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [AuthService](cpp/AuthService.md) | Ely.by, офлайн, authlib-injector; структура `AuthResult` |
|
||||||
|
| [MsaAuthService](cpp/MsaAuthService.md) | Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
|
||||||
|
| [GameLauncher](cpp/GameLauncher.md) | командная строка, нативные библиотеки, процесс игры |
|
||||||
|
|
||||||
### Версии игры
|
### Версии игры
|
||||||
|
|
||||||
| Класс | Описание |
|
| | |
|
||||||
|-------|----------|
|
|---|---|
|
||||||
| [VersionManifestService](cpp/VersionManifestService.md) | Каталог версий Mojang с кэшем в папке лаунчера |
|
| [VersionManifestService](cpp/VersionManifestService.md) | каталог версий Mojang |
|
||||||
| [VersionInstaller](cpp/VersionInstaller.md) | Фоновая установка версии: jar, библиотеки, индекс ресурсов и сами ресурсы |
|
| [VersionInstaller](cpp/VersionInstaller.md) | установка версии: jar, библиотеки, ресурсы |
|
||||||
|
| [minecraftversion.h](cpp/minecraftversion.md) | чтение описаний версий, цепочка `inheritsFrom`, правила Mojang |
|
||||||
|
|
||||||
### Модлоадеры
|
### Модлоадеры
|
||||||
|
|
||||||
| Класс | Описание |
|
| | |
|
||||||
|-------|----------|
|
|---|---|
|
||||||
| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | Списки версий Forge, Fabric, NeoForge и Quilt; совместимость заложена в структуру данных |
|
| [modloader.h](cpp/modloader.md) | перечисление модлоадеров и ключи |
|
||||||
| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | Два пути установки под одним фасадом: готовое описание версии либо запуск `installer.jar` |
|
| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | версии Forge, Fabric, NeoForge, Quilt |
|
||||||
|
| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | установка: готовый профиль или `installer.jar` |
|
||||||
|
| [zlibreference.h](cpp/zlibreference.md) | обход zlib-ng для установщиков Forge/NeoForge на Linux |
|
||||||
|
|
||||||
### Java
|
### Java
|
||||||
|
|
||||||
| Класс | Описание |
|
| | |
|
||||||
|-------|----------|
|
|---|---|
|
||||||
| [JavaRuntimeService](cpp/JavaRuntimeService.md) | Каталог сборок Mojang и Eclipse Temurin под текущую платформу |
|
| [javaruntime.h](cpp/javaruntime.md) | типы, папка `<root>/java`, какая Java нужна какой версии игры |
|
||||||
| [JavaInstaller](cpp/JavaInstaller.md) | Установка сборки Java: архив Temurin либо дерево файлов Mojang |
|
| [JavaRuntimeService](cpp/JavaRuntimeService.md) | каталог Mojang и Temurin |
|
||||||
|
| [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) | все пути к данным лаунчера |
|
||||||
|
|
||||||
### Сезонные сборки
|
## Прочее в репозитории
|
||||||
|
|
||||||
| Класс | Описание |
|
| Путь | Что это |
|
||||||
|-------|----------|
|
|------|---------|
|
||||||
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | Каталог готовых сборок с файлового сервера |
|
| `i18n/translations.json` | все тексты интерфейса |
|
||||||
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | Загрузка архива сборки потоком с проверкой sha256 |
|
| `tools/check_translations.py` | проверка каталога переводов |
|
||||||
|
| `tests/` | автотесты, см. [testing.md](testing.md) |
|
||||||
### Общие типы и утилиты
|
| `icons/` | иконки и скрипт, который их генерирует |
|
||||||
|
| `images/` | картинки интерфейса |
|
||||||
| Файл | Описание |
|
| `zlib/` | исходники zlib 1.3.1 (чужой код) |
|
||||||
|------|----------|
|
| `packaging/` | установщик Windows, лицензии сторонних компонентов |
|
||||||
| [minecraftversion.h](cpp/minecraftversion.md) | Структуры версии и библиотеки, чтение и разворачивание цепочки `inheritsFrom` |
|
| `ci/` | скрипты подготовки раннеров |
|
||||||
| [javaruntime.h](cpp/javaruntime.md) | Виды сборок Java, папка `<root>/java` и таблица требований версий игры |
|
| `.gitea/workflows/release.yml` | сборка релизов |
|
||||||
| [modloader.h](cpp/modloader.md) | Перечисление модлоадеров, запись версии и перевод ключей |
|
| `.gitea/workflows/tests.yml` | автотесты на тех же раннерах |
|
||||||
| [launcherpaths.h](cpp/launcherpaths.md) | Все пути к данным лаунчера в одном месте |
|
| `Screenshots/` | картинки для README |
|
||||||
| [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
|
|
||||||
остаётся на месте. Исходники хранятся в репозитории, чтобы сборка не зависела от сети, а версия
|
|
||||||
была зафиксирована: от неё зависит побайтовый результат сжатия.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,178 +1,94 @@
|
|||||||
# BuildsDialog
|
# BuildsDialog.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Окно пользовательских сборок. Слева список, справа карточка выбранной сборки: имя, сервер, версия
|
||||||
|
Minecraft, модлоадер. Отсюда сборку ставят, делают активной и удаляют.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Сборка в нём — это именованный
|
Сборка — это версия игры, модлоадер и адрес сервера плюс собственное содержимое `.minecraft`
|
||||||
набор «версия игры + модлоадер + адрес сервера» вместе с собственным содержимым `.minecraft`:
|
(моды, конфиги, миры). Активная сборка одна, её файлы разложены в `.minecraft`, остальные лежат в
|
||||||
модами, конфигами и мирами. Активная сборка одна, её содержимое лежит в `.minecraft`, остальные
|
архивах. Подробнее в [BuildSwitcher](../cpp/BuildSwitcher.md).
|
||||||
хранятся в архивах и разворачиваются при переключении.
|
|
||||||
|
|
||||||
`BuildsDialog` — окно управления этими сборками: слева список со сменой активной, справа карточка
|
Открывается из [Main](Main.md) по кнопке активной сборки.
|
||||||
выбранной — имя, сервер, версия 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` | `var` | — | **Да** | Экземпляр `LauncherBackend` — хранилище сборок и исполнитель установки, удаления и переключения. |
|
| `backend` (required) | `LauncherBackend` |
|
||||||
| `editIndex` | `int` | `-1` | Нет | Индекс сборки, открытой в карточке справа. Значение `-1` означает, что карточка пуста и выключена. С активной сборкой не связан: активную меняет отдельная кнопка, чтобы случайный клик по списку не запускал архивацию `.minecraft`. |
|
| `editIndex` | сборка, открытая в карточке; −1 — ничего не открыто |
|
||||||
| `loading` | `bool` | `false` | Нет | Признак того, что карточка сейчас заполняется данными сборки. Пока он выставлен, обработчики полей не должны писать обратно — иначе открытие сборки немедленно перезаписывало бы её. |
|
| `loading` | карточка сейчас заполняется данными сборки, обработчики полей ничего не записывают |
|
||||||
|
|
||||||
### Внутренняя панель модлоадеров
|
`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`). |
|
|
||||||
|
|
||||||
Функции панели: `applyBuild(loader, loaderVersion)` раздаёт данные сборки всем строкам;
|
**Слева** (260 px) список сборок. У активной пометка «активна», при наведении появляется корзина.
|
||||||
`selectedRow()` возвращает отмеченную строку или `null`; `keepOnly(row)` очищает все строки,
|
Ниже строка «+ Новая сборка»: создаёт сборку с первым свободным именем вида «Сборка N» и сразу
|
||||||
кроме переданной; `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()`,
|
Отдельной кнопки «Сохранить» нет. Каждое поле записывается сразу после правки через
|
||||||
выставляет `loading` на время заполнения полей, раздаёт лоадеры панели через `applyBuild()` и в
|
`commit(fields)` → `updateCustomBuild()`. Иначе пришлось бы как-то показывать несохранённые правки
|
||||||
конце обновляет строку состояния.
|
при переключении между сборками в списке.
|
||||||
|
|
||||||
#### askRemove(int index) : void
|
Чтобы открытие сборки не записывало её саму в себя, на время `selectBuild()` выставляется
|
||||||
|
`loading`, и `commit()` в это время ничего не делает.
|
||||||
|
|
||||||
Спрашивает подтверждение перед удалением: оно необратимо и уносит с собой архив сборки. Запрашивает
|
## Модлоадеры
|
||||||
`customBuildRemovalInfo()` и наполняет окно подтверждения именем сборки и тремя признаками — есть
|
|
||||||
ли у неё архив, активна ли она сейчас и последняя ли она. Индекс запоминается в самом окне
|
|
||||||
подтверждения, потому что к моменту ответа строка списка под курсором может быть уже другой.
|
|
||||||
|
|
||||||
Окно подтверждения объясняет последствия по этим признакам: вместе со сборкой удалится её архив, и
|
`loaderPanel` держит четыре строки и следит, чтобы был отмечен не больше чем один модлоадер: игра
|
||||||
моды, конфиги и миры восстановить будет нельзя; активная сборка владеет содержимым `.minecraft`,
|
запускается с одним профилем из `versions/`. Ни одной отметки — ванильная сборка.
|
||||||
оно будет очищено, а на его место развернётся следующая сборка; для последней сборки содержимое
|
|
||||||
`.minecraft` остаётся на месте.
|
|
||||||
|
|
||||||
#### performRemove(int index) : void
|
- `applyBuild(loader, loaderVersion)` — раздать данные сборки строкам;
|
||||||
|
- `selectedRow()` — отмеченная строка или `null`;
|
||||||
|
- `keepOnly(row)` — снять отметки с остальных (на `userChecked` строки);
|
||||||
|
- `commitSelection()` — записать `loader`, `loaderVersion` и пустой `resolvedVersionId` (на
|
||||||
|
`changed` строки). Профиль модлоадера появится только после установки, а до этого сборка
|
||||||
|
запускается как ванильная.
|
||||||
|
|
||||||
Удаляет сборку через бэкенд и восстанавливает состояние карточки: если сборок не осталось,
|
Строки получают `gameVersion` привязкой к выбранной версии, поэтому смена версии игры сама
|
||||||
сбрасывает `editIndex` в `-1`, иначе открывает соседнюю — ту же позицию или последнюю оставшуюся.
|
перезапрашивает их списки.
|
||||||
|
|
||||||
#### commit(var fields) : void
|
## Удаление
|
||||||
|
|
||||||
Сохраняет часть полей сборки: передаёт `QVariantMap` в `updateCustomBuild()` и обновляет строку
|
`askRemove(index)` берёт `customBuildRemovalInfo()` и открывает подтверждение. Текст зависит от
|
||||||
состояния. Ничего не делает во время заполнения карточки (`loading`) и при пустом `editIndex` —
|
трёх признаков:
|
||||||
это и есть защита от записи при открытии сборки.
|
|
||||||
|
|
||||||
Вызывается по завершении правки каждого поля: имени, адреса сервера, версии Minecraft и выбора
|
- `hasArchive` — вместе со сборкой удалится её архив, моды, конфиги и миры будет не вернуть;
|
||||||
модлоадера.
|
- `active` — содержимое `.minecraft` принадлежит этой сборке, оно будет очищено, и на его место
|
||||||
|
развернётся следующая;
|
||||||
|
- `lastOne` — для последней сборки содержимое `.minecraft` остаётся как есть.
|
||||||
|
|
||||||
#### refreshStatus() : void
|
Индекс запоминается в самом окне подтверждения, потому что к моменту ответа под курсором может
|
||||||
|
оказаться другая строка.
|
||||||
|
|
||||||
Пересчитывает строку комплектности под карточкой. Спрашивает у бэкенда `checkInstallation()` и
|
`performRemove(index)` удаляет сборку и открывает соседнюю (ту же позицию или последнюю), а если
|
||||||
показывает либо сообщение о готовности к запуску, либо число недостающих файлов вместе с первым из
|
сборок не осталось, очищает карточку.
|
||||||
них. При пустом `editIndex` очищает строку.
|
|
||||||
|
|
||||||
#### newBuildName() : string
|
## Когда окно открывается
|
||||||
|
|
||||||
Придумывает имя для новой сборки: перебирает «Сборка 1», «Сборка 2» и так далее, пока не найдёт
|
`onAboutToShow` просит обновить каталог версий и открывает активную сборку (или первую). Через
|
||||||
свободное среди существующих имён.
|
`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()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,88 +1,15 @@
|
|||||||
# DarkCombo
|
# DarkCombo.qml
|
||||||
|
|
||||||
## Обзор компонента
|
`ComboBox` в тёмном оформлении окна: фон `#2a2a2a`, зелёная рамка при фокусе, выпадающая панель
|
||||||
|
высотой до 220 px с полосой прокрутки.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Всё окно оформлено вручную в
|
Стрелка взята та же, что у списков профиля и версии на главном окне
|
||||||
тёмной теме, а стиль Qt Quick Controls принудительно выставлен в `Basic` (`main.cpp`), потому что
|
(`images/Profile_Box/Asset_23/24.svg`), вместо двойного шеврона стиля `Basic`.
|
||||||
нативные стили игнорируют пользовательские `contentItem` и `background`. Из-за этого каждый
|
|
||||||
стандартный элемент управления, попадающий в интерфейс, приходится переопределять самому.
|
|
||||||
|
|
||||||
`DarkCombo` — как раз такое переопределение: выпадающий список в общем тёмном стиле окна.
|
Новых свойств нет, используется как обычный `ComboBox`. Есть одно ограничение: делегат выводит
|
||||||
Компонент не добавляет логики, он целиком про внешний вид — фон, рамку, стрелку, делегат строки и
|
`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])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,148 +1,48 @@
|
|||||||
# JavaPickerDialog
|
# JavaPickerDialog.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Окно выбора сборки Java. Устроено как [VersionPickerDialog](VersionPickerDialog.md): слева виды,
|
||||||
|
справа список с поиском. Отличие в том, что выбранная здесь сборка сразу и скачивается. Версии игры
|
||||||
|
качает сборка, а Java, которой нет на диске, игре ничем не поможет.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Разным версиям игры нужны разные
|
Открывается из диалога настроек в [Main](Main.md).
|
||||||
версии 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)` | выбор подтверждён |
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
## Как работает
|
||||||
|
|
||||||
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
`openFor()` сбрасывает поиск, открывает категорию переданной сборки и просит бэкенд обновить
|
||||||
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
каталог Java. По «Отмене» ничего не меняется.
|
||||||
|
|
||||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
Под категориями слева написано, какая Java нужна выбранной версии игры, чтобы не держать это в
|
||||||
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства `javaCatalog` и
|
голове.
|
||||||
`javaCatalogLoading`, вызывает `refreshJavaCatalog()`, `installJavaRuntime()` и
|
|
||||||
`removeJavaRuntime()`.
|
|
||||||
|
|
||||||
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`.
|
`javaCatalog` приходит из C++ уже отсортированным: скачанное первым, потом новые версии сверху.
|
||||||
Инстанцируется в диалоге настроек внутри [Main](Main.md) — сборка Java выбирается один раз для
|
Окно только отбирает строки.
|
||||||
всего лаунчера, а не отдельно для каждой сборки Minecraft.
|
|
||||||
|
|
||||||
Каталог приходит из C++ уже отсортированным — новые сверху, скачанные первыми, — поэтому окно
|
В строке: версия, пометка LTS, какие версии игры она потянет (`coverage`), размер и галочка у
|
||||||
только отбирает записи и порядок не трогает. Запись каталога содержит поля `id`, `label`, `kind`
|
скачанных. Сборка ниже `requiredMajor` не прячется, а помечается красноватым и подписью «слишком
|
||||||
(тип сборки), `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))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,16 @@
|
|||||||
# LabelledField
|
# LabelledField.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Подпись и под ней поле ввода. Используется в настройках и в карточке сборки.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick с полностью самостоятельно
|
| | |
|
||||||
оформленным тёмным интерфейсом. В диалоге настроек и в редакторе сборок много однотипных полей
|
|---|---|
|
||||||
ввода: подпись сверху, поле под ней. `LabelledField` собирает эту пару в один компонент, чтобы
|
| `label` | подпись |
|
||||||
отступы, цвета и подсветка фокуса не переписывались в каждом месте заново.
|
| `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)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,149 +1,52 @@
|
|||||||
# LoaderRow
|
# LoaderRow.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Одна строка модлоадера в карточке [BuildsDialog](BuildsDialog.md): чекбокс, а если он отмечен,
|
||||||
|
выпадающий список версий под выбранную версию Minecraft.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме чистой игры он умеет
|
Совместимость здесь не проверяется. `backend.loaderVersions()` отдаёт список уже под конкретную
|
||||||
ставить модлоадеры: 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()` |
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
Пока версия игры не выбрана, чекбокс выключен.
|
||||||
|
|
||||||
Импортирует `QtQuick` и `QtQuick.Controls 2.15`.
|
## applyEntries(list)
|
||||||
|
|
||||||
Использует компонент [DarkCombo](DarkCombo.md) из того же QML-модуля — выпадающий список версий в
|
Единственное место, где меняются `entries` и `selectedVersion`. Через него проходят все три пути:
|
||||||
тёмном стиле окна.
|
ответ из кэша, ответ сети и заполнение из сборки. Если выбранной версии в новом списке нет, она
|
||||||
|
заменяется первой строкой, а это самая новая версия для этой версии игры. Если при этом значение
|
||||||
|
изменилось и строка не в режиме `applying`, испускается `changed()`.
|
||||||
|
|
||||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, зарегистрирован через `QML_ELEMENT`
|
Раньше проверку делал только путь из сети, и версия модлоадера, выбранная под прошлую версию игры,
|
||||||
в модуле `Minecraft_launcher`), который передаётся снаружи в свойство `backend`. Из него
|
доживала до сохранения сборки.
|
||||||
компонент вызывает `loaderVersions()`, `refreshLoaderVersions()` и `loaderVersionsLoading()`, а
|
|
||||||
также слушает сигнал `loaderVersionsChanged`.
|
|
||||||
|
|
||||||
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`). Инстанцируется в
|
## Откуда берётся список
|
||||||
[BuildsDialog](BuildsDialog.md) — по одной строке на каждый поддерживаемый лоадер.
|
|
||||||
|
|
||||||
## Иерархия и роль
|
`reload()` сначала берёт то, что уже есть в кэше (`loaderVersions()`), чтобы список появился сразу,
|
||||||
|
а потом просит обновление (`refreshLoaderVersions()`). Ответ приходит в
|
||||||
|
`backend.loaderVersionsChanged(key, game)`. Строка сравнивает пару со своей и игнорирует чужие
|
||||||
|
ответы: от другого модлоадера или от прошлой версии игры.
|
||||||
|
|
||||||
Корневой тип — `Column` с расстоянием 4 px. Внутри три потомка, видимость которых
|
Под пустым списком написано «загрузка версий…», пока идёт запрос
|
||||||
взаимоисключающая по нижней части:
|
(`loaderVersionsLoading()`), и «не поддерживает версию N» после.
|
||||||
|
|
||||||
- `CheckBox` с полностью переопределённым индикатором (квадрат со скруглением и галочкой) и
|
`applyBuild()` берёт версию из сборки как пожелание. Если под текущую версию игры её нет,
|
||||||
подписью. Выключен, пока не выбрана версия игры.
|
`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()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,211 +1,136 @@
|
|||||||
# Main
|
# Main.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Главное окно, оно же точка входа: `main.cpp` загружает его через
|
||||||
|
`engine.loadFromModule("BiLauncher", "Main")`. Корень — `Window` 1280×720.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. `Main.qml` — его главное и
|
Что в нём есть:
|
||||||
единственное настоящее окно: точка входа приложения, которую загружает `main.cpp` вызовом
|
|
||||||
`engine.loadFromModule("Minecraft_launcher", "Main")`.
|
|
||||||
|
|
||||||
Окно совмещает четыре роли. Оно держит единственный экземпляр `LauncherBackend` — весь остальной
|
- единственный экземпляр `LauncherBackend` и все обработчики его сигналов;
|
||||||
интерфейс получает его от главного окна. Оно рисует сам экран запуска: фоновая картинка, большая
|
- экран запуска: фон, кнопка игры по центру, список профилей, кнопка активной сборки, кнопки папки
|
||||||
кнопка игры по центру, выпадающий список профилей, кнопка активной сборки, кнопки папки модов,
|
модов, настроек и сезонных сборок;
|
||||||
настроек и сезонных сборок. Оно показывает обратную связь — всплывающую плашку сообщений и две
|
- плашка сообщений и две панели прогресса ([ProgressPanel](ProgressPanel.md));
|
||||||
панели хода долгих операций. И наконец, оно объявляет диалоги, которые не вынесены в отдельные
|
- диалоги, которые не вынесены в отдельные файлы: новый профиль, редактирование профиля, код
|
||||||
файлы: создание и редактирование профиля, ввод кода двухфакторной аутентификации и настройки
|
двухфакторной аутентификации, настройки запуска.
|
||||||
запуска.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
Отдельными файлами сделаны [BuildsDialog](BuildsDialog.md),
|
||||||
|
[SeasonalBuildsDialog](SeasonalBuildsDialog.md), [JavaPickerDialog](JavaPickerDialog.md) и
|
||||||
|
[MicrosoftLoginDialog](MicrosoftLoginDialog.md). Окно передаёт им бэкенд через свойство `backend`.
|
||||||
|
|
||||||
Импортирует `QtQuick`, `QtQuick.Layouts 2.15`, `QtQuick.Controls 2.15` и сам QML-модуль проекта
|
Стиль Qt Quick Controls — `Basic` (выставлен в `main.cpp`), и почти у каждого контрола здесь своё
|
||||||
`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`; поэтому в этом файле почти каждый
|
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
|
||||||
элемент управления переопределяет своё оформление вручную.
|
правом нижнем углу, потому что остальные углы заняты.
|
||||||
|
|
||||||
Использует ресурсы из `RESOURCES` QML-модуля: фоновую картинку, три состояния кнопки запуска, по
|
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
|
||||||
три состояния кнопок папки и настроек, стрелки выпадающих списков, `images/Trash.svg` и
|
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
|
||||||
`images/Pencil.svg`.
|
(см. [main.cpp](../cpp/main.md)).
|
||||||
|
|
||||||
## Иерархия и роль
|
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
|
||||||
|
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
|
||||||
|
наоборот. Панель смены сборки отменить нельзя.
|
||||||
|
|
||||||
Корневой тип — `Window` размером 1280×720 px, видимое при старте. Это не переиспользуемый
|
У заголовков и подвалов всех диалогов корнем служит `Item` с явным `implicitHeight`. У
|
||||||
компонент, а точка входа приложения, поэтому раздел с примером использования здесь неприменим.
|
`Rectangle` `implicitHeight` всегда 0, и `Dialog` не смог бы посчитать свою высоту.
|
||||||
|
|
||||||
Раскладка держится на якорях относительно центральной кнопки запуска: список профилей — слева
|
## Функции
|
||||||
сверху от неё, кнопка активной сборки — справа сверху, кнопки папки и настроек — под списком
|
|
||||||
профилей. Кнопка сезонных сборок стоит в правом нижнем углу: это единственная свободная часть
|
|
||||||
окна, потому что панели хода работ висят слева, а всё остальное собрано вокруг кнопки запуска.
|
|
||||||
|
|
||||||
Панели загрузки и смены сборки имеют одни и те же якоря — они взаимоисключающи по построению:
|
**`showToast(text, color, timeout)`** — показать сообщение в плашке. `timeout` в миллисекундах,
|
||||||
признак занятости бэкенда не даёт начать переключение во время установки и наоборот. Панель смены
|
по умолчанию 4000. `0` значит «держать до следующего сообщения». Так показываются шаги запуска,
|
||||||
сборки объявлена неотменяемой: отступать после очистки `.minecraft` некуда, операцию нужно довести
|
чтобы сообщение не исчезало посреди долгой операции.
|
||||||
до конца.
|
|
||||||
|
|
||||||
Заголовки и подвалы всех диалогов сделаны на `Item` с явным `implicitHeight`, а не на
|
**`openMicrosoftLogin(url)`** — открыть окно входа Microsoft. Окно создаётся при первом вызове
|
||||||
`Rectangle`: у прямоугольника `implicitHeight` равен нулю независимо от заданной высоты, и
|
через `Qt.createComponent()` и хранится в `microsoftLoginDialog`. Объявить его прямо в файле
|
||||||
`Dialog` не смог бы вычислить свою полную высоту.
|
нельзя: `MicrosoftLoginDialog.qml` есть в модуле только в сборках с WebEngine, и в остальных
|
||||||
|
сломалось бы всё главное окно. Если компонент не создался, вход отменяется у бэкенда и показывается
|
||||||
|
сообщение.
|
||||||
|
|
||||||
## Свойства
|
**`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 в настройках |
|
||||||
|
|
||||||
`Main.qml` объявляет четыре диалога прямо в файле. Их свойства — часть состояния главного окна.
|
Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`;
|
||||||
|
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
|
||||||
|
числе сезонной сборки) доступна только отмена на панели загрузки.
|
||||||
|
|
||||||
**Диалог редактирования профиля** (`editProfileDialog`):
|
## Профили
|
||||||
|
|
||||||
| Свойство | Тип | По умолчанию | Описание |
|
Выпадающий список профилей переделан целиком. Наверху всплывающей панели закреплена кнопка
|
||||||
|----------|-----|--------------|----------|
|
«+ Добавить профиль», под ней список. У каждой строки при наведении появляются карандаш (открывает
|
||||||
| `editIndex` | `int` | `-1` | Индекс редактируемого профиля; `-1` — диалог не открыт ни для кого. |
|
редактирование через `openFor()`) и корзина (`removeProfile()`).
|
||||||
| `msProfile` | `bool` | `false` | Открытый профиль имеет тип «Microsoft». |
|
|
||||||
| `msLinked` | `bool` | `false` | У профиля есть действующая сессия Microsoft — от этого зависит строка статуса и подпись кнопки входа. |
|
|
||||||
| `msName` | `string` | `""` | Ник, полученный при официальной авторизации. Показывается отдельным полем только для чтения, а не подменой поля логина: привязка сломалась бы первым же вводом в поле логина обычного профиля. |
|
|
||||||
|
|
||||||
**Диалог двухфакторной аутентификации** (`twoFactorDialog`):
|
Типы входа во всех списках идут в одном порядке: 0 — `offline`, 1 — `elyby`, 2 — `microsoft`.
|
||||||
|
Модель берётся из `Loc.t.profile.authTypes`, и порядок в каталоге важен, потому что код сравнивает
|
||||||
|
`currentIndex` с 1 и 2. Без WebEngine третий пункт отрезается через `.slice(0, 2)`. В окне
|
||||||
|
редактирования он остаётся, если профиль уже лицензионный: иначе в сборке без WebEngine такой
|
||||||
|
профиль молча стал бы офлайновым.
|
||||||
|
|
||||||
| Свойство | Тип | По умолчанию | Описание |
|
**Новый профиль.** `addProfile()`, новый профиль выбирается в списке. Если тип «Microsoft», сразу
|
||||||
|----------|-----|--------------|----------|
|
начинается вход, потому что без него такой профиль бесполезен. Скрытые поля при сохранении не
|
||||||
| `profileName` | `string` | `""` | Имя профиля, для которого запрошен код; подставляется в текст просьбы. |
|
читаются: в них мог остаться текст, набранный до смены типа.
|
||||||
|
|
||||||
**Диалог настроек** (`settingsDialog`):
|
**Редактирование.** Дополнительные свойства диалога: `editIndex`, `msProfile`, `msLinked` (есть ли
|
||||||
|
сессия Microsoft), `msName` (ник из аккаунта). Ник показывается отдельным полем только для чтения,
|
||||||
|
а не подставляется в поле логина. Иначе привязка оборвалась бы при первом же вводе в это поле у
|
||||||
|
обычного профиля. Кнопка входа сначала сохраняет профиль с типом `microsoft` через
|
||||||
|
`updateProfile()` и только потом вызывает `startMicrosoftLogin()`. Тип могли переключить только
|
||||||
|
что, и без этого токены достались бы профилю другого типа.
|
||||||
|
|
||||||
| Свойство | Тип | По умолчанию | Описание |
|
## Двухфакторная аутентификация
|
||||||
|----------|-----|--------------|----------|
|
|
||||||
| `javaRuntimeId` | `string` | `""` | Выбранная сборка Java из папки лаунчера. Живёт в свойстве, а не в поле ввода: её выбирают в отдельном окне, а записывается она только по «Сохранить». Пустая строка означает «искать Java в системе». |
|
|
||||||
| `javaRuntimeInfo` | `var` | `null` | Подробности выбранной сборки для строки поля. Не привязка: `javaRuntimeInfo()` — обычный вызов, и сам он не пересчитается, когда сборка докачается, поэтому значение обновляется по событиям. |
|
|
||||||
|
|
||||||
Типы профилей во всех выпадающих списках кодируются одинаково: позиция 0 — `offline`
|
Диалог показывает имя профиля (`profileName`) и поле для кода. «Подтвердить» вызывает
|
||||||
(офлайн, без пароля), 1 — `elyby` (Ely.by, с логином и паролем), 2 — `microsoft` (лицензия).
|
`submitTwoFactorCode()`, «Отмена» — `cancelPendingLaunch()`. Если отмену не вызвать, запуск так и
|
||||||
Третья позиция показывается, только когда лаунчер собран с Qt WebEngine; в диалоге редактирования
|
повиснет.
|
||||||
она показывается ещё и тогда, когда профиль уже сохранён как лицензионный — иначе в сборке без
|
|
||||||
WebEngine он молча стал бы офлайновым.
|
|
||||||
|
|
||||||
## Сигналы
|
## Настройки
|
||||||
|
|
||||||
Собственных сигналов главное окно не объявляет.
|
`settingsDialog.load()` читает `settings()` и `detectedJava()`, раскладывает значения по полям и
|
||||||
|
открывает диалог. «Сохранить» собирает всё в одну карту и отдаёт в `updateSettings()`.
|
||||||
|
|
||||||
## Методы
|
- **Язык** стоит первым, потому что относится ко всему приложению, а не к запуску игры. В модели
|
||||||
|
переведённые подписи, рядом лежит непереводимый список `codes` (`system`, `ru`, `en`). Язык
|
||||||
|
применяется по «Сохранить» и сразу, без перезапуска.
|
||||||
|
- **Папка игры**, а под полем — путь, который получится на самом деле (`resolvedGameDir`). Путь
|
||||||
|
хранится в свойстве, а не записывается в текст напрямую, иначе подпись не обновилась бы при смене
|
||||||
|
языка.
|
||||||
|
- **Java.** Поле открывает [JavaPickerDialog](JavaPickerDialog.md) с текущим выбором и требованием
|
||||||
|
активной сборки (`requiredJavaMajor()`). Крестик сбрасывает выбор, и тогда Java ищется в системе.
|
||||||
|
Выбор хранится в `javaRuntimeId` и записывается только по «Сохранить». `javaRuntimeInfo`
|
||||||
|
обновляется при смене `javaRuntimeId` и по `javaRuntimeInstalled`, потому что
|
||||||
|
`javaRuntimeInfo()` — обычный вызов, а не привязка. Если сборка выбрана, запуск идёт ею, а путь
|
||||||
|
из соседнего поля остаётся запасным вариантом.
|
||||||
|
- Память, размер окна, аргументы JVM, полноэкранный режим.
|
||||||
|
- Ниже для справки список найденных в системе java.
|
||||||
|
|
||||||
#### showToast(string text, color color, int timeout) : void
|
Окно выбора Java объявлено рядом с диалогом настроек, а не внутри него. Оно шире настроек и
|
||||||
|
|
||||||
Показывает единую всплывающую плашку сообщений: через неё проходят сообщения о ходе запуска,
|
|
||||||
ошибки и статус игры. Задаёт текст и цвет фона и перезапускает таймер скрытия.
|
|
||||||
|
|
||||||
Параметр `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`: `Loc.t.домен.вид.имя`.
|
Все тексты окна берутся из `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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +1,45 @@
|
|||||||
# MicrosoftLoginDialog
|
# MicrosoftLoginDialog.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Окно входа в аккаунт Microsoft. Работает так же, как официальный лаунчер: страница входа
|
||||||
|
открывается внутри приложения в `WebEngineView`, а лаунчер ждёт, когда браузер перейдёт на
|
||||||
|
`redirect_uri` с кодом в адресе.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Он поддерживает несколько
|
Файл попадает в QML-модуль, только если CMake нашёл `Qt6::WebEngineQuick`. Поэтому
|
||||||
способов входа: офлайн-профиль, сервер Ely.by и учётную запись Microsoft. Последний путь требует
|
[Main](Main.md) не объявляет окно в разметке, а создаёт его через `Qt.createComponent()` при первом
|
||||||
показать пользователю настоящую страницу входа Microsoft и дождаться, пока браузер уйдёт на
|
входе (`openMicrosoftLogin()`).
|
||||||
`redirect_uri` с кодом авторизации в адресе — ровно так же поступает официальный лаунчер.
|
|
||||||
|
|
||||||
`MicrosoftLoginDialog` — окно с этой страницей. Внутри него живёт `WebEngineView`; диалог следит
|
## Интерфейс
|
||||||
за сменой адреса, отдаёт перехваченный код бэкенду и закрывается. Собственной логики разбора
|
|
||||||
адреса у него нет — она в C++, чтобы правила совпадения совпадали с теми, по которым сервис сам
|
|
||||||
строит `redirect_uri`.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `openAt(url)` | открыть страницу входа |
|
||||||
|
| `failed(message)` | вход не удался. Окно ничего не знает о плашке сообщений главного окна и сообщает о неудаче сигналом |
|
||||||
|
|
||||||
Импортирует `QtQuick`, `QtQuick.Controls 2.15` и `QtWebEngine`. В начале файла объявлена
|
## Как работает
|
||||||
`pragma ComponentBehavior: Bound`.
|
|
||||||
|
|
||||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT`), который передаётся
|
На каждое `urlChanged` вызывается `handleUrl()`. Он передаёт адрес в
|
||||||
снаружи в свойство `backend`. Вызывает у него `inspectMicrosoftRedirect()`,
|
`backend.inspectMicrosoftRedirect()`. Разбор сделан в C++, чтобы правила совпадали с тем, как
|
||||||
`finishMicrosoftLogin()` и `cancelMicrosoftLogin()`.
|
[MsaAuthService](../cpp/MsaAuthService.md) строит `redirect_uri`.
|
||||||
|
|
||||||
**Особенность сборки.** Это единственный QML-файл проекта, который попадает в модуль условно.
|
Когда адрес совпал:
|
||||||
`CMakeLists.txt` ищет `Qt6WebEngineQuick` через `find_package(... QUIET)`; модуль объявлен
|
|
||||||
необязательным сознательно — он ставится отдельной галочкой в установщике Qt и тянет за собой
|
|
||||||
WebChannel с Positioning, которых в типовой установке нет. Если модуль найден, файл добавляется в
|
|
||||||
`QML_FILES` и определяется макрос `LAUNCHER_HAS_WEBENGINE`; если нет — лаунчер собирается и
|
|
||||||
работает как прежде, только без входа через Microsoft. В QML это различие видно через свойство
|
|
||||||
`backend.microsoftAvailable`, и интерфейс не должен предлагать этот путь, когда оно ложно.
|
|
||||||
|
|
||||||
Инстанцируется динамически из [Main](Main.md) — главное окно создаёт диалог по требованию, потому
|
- окно закрывается, `codeTaken` выставляется в `true`;
|
||||||
что при сборке без WebEngine самого типа в модуле не существует.
|
- если есть код, вызывается `backend.finishMicrosoftLogin(code)`;
|
||||||
|
- если кода нет, вызывается `backend.cancelMicrosoftLogin()` и испускается `failed` с текстом от
|
||||||
|
Microsoft или общим «вход не завершён».
|
||||||
|
|
||||||
## Иерархия и роль
|
`codeTaken` нужен потому, что `WebEngineView` может сообщить об одном и том же адресе несколько
|
||||||
|
раз, а отдавать код дважды нельзя.
|
||||||
|
|
||||||
Корневой тип — `Dialog` из Qt Quick Controls: модальный, 560×680 px, по центру родителя, с нулевым
|
Кнопка «Отмена» выставляет `codeTaken`, закрывает окно и вызывает `cancelMicrosoftLogin()`. Щелчок
|
||||||
внутренним отступом и `closePolicy: Popup.NoAutoClose` — окно нельзя закрыть щелчком мимо или
|
мимо окна и Esc его не закрывают.
|
||||||
клавишей Escape, выход только через кнопку отмены или успешный вход.
|
|
||||||
|
|
||||||
Оформление задано вручную: тёмный фон со скруглением и акцентной рамкой, заголовок с
|
## Куки
|
||||||
разделительной линией, подвал с кнопкой «Отмена».
|
|
||||||
|
|
||||||
Содержимое — `WebEngineView` во всю площадь с отступом 12 px и индикатор занятости по центру,
|
Профиль браузера создаётся через `WebEngineProfilePrototype` без `storageName`, то есть только в
|
||||||
видимый на время загрузки страницы. Рядом объявлен `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)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,23 @@
|
|||||||
# ProgressPanel
|
# ProgressPanel.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Плашка прогресса долгой операции, 320×72, в левом нижнем углу главного окна. Там она не закрывает
|
||||||
|
кнопку запуска, сообщения по центру и кнопки папки и настроек.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Почти каждое действие в нём
|
В [Main](Main.md) их две: загрузка (версия, модлоадер, Java, пак) и смена сборки.
|
||||||
долгое: скачивание версии игры, установка 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()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,134 +1,56 @@
|
|||||||
# SeasonalBuildsDialog
|
# SeasonalBuildsDialog.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Каталог сезонных сборок с сервера лаунчера. Таблица показывает всё, что нужно, чтобы решить,
|
||||||
|
ставить сборку или нет. Остальное делает одна кнопка: создаёт запись сборки, ставит версию игры,
|
||||||
|
модлоадер и Java и раскладывает файлы. Как это устроено, описано в
|
||||||
|
[LauncherBackend](../cpp/LauncherBackend.md#сезонные-сборки).
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме сборок, которые
|
Открывается из [Main](Main.md) кнопкой в правом нижнем углу (`openCatalog()`).
|
||||||
пользователь собирает сам, он умеет ставить готовые сезонные сборки с сервера лаунчера: набор
|
|
||||||
модов под конкретную версию игры и модлоадер, подготовленный заранее и выдаваемый целиком.
|
|
||||||
|
|
||||||
`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` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога сезонных сборок и исполнитель установки. |
|
| `backend` (required) | `LauncherBackend` |
|
||||||
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной строки. Хранится по `id`, а не по индексу: список обновляется под руками, и индекс после обновления указывал бы на другую сборку. |
|
| `selectedId` | выбранная строка, по `id` сборки |
|
||||||
| `columns` | `var` (только чтение) | список из семи колонок | Нет | Описание таблицы: массив объектов с полями `key` (поле записи), `title` (заголовок), `width` (ширина в пикселях) и `align` (выравнивание). Колонки: название, версия, загрузчик, число модов, начало и конец сезона, статус. Ширины собраны в одном месте, потому что их повторяют и шапка, и делегат строки. |
|
| `columns` | описание колонок: ключ, заголовок, ширина, выравнивание |
|
||||||
| `entries` | `var` (только чтение) | `backend.seasonalCatalog` | Нет | Строки каталога напрямую из бэкенда. Отбора и сортировки в окне нет. |
|
|
||||||
| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки или `null`, если ничего не выбрано. От неё зависят подвал и доступность кнопки установки. |
|
|
||||||
|
|
||||||
## Сигналы
|
Выбранная строка хранится по `id`, а не по индексу: список может обновиться, пока окно открыто,
|
||||||
|
и индекс начал бы указывать на другую сборку.
|
||||||
|
|
||||||
Собственных сигналов компонент не объявляет. Результат работы окна виден через состояние бэкенда:
|
## Таблица
|
||||||
установка меняет список сборок и запускает загрузку, за ходом которой следит главное окно.
|
|
||||||
|
|
||||||
## Методы
|
Колонки: название, версия, модлоадер с версией, число модов, начало и конец сезона, статус
|
||||||
|
(«не установлена», «установлена», «доступно обновление»). Ширины заданы в одном месте, `columns`,
|
||||||
|
и их используют и шапка, и строки.
|
||||||
|
|
||||||
#### openCatalog() : void
|
Таблица собрана из `Row` с колонками фиксированной ширины, а не из `TableView`. В проекте нет ни
|
||||||
|
одной модели на `QAbstractItemModel`, строки приходят из C++ готовыми `QVariantMap`, и заводить
|
||||||
|
модель ради семи колонок не стоит.
|
||||||
|
|
||||||
Открывает окно. Обновление каталога происходит само в обработчике `onAboutToShow`, который просит
|
Строки приходят уже отсортированными и сведёнными с локальными записями: окно ничего не считает.
|
||||||
у бэкенда `refreshSeasonalCatalog(false)` — без принудительного обхода кэша: свежий кэш отвечает
|
|
||||||
без сети, поэтому вызов при каждом открытии ничего не стоит.
|
|
||||||
|
|
||||||
#### formatMb(bytes) : string
|
Пустой список и ошибка сервера выглядят одинаково пусто, поэтому причина пишется прямо на месте
|
||||||
|
строк: «загрузка…», «сборок пока нет» или текст `seasonalCatalogError` красным.
|
||||||
|
|
||||||
Переводит размер в байтах в строку с мегабайтами и одним знаком после запятой. Для нулевого или
|
Под таблицей описание выбранной сборки, ревизия, размер архива и установленная ревизия, если она
|
||||||
отсутствующего значения возвращает пустую строку, чтобы размер просто не попал в строку подвала.
|
есть. Описания длинные и в таблицу не помещаются, а решение принимают как раз по ним.
|
||||||
|
|
||||||
#### cellText(entry, string key) : string
|
## Кнопки
|
||||||
|
|
||||||
Возвращает текст ячейки для записи и ключа колонки. Для всех колонок это значение одноимённого
|
- **Обновить список** — `refreshSeasonalCatalog(true)`, мимо кэша. Выключена, пока идёт загрузка.
|
||||||
поля записи, приведённое к строке; исключение — колонка загрузчика, где название и версия
|
- **Установить / Обновить** — `installSeasonalBuild(id)`. Подпись зависит от `updateAvailable`.
|
||||||
склеиваются в одну подпись, а при пустой версии остаётся только название.
|
Выключена, пока лаунчер чем-то занят (`busy`): установка занимает и панель загрузки, и
|
||||||
|
`.minecraft` целиком.
|
||||||
|
|
||||||
#### installSelected() : void
|
Двойной щелчок по строке делает то же, что кнопка установки.
|
||||||
|
|
||||||
Ставит выбранную сборку. Ничего не делает, если строка не выбрана или лаунчер занят другой
|
Каталог обновляется при каждом открытии окна (`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()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +1,58 @@
|
|||||||
# VersionPickerDialog
|
# VersionPickerDialog.qml
|
||||||
|
|
||||||
## Обзор компонента
|
Окно выбора версии Minecraft. Слева категории, справа список версий выбранной категории с поиском.
|
||||||
|
|
||||||
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Каталог версий Mojang — это около
|
Раньше на этом месте был выпадающий список на тысячу версий, и найти в нём, например, бету 1.7
|
||||||
тысячи записей: релизы, снапшоты, старые беты и альфы. Раньше выбор версии был обычным выпадающим
|
можно было только набрав точный номер. Категории делят каталог на части разумного размера, а поиск
|
||||||
списком на всю эту тысячу, и найти в нём, скажем, бету 1.7 можно было только поиском по точному
|
работает внутри выбранной.
|
||||||
номеру.
|
|
||||||
|
|
||||||
`VersionPickerDialog` заменил тот список отдельным окном: слева категории, справа сами версии с
|
Используется в [BuildsDialog](BuildsDialog.md) для поля версии игры.
|
||||||
поиском сверху. Категории делят каталог на обозримые части, а поиск работает внутри выбранной.
|
|
||||||
Помимо выбора окно умеет удалять уже скачанные версии — с предупреждением о последствиях, потому
|
|
||||||
что версия весит десятки мегабайт, а поверх неё могут стоять профили модлоадеров.
|
|
||||||
|
|
||||||
Окно открывается из карточки сборки, когда пользователь выбирает, на какой версии Minecraft
|
## Интерфейс
|
||||||
собирается играть.
|
|
||||||
|
|
||||||
## Место в проекте и зависимости
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `selectedId` | выбранная версия |
|
||||||
|
| `category` | `release`, `snapshot`, `old_beta`, `old_alpha` или `other` |
|
||||||
|
| `filterText` | строка поиска |
|
||||||
|
| `installedOnly` | показывать только скачанные |
|
||||||
|
| `openFor(versionId)` | открыть с этой версией |
|
||||||
|
| `versionChosen(versionId)` | выбор подтверждён |
|
||||||
|
|
||||||
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
Сигнал называется не `accepted`, потому что такой сигнал у `Dialog` уже есть, и переопределить его
|
||||||
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
нельзя.
|
||||||
|
|
||||||
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
## Как работает
|
||||||
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает у него свойства
|
|
||||||
`versionCatalog` и `catalogLoading`, вызывает `refreshVersionCatalog()`, `versionRemovalInfo()` и
|
|
||||||
`removeVersion()`.
|
|
||||||
|
|
||||||
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg` из
|
`openFor()` сбрасывает поиск, переключается на категорию переданной версии (а не всегда на
|
||||||
`RESOURCES` того же модуля. Инстанцируется в [BuildsDialog](BuildsDialog.md).
|
релизы), просит бэкенд обновить каталог (`refreshVersionCatalog()`) и прокручивает список к
|
||||||
|
выбранной строке. Если версии нет в каталоге, окно открывается на релизах. По «Отмене» ничего не
|
||||||
|
меняется: результат уходит наружу только через `versionChosen`.
|
||||||
|
|
||||||
Каталог приходит из C++ уже отсортированным (новые сверху), поэтому окно только отбирает записи и
|
`versionCatalog` приходит из C++ уже отсортированным, новые сверху. `visibleEntries` только
|
||||||
порядок не трогает. Каждая запись каталога — объект с полями `id` (идентификатор версии), `label`
|
отбирает строки по категории, флажку «только скачанные» и подстроке в поле `search`, а порядок не
|
||||||
(подпись строки), `category` (ключ категории), `installed` (скачана ли) и `search`
|
меняет. В «Прочие» попадают профили модлоадеров и всё, чего нет в манифесте Mojang.
|
||||||
(предвычисленная строка для поиска в нижнем регистре).
|
|
||||||
|
|
||||||
## Иерархия и роль
|
С клавиатуры: фокус сразу в поиске, стрелки вверх и вниз двигают выбор, 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)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Автотесты
|
||||||
|
|
||||||
|
Тесты лежат в `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`). Такие данные выносите в фикстуры или собирайте по шагам.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#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 {
|
||||||
@@ -46,7 +47,11 @@ 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;
|
||||||
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
|
// Версионированные so (libfoo.so.1, libfoo.so.1.2.3). Хвост — строго цифры,
|
||||||
|
// иначе в натив попадают служебные файлы LWJGL вроде liblwjgl.so.sha1 / .so.git.
|
||||||
|
static const QRegularExpression versionedSo(QStringLiteral("\\.so(\\.\\d+)+$"),
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
return versionedSo.match(fileName).hasMatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "launcherversion.h"
|
||||||
#include "minecraftversion.h"
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
@@ -34,7 +35,7 @@ struct LaunchOptions
|
|||||||
QString authlibInjectorApi = QStringLiteral("ely.by");
|
QString authlibInjectorApi = QStringLiteral("ely.by");
|
||||||
|
|
||||||
QString launcherName = QStringLiteral("BiLauncher");
|
QString launcherName = QStringLiteral("BiLauncher");
|
||||||
QString launcherVersion = QStringLiteral("1.0");
|
QString launcherVersion = QStringLiteral(LAUNCHER_VERSION);
|
||||||
};
|
};
|
||||||
|
|
||||||
class GameLauncher : public QObject
|
class GameLauncher : public QObject
|
||||||
|
|||||||
@@ -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,6 +31,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"strings": {
|
"strings": {
|
||||||
|
"app.label.version": {
|
||||||
|
"ru": "Версия: %1",
|
||||||
|
"en": "Version: %1"
|
||||||
|
},
|
||||||
"app.title": {
|
"app.title": {
|
||||||
"ru": "BiLauncher",
|
"ru": "BiLauncher",
|
||||||
"en": "BiLauncher"
|
"en": "BiLauncher"
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
"en": "Open mods folder"
|
"en": "Open mods folder"
|
||||||
},
|
},
|
||||||
"app.tooltip.seasonal": {
|
"app.tooltip.seasonal": {
|
||||||
"ru": "Сезонные сборки от команды Мельника",
|
"ru": "Сезонные сборки от команды Galeon'а",
|
||||||
"en": "Seasonal builds from Galeon team"
|
"en": "Seasonal builds from Galeon team"
|
||||||
},
|
},
|
||||||
"app.tooltip.settings": {
|
"app.tooltip.settings": {
|
||||||
@@ -156,8 +160,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 — показать окно входа Microsoft нечем",
|
"ru": "Эта сборка лаунчера собрана без Qt WebEngine",
|
||||||
"en": "This launcher build was made without Qt WebEngine — there is nothing to show the Microsoft sign-in window with"
|
"en": "This launcher build was made without Qt WebEngine"
|
||||||
},
|
},
|
||||||
"auth.msa.error.noXboxProfile": {
|
"auth.msa.error.noXboxProfile": {
|
||||||
"ru": "К аккаунту Microsoft не привязан профиль Xbox. Создайте его на xbox.com и повторите вход.",
|
"ru": "К аккаунту Microsoft не привязан профиль Xbox. Создайте его на xbox.com и повторите вход.",
|
||||||
@@ -196,8 +200,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 недоступно: сборка без Qt WebEngine",
|
"ru": "Окно входа Microsoft недоступно",
|
||||||
"en": "The Microsoft sign-in window is unavailable: this build has no Qt WebEngine"
|
"en": "The Microsoft sign-in window is unavailable"
|
||||||
},
|
},
|
||||||
"auth.msa.error.xboxNoToken": {
|
"auth.msa.error.xboxNoToken": {
|
||||||
"ru": "Xbox Live не вернул токен",
|
"ru": "Xbox Live не вернул токен",
|
||||||
@@ -288,8 +292,8 @@
|
|||||||
"en": "Pick a version"
|
"en": "Pick a version"
|
||||||
},
|
},
|
||||||
"build.error.closeGameFilesMoving": {
|
"build.error.closeGameFilesMoving": {
|
||||||
"ru": "Сначала закройте игру — её файлы сейчас переносятся",
|
"ru": "Файлы игры в процессе переноса, сначала закройте игру",
|
||||||
"en": "Close the game first — its files are being moved right now"
|
"en": "Game files are being moved, close the game first"
|
||||||
},
|
},
|
||||||
"build.error.filesIncomplete": {
|
"build.error.filesIncomplete": {
|
||||||
"ru": "Файлы сборки установлены не полностью: %1\nПовторите установку сборки.",
|
"ru": "Файлы сборки установлены не полностью: %1\nПовторите установку сборки.",
|
||||||
@@ -328,8 +332,8 @@
|
|||||||
"en": "The build was not switched: %1"
|
"en": "The build was not switched: %1"
|
||||||
},
|
},
|
||||||
"build.error.switchFailedDirty": {
|
"build.error.switchFailedDirty": {
|
||||||
"ru": "Сборка не переключена: %1\nСодержимое .minecraft осталось разобранным — повторите переключение.",
|
"ru": "Сборка не переключена: %1\n Повторите переключение.",
|
||||||
"en": "The build was not switched: %1\nThe contents of .minecraft are left half-applied — switch again."
|
"en": "The build was not switched: %1\n Switch again."
|
||||||
},
|
},
|
||||||
"build.error.versionNotInstalled": {
|
"build.error.versionNotInstalled": {
|
||||||
"ru": "Версия «%1» из сборки «%2» не установлена в %3",
|
"ru": "Версия «%1» из сборки «%2» не установлена в %3",
|
||||||
@@ -340,8 +344,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": "Это последняя сборка. Содержимое .minecraft останется на месте.",
|
"ru": "Последняя сборка, файлы из корня игры не удаляются.",
|
||||||
"en": "This is the last build. The contents of .minecraft will stay where they are."
|
"en": "Last build. The contents of .minecraft will stay."
|
||||||
},
|
},
|
||||||
"build.label.active": {
|
"build.label.active": {
|
||||||
"ru": "активна",
|
"ru": "активна",
|
||||||
@@ -360,8 +364,8 @@
|
|||||||
"en": "Server address"
|
"en": "Server address"
|
||||||
},
|
},
|
||||||
"build.placeholder.name": {
|
"build.placeholder.name": {
|
||||||
"ru": "как её видно в списке",
|
"ru": "Введите наименование",
|
||||||
"en": "as it appears in the list"
|
"en": "Write a name"
|
||||||
},
|
},
|
||||||
"build.placeholder.serverAddress": {
|
"build.placeholder.serverAddress": {
|
||||||
"ru": "host:port (необязательно)",
|
"ru": "host:port (необязательно)",
|
||||||
@@ -379,13 +383,21 @@
|
|||||||
"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": "Everything is in place, the build can be launched"
|
"en": "Ready to launch"
|
||||||
|
},
|
||||||
|
"build.title.fromGameDir": {
|
||||||
|
"ru": "Сборка из папки игры",
|
||||||
|
"en": "Build from the game folder"
|
||||||
},
|
},
|
||||||
"build.title.numbered": {
|
"build.title.numbered": {
|
||||||
"ru": "Сборка %1",
|
"ru": "Сборка %1",
|
||||||
@@ -396,8 +408,8 @@
|
|||||||
"en": "Remove the build?"
|
"en": "Remove the build?"
|
||||||
},
|
},
|
||||||
"build.warning.archiveGoesToo": {
|
"build.warning.archiveGoesToo": {
|
||||||
"ru": "Вместе с ней удалится её архив: моды, конфиги и миры этой сборки восстановить будет нельзя.",
|
"ru": "Внимание, при удалении так же очищаются пользовательские сохранённые архивы.",
|
||||||
"en": "Its archive goes with it: the mods, configs and worlds of this build cannot be recovered."
|
"en": "Attention, after removing from the list all saved files will be deleted."
|
||||||
},
|
},
|
||||||
"build.warning.archiveNotRemoved": {
|
"build.warning.archiveNotRemoved": {
|
||||||
"ru": "Сборка «%1» удалена, но её архив в %2 стереть не удалось",
|
"ru": "Сборка «%1» удалена, но её архив в %2 стереть не удалось",
|
||||||
@@ -1364,8 +1376,8 @@
|
|||||||
"en": "%1 — downloading"
|
"en": "%1 — downloading"
|
||||||
},
|
},
|
||||||
"settings.status.javaRuntimeNone": {
|
"settings.status.javaRuntimeNone": {
|
||||||
"ru": "не выбрана — искать в системе",
|
"ru": "не выбрана",
|
||||||
"en": "not selected — search the system"
|
"en": "not selected"
|
||||||
},
|
},
|
||||||
"settings.status.resolvedGameDir": {
|
"settings.status.resolvedGameDir": {
|
||||||
"ru": "Папка игры: %1",
|
"ru": "Папка игры: %1",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
IDI_ICON1 ICON "icon.ico"
|
|
||||||
@@ -15,9 +15,11 @@
|
|||||||
#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>
|
||||||
@@ -96,11 +98,20 @@ 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();
|
||||||
|
|
||||||
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
||||||
@@ -369,6 +380,10 @@ 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'));
|
||||||
@@ -433,7 +448,10 @@ void LauncherBackend::addCustomBuild(const QString &name,
|
|||||||
m_builds.append(build);
|
m_builds.append(build);
|
||||||
saveCustomBuilds();
|
saveCustomBuilds();
|
||||||
emit customBuildsChanged();
|
emit customBuildsChanged();
|
||||||
maybeInstall(launchVersionIdOf(m_builds.last()));
|
// Без версии игры ставить нечего: launchVersionIdOf() вернул бы имя сборки,
|
||||||
|
// и установщик пошёл бы искать в манифесте «Сборка 1».
|
||||||
|
if (!minecraftVersion.trimmed().isEmpty())
|
||||||
|
maybeInstall(minecraftVersion.trimmed());
|
||||||
}
|
}
|
||||||
|
|
||||||
void LauncherBackend::updateProfile(int index,
|
void LauncherBackend::updateProfile(int index,
|
||||||
@@ -1463,8 +1481,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 = m_settings.value(QStringLiteral("launcherName")).toString();
|
options.launcherName = QCoreApplication::applicationName();
|
||||||
options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
|
options.launcherVersion = QCoreApplication::applicationVersion();
|
||||||
|
|
||||||
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())
|
||||||
@@ -2479,6 +2497,36 @@ 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()
|
||||||
@@ -2543,8 +2591,6 @@ 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},
|
||||||
// Сервер готовых сборок. В интерфейс не вынесен: менять его
|
// Сервер готовых сборок. В интерфейс не вынесен: менять его
|
||||||
// приходится только при отладке против локального сервера.
|
// приходится только при отладке против локального сервера.
|
||||||
@@ -2553,6 +2599,12 @@ 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()
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ 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);
|
||||||
|
|
||||||
@@ -313,6 +316,9 @@ 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,21 +3,38 @@
|
|||||||
|
|
||||||
#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 QDir::homePath() + QStringLiteral("/AppData/Roaming");
|
return homeBase() + QStringLiteral("/AppData/Roaming");
|
||||||
#elif defined(Q_OS_MACOS)
|
#elif defined(Q_OS_MACOS)
|
||||||
return QDir::homePath() + QStringLiteral("/Library/Application Support");
|
return homeBase() + QStringLiteral("/Library/Application Support");
|
||||||
#else // Linux и прочие Unix-системы
|
#else // Linux и прочие Unix-системы
|
||||||
return QDir::homePath();
|
return homeBase();
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
#include "launcherpaths.h"
|
||||||
|
#include "launcherversion.h"
|
||||||
#include "localization.h"
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QIcon>
|
#include <QIcon>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
@@ -33,6 +37,10 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#ifndef AppVersion
|
||||||
|
#error Передайте версию: /DAppVersion=X.Y.Z
|
||||||
|
#endif
|
||||||
|
#ifndef SourceDir
|
||||||
|
#error Передайте префикс установки: /DSourceDir=...
|
||||||
|
#endif
|
||||||
|
#ifndef OutputDir
|
||||||
|
#define OutputDir "."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if Pos("-", AppVersion) > 0
|
||||||
|
#define NumericVersion Copy(AppVersion, 1, Pos("-", AppVersion) - 1)
|
||||||
|
#else
|
||||||
|
#define NumericVersion AppVersion
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
||||||
|
AppName=BiLauncher
|
||||||
|
AppVersion={#AppVersion}
|
||||||
|
AppVerName=BiLauncher {#AppVersion}
|
||||||
|
AppPublisher=GaleonDev
|
||||||
|
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
||||||
|
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
||||||
|
VersionInfoVersion={#NumericVersion}
|
||||||
|
VersionInfoTextVersion={#AppVersion}
|
||||||
|
VersionInfoProductTextVersion={#AppVersion}
|
||||||
|
DefaultDirName={autopf}\BiLauncher
|
||||||
|
DefaultGroupName=BiLauncher
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
||||||
|
OutputDir={#OutputDir}
|
||||||
|
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
||||||
|
SetupIconFile=..\..\icons\icon.ico
|
||||||
|
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
||||||
|
Compression=lzma2/ultra64
|
||||||
|
SolidCompression=yes
|
||||||
|
LZMANumBlockThreads=4
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
|
WizardStyle=modern
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
||||||
|
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||||
|
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
||||||
|
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||||
|
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
|
||||||
|
#endif
|
||||||
|
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Автотесты BiLauncher. Включаются опцией BILAUNCHER_BUILD_TESTS (корневой CMakeLists.txt).
|
||||||
|
#
|
||||||
|
# Код лаунчера не меняется ради тестов. Те же исходники собираются ещё раз в
|
||||||
|
# статическую библиотеку-QML-модуль bilauncher_testlib (URI BiLauncher): её
|
||||||
|
# линкуют все тесты — и C++-тесты сервисов, и Qt Quick Test для QML.
|
||||||
|
# Подробности — doc/testing.md.
|
||||||
|
|
||||||
|
find_package(Qt6 REQUIRED COMPONENTS Test QuickTest Network Qml Quick QuickControls2)
|
||||||
|
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
|
||||||
|
# --- Исходники лаунчера -----------------------------------------------------------
|
||||||
|
# Все .cpp/.h из корня, кроме main.cpp: новый файл попадёт в тесты сам.
|
||||||
|
file(GLOB LAUNCHER_TEST_CPP CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/*.cpp" "${PROJECT_SOURCE_DIR}/*.h")
|
||||||
|
list(FILTER LAUNCHER_TEST_CPP EXCLUDE REGEX "/main\\.cpp$")
|
||||||
|
|
||||||
|
# QML-файлы модуля. MicrosoftLoginDialog.qml нужен только со сборкой WebEngine;
|
||||||
|
# тестовая библиотека собирается без него (как сборка без Qt WebEngine), так что
|
||||||
|
# backend.microsoftAvailable в тестах всегда false.
|
||||||
|
file(GLOB LAUNCHER_TEST_QML RELATIVE "${PROJECT_SOURCE_DIR}" CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/*.qml")
|
||||||
|
list(REMOVE_ITEM LAUNCHER_TEST_QML MicrosoftLoginDialog.qml)
|
||||||
|
|
||||||
|
# Ресурсы модуля берём прямо из корневого CMakeLists.txt (строки «RESOURCES …»),
|
||||||
|
# чтобы список не пришлось вести в двух местах.
|
||||||
|
file(READ "${PROJECT_SOURCE_DIR}/CMakeLists.txt" _root_cmake)
|
||||||
|
string(REGEX MATCHALL "\n[ \t]*RESOURCES[^\n]*" _resource_lines "${_root_cmake}")
|
||||||
|
set(LAUNCHER_TEST_RESOURCES "")
|
||||||
|
foreach(_line IN LISTS _resource_lines)
|
||||||
|
string(REGEX REPLACE "^[ \t\n]*RESOURCES[ \t]+" "" _line "${_line}")
|
||||||
|
string(REGEX REPLACE "[ \t]+" ";" _files "${_line}")
|
||||||
|
list(APPEND LAUNCHER_TEST_RESOURCES ${_files})
|
||||||
|
endforeach()
|
||||||
|
list(REMOVE_ITEM LAUNCHER_TEST_RESOURCES "")
|
||||||
|
|
||||||
|
# Файлы QML-модуля лежат в корне проекта, а не в tests/: для qt_add_qml_module
|
||||||
|
# им нужны пути относительно текущего каталога, поэтому копируем их в дерево сборки.
|
||||||
|
set(_module_dir "${CMAKE_CURRENT_BINARY_DIR}/module_src")
|
||||||
|
set(_module_qml "")
|
||||||
|
foreach(_file IN LISTS LAUNCHER_TEST_QML LAUNCHER_TEST_RESOURCES)
|
||||||
|
configure_file("${PROJECT_SOURCE_DIR}/${_file}" "${_module_dir}/${_file}" COPYONLY)
|
||||||
|
endforeach()
|
||||||
|
foreach(_file IN LISTS LAUNCHER_TEST_QML)
|
||||||
|
list(APPEND _module_qml "${_module_dir}/${_file}")
|
||||||
|
set_source_files_properties("${_module_dir}/${_file}" PROPERTIES QT_RESOURCE_ALIAS "${_file}")
|
||||||
|
endforeach()
|
||||||
|
set(_module_res "")
|
||||||
|
foreach(_file IN LISTS LAUNCHER_TEST_RESOURCES)
|
||||||
|
list(APPEND _module_res "${_module_dir}/${_file}")
|
||||||
|
set_source_files_properties("${_module_dir}/${_file}" PROPERTIES QT_RESOURCE_ALIAS "${_file}")
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
qt_add_library(bilauncher_testlib STATIC)
|
||||||
|
qt_add_qml_module(bilauncher_testlib
|
||||||
|
URI BiLauncher
|
||||||
|
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
||||||
|
SOURCES ${LAUNCHER_TEST_CPP}
|
||||||
|
QML_FILES ${_module_qml}
|
||||||
|
RESOURCES ${_module_res}
|
||||||
|
NO_LINT
|
||||||
|
)
|
||||||
|
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}" "${LAUNCHER_GENERATED_DIR}")
|
||||||
|
add_dependencies(bilauncher_testlib launcher_version)
|
||||||
|
target_link_libraries(bilauncher_testlib PUBLIC
|
||||||
|
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Подставная java --------------------------------------------------------------
|
||||||
|
add_executable(fake_java fake_java/main.cpp)
|
||||||
|
set_target_properties(fake_java PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON
|
||||||
|
AUTOMOC OFF MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
|
||||||
|
|
||||||
|
# --- Общая обвязка тестов --------------------------------------------------------
|
||||||
|
add_library(bilauncher_testsupport STATIC
|
||||||
|
common/testenv.h common/testenv.cpp
|
||||||
|
common/fixtures.h common/fixtures.cpp
|
||||||
|
common/fakeinternet.h common/fakeinternet.cpp
|
||||||
|
common/fakemojang.h common/fakemojang.cpp
|
||||||
|
common/nettest.h
|
||||||
|
)
|
||||||
|
# Designated initializers (VersionSpec{.id = ...}) — C++20.
|
||||||
|
target_compile_features(bilauncher_testsupport PUBLIC cxx_std_20)
|
||||||
|
target_include_directories(bilauncher_testsupport PUBLIC common)
|
||||||
|
target_compile_definitions(bilauncher_testsupport PUBLIC
|
||||||
|
BILAUNCHER_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
|
||||||
|
BILAUNCHER_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
|
||||||
|
BILAUNCHER_FAKE_JAVA="$<TARGET_FILE:fake_java>"
|
||||||
|
)
|
||||||
|
target_link_libraries(bilauncher_testsupport PUBLIC bilauncher_testlib Qt6::Test Qt6::Network Qt6::CorePrivate)
|
||||||
|
add_dependencies(bilauncher_testsupport fake_java)
|
||||||
|
|
||||||
|
# Окружение всех тестов: без окон, без GPU, без кеша QML на диске.
|
||||||
|
set(BILAUNCHER_TEST_ENV
|
||||||
|
"QT_QPA_PLATFORM=offscreen"
|
||||||
|
"QT_QUICK_BACKEND=software"
|
||||||
|
"QML_DISABLE_DISK_CACHE=1"
|
||||||
|
"QT_LOGGING_RULES=qt.network.ssl.warning=false"
|
||||||
|
)
|
||||||
|
|
||||||
|
# bilauncher_add_test(<имя> LABEL <метка> SOURCES ... [TIMEOUT сек] [GUI])
|
||||||
|
function(bilauncher_add_test name)
|
||||||
|
cmake_parse_arguments(ARG "GUI" "LABEL;TIMEOUT" "SOURCES" ${ARGN})
|
||||||
|
add_executable(${name} ${ARG_SOURCES})
|
||||||
|
set_target_properties(${name} PROPERTIES MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
|
||||||
|
target_link_libraries(${name} PRIVATE bilauncher_testsupport)
|
||||||
|
if(ARG_GUI)
|
||||||
|
target_link_libraries(${name} PRIVATE bilauncher_testlibplugin Qt6::QuickTest)
|
||||||
|
endif()
|
||||||
|
add_test(NAME ${name} COMMAND ${name})
|
||||||
|
if(NOT ARG_TIMEOUT)
|
||||||
|
set(ARG_TIMEOUT 120)
|
||||||
|
endif()
|
||||||
|
set_tests_properties(${name} PROPERTIES
|
||||||
|
LABELS "${ARG_LABEL}"
|
||||||
|
TIMEOUT ${ARG_TIMEOUT}
|
||||||
|
ENVIRONMENT "${BILAUNCHER_TEST_ENV}")
|
||||||
|
if(WIN32)
|
||||||
|
# DLL Qt — из установки Qt, без windeployqt для каждого теста.
|
||||||
|
set_property(TEST ${name} APPEND PROPERTY ENVIRONMENT_MODIFICATION
|
||||||
|
"PATH=path_list_prepend:$<TARGET_FILE_DIR:Qt6::Core>")
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
add_subdirectory(unit)
|
||||||
|
add_subdirectory(net)
|
||||||
|
add_subdirectory(backend)
|
||||||
|
add_subdirectory(qml)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# LauncherBackend целиком: всё, что вызывает QML, с настоящими сервисами под ним.
|
||||||
|
bilauncher_add_test(tst_launcherbackend LABEL backend TIMEOUT 300 SOURCES tst_launcherbackend.cpp)
|
||||||
@@ -0,0 +1,809 @@
|
|||||||
|
// Интеграционные тесты 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"
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
#include "fakeinternet.h"
|
||||||
|
|
||||||
|
#include "fixtures.h"
|
||||||
|
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QHostAddress>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkProxy>
|
||||||
|
#include <QSslConfiguration>
|
||||||
|
#include <QSslKey>
|
||||||
|
#include <QSslSocket>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
struct FakeInternet::Connection
|
||||||
|
{
|
||||||
|
QSslSocket *socket = nullptr;
|
||||||
|
QByteArray buffer;
|
||||||
|
QString tunnelHost; // непусто — внутри CONNECT-туннеля (https)
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QByteArray reasonPhrase(int status)
|
||||||
|
{
|
||||||
|
switch (status) {
|
||||||
|
case 200: return "OK";
|
||||||
|
case 201: return "Created";
|
||||||
|
case 204: return "No Content";
|
||||||
|
case 301: return "Moved Permanently";
|
||||||
|
case 302: return "Found";
|
||||||
|
case 304: return "Not Modified";
|
||||||
|
case 400: return "Bad Request";
|
||||||
|
case 401: return "Unauthorized";
|
||||||
|
case 403: return "Forbidden";
|
||||||
|
case 404: return "Not Found";
|
||||||
|
case 410: return "Gone";
|
||||||
|
case 429: return "Too Many Requests";
|
||||||
|
case 500: return "Internal Server Error";
|
||||||
|
case 502: return "Bad Gateway";
|
||||||
|
case 503: return "Service Unavailable";
|
||||||
|
default: return "Status";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QUrl FakeRequest::url() const
|
||||||
|
{
|
||||||
|
QUrl result;
|
||||||
|
result.setScheme(scheme);
|
||||||
|
result.setHost(host);
|
||||||
|
result.setPath(path);
|
||||||
|
if (!query.isEmpty())
|
||||||
|
result.setQuery(query);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeResponse FakeResponse::json(const QByteArray &body, int status)
|
||||||
|
{
|
||||||
|
FakeResponse response;
|
||||||
|
response.status = status;
|
||||||
|
response.body = body;
|
||||||
|
response.contentType = "application/json";
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeResponse FakeResponse::json(const QJsonObject &object, int status)
|
||||||
|
{
|
||||||
|
return json(QJsonDocument(object).toJson(QJsonDocument::Compact), status);
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeResponse FakeResponse::text(const QByteArray &body, int status)
|
||||||
|
{
|
||||||
|
FakeResponse response;
|
||||||
|
response.status = status;
|
||||||
|
response.body = body;
|
||||||
|
response.contentType = "text/plain; charset=utf-8";
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeResponse FakeResponse::bytes(const QByteArray &body)
|
||||||
|
{
|
||||||
|
FakeResponse response;
|
||||||
|
response.body = body;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeResponse FakeResponse::error(int status, const QByteArray &body)
|
||||||
|
{
|
||||||
|
FakeResponse response = text(body.isEmpty() ? reasonPhrase(status) : body, status);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeInternet::FakeInternet(QObject *parent)
|
||||||
|
: QTcpServer(parent)
|
||||||
|
{
|
||||||
|
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeInternet::~FakeInternet()
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FakeInternet::tlsAvailable(QString *why)
|
||||||
|
{
|
||||||
|
if (!QSslSocket::supportsSsl()) {
|
||||||
|
if (why)
|
||||||
|
*why = QStringLiteral("в этой сборке Qt нет рабочего TLS-бэкенда");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FakeInternet::start(QString *error)
|
||||||
|
{
|
||||||
|
if (m_started)
|
||||||
|
return true;
|
||||||
|
if (!tlsAvailable(error))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
m_certificate = Fixtures::read(QStringLiteral("tls/cert.pem"));
|
||||||
|
m_key = Fixtures::read(QStringLiteral("tls/key.pem"));
|
||||||
|
|
||||||
|
if (!listen(QHostAddress::LocalHost, 0)) {
|
||||||
|
if (error)
|
||||||
|
*error = errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkProxy::setApplicationProxy(
|
||||||
|
QNetworkProxy(QNetworkProxy::HttpProxy, QStringLiteral("127.0.0.1"), serverPort()));
|
||||||
|
|
||||||
|
QSslConfiguration ssl = QSslConfiguration::defaultConfiguration();
|
||||||
|
ssl.setPeerVerifyMode(QSslSocket::VerifyNone);
|
||||||
|
QSslConfiguration::setDefaultConfiguration(ssl);
|
||||||
|
|
||||||
|
m_started = true;
|
||||||
|
|
||||||
|
// Самопроверка: TLS-бэкенд может не уметь серверный режим (бывает на
|
||||||
|
// отдельных сборках Qt под Windows/macOS). Лучше узнать сразу и внятно,
|
||||||
|
// чем получить десятки непонятных падений в тестах сервисов.
|
||||||
|
const QString probe = QStringLiteral("https://selftest.fake-internet.test/ping");
|
||||||
|
route(probe, FakeResponse::text("pong"));
|
||||||
|
QNetworkAccessManager manager;
|
||||||
|
QNetworkReply *reply = manager.get(QNetworkRequest(QUrl(probe)));
|
||||||
|
QEventLoop loop;
|
||||||
|
QTimer::singleShot(10000, &loop, &QEventLoop::quit);
|
||||||
|
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
|
if (!reply->isFinished())
|
||||||
|
loop.exec();
|
||||||
|
const bool ok = reply->isFinished() && reply->error() == QNetworkReply::NoError
|
||||||
|
&& reply->readAll() == "pong";
|
||||||
|
const QString why = reply->isFinished() ? reply->errorString() : QStringLiteral("таймаут 10 с");
|
||||||
|
delete reply;
|
||||||
|
reset();
|
||||||
|
if (!ok) {
|
||||||
|
stop();
|
||||||
|
if (error)
|
||||||
|
*error = QStringLiteral("TLS через локальный прокси не заработал (%1), TLS-бэкенд: %2")
|
||||||
|
.arg(why, QSslSocket::activeBackend());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::stop()
|
||||||
|
{
|
||||||
|
if (!m_started)
|
||||||
|
return;
|
||||||
|
// Никуда наружу и после остановки: прокси на закрытый порт.
|
||||||
|
close();
|
||||||
|
m_started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::route(const QString &pattern, const Handler &handler)
|
||||||
|
{
|
||||||
|
unroute(pattern);
|
||||||
|
const QUrl url(pattern.endsWith(u'*') ? pattern.chopped(1) : pattern);
|
||||||
|
Route route;
|
||||||
|
route.pattern = pattern;
|
||||||
|
route.host = url.host().toLower();
|
||||||
|
route.path = url.path().isEmpty() ? QStringLiteral("/") : url.path();
|
||||||
|
route.prefix = pattern.endsWith(u'*');
|
||||||
|
route.handler = handler;
|
||||||
|
m_routes << route;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::route(const QString &pattern, const FakeResponse &response)
|
||||||
|
{
|
||||||
|
route(pattern, [response](const FakeRequest &) { return response; });
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::unroute(const QString &pattern)
|
||||||
|
{
|
||||||
|
m_routes.removeIf([&](const Route &route) { return route.pattern == pattern; });
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::reset()
|
||||||
|
{
|
||||||
|
m_routes.clear();
|
||||||
|
m_requests.clear();
|
||||||
|
m_unmatched.clear();
|
||||||
|
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<FakeRequest> FakeInternet::requests() const
|
||||||
|
{
|
||||||
|
return m_requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
int FakeInternet::hits(const QString &url) const
|
||||||
|
{
|
||||||
|
const QUrl wanted(url);
|
||||||
|
int count = 0;
|
||||||
|
for (const FakeRequest &request : m_requests) {
|
||||||
|
if (request.host.compare(wanted.host(), Qt::CaseInsensitive) == 0
|
||||||
|
&& request.path == wanted.path())
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList FakeInternet::unmatched() const
|
||||||
|
{
|
||||||
|
return m_unmatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList FakeInternet::requestedUrls() const
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
for (const FakeRequest &request : m_requests)
|
||||||
|
result << request.url().toString();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::incomingConnection(qintptr descriptor)
|
||||||
|
{
|
||||||
|
auto *socket = new QSslSocket(this);
|
||||||
|
if (!socket->setSocketDescriptor(descriptor)) {
|
||||||
|
delete socket;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *connection = new Connection;
|
||||||
|
connection->socket = socket;
|
||||||
|
|
||||||
|
connect(socket, &QSslSocket::readyRead, this, [this, connection] { onReadyRead(connection); });
|
||||||
|
connect(socket, &QSslSocket::sslErrors, socket, [socket](const QList<QSslError> &) {
|
||||||
|
socket->ignoreSslErrors();
|
||||||
|
});
|
||||||
|
// Удаляем отложенно: disconnected может прийти синхронно посреди processBuffer().
|
||||||
|
connect(socket, &QSslSocket::disconnected, this, [socket, connection] {
|
||||||
|
socket->deleteLater();
|
||||||
|
QObject::connect(socket, &QObject::destroyed, [connection] { delete connection; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::onReadyRead(Connection *connection)
|
||||||
|
{
|
||||||
|
connection->buffer += connection->socket->readAll();
|
||||||
|
processBuffer(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::processBuffer(Connection *connection)
|
||||||
|
{
|
||||||
|
for (;;) {
|
||||||
|
const qsizetype headerEnd = connection->buffer.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QList<QByteArray> lines = connection->buffer.left(headerEnd).split('\n');
|
||||||
|
const QList<QByteArray> requestLine = lines.value(0).trimmed().split(' ');
|
||||||
|
if (requestLine.size() < 2) {
|
||||||
|
connection->socket->abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeRequest request;
|
||||||
|
request.method = requestLine.at(0);
|
||||||
|
for (qsizetype i = 1; i < lines.size(); ++i) {
|
||||||
|
const QByteArray line = lines.at(i).trimmed();
|
||||||
|
const qsizetype colon = line.indexOf(':');
|
||||||
|
if (colon > 0)
|
||||||
|
request.headers.insert(line.left(colon).trimmed().toLower(),
|
||||||
|
line.mid(colon + 1).trimmed());
|
||||||
|
}
|
||||||
|
|
||||||
|
const qsizetype bodySize = request.headers.value("content-length", "0").toLongLong();
|
||||||
|
if (connection->buffer.size() < headerEnd + 4 + bodySize)
|
||||||
|
return; // тело ещё не дошло
|
||||||
|
request.body = connection->buffer.mid(headerEnd + 4, bodySize);
|
||||||
|
connection->buffer.remove(0, headerEnd + 4 + bodySize);
|
||||||
|
|
||||||
|
const QByteArray target = requestLine.at(1);
|
||||||
|
|
||||||
|
if (request.method == "CONNECT" && connection->tunnelHost.isEmpty()) {
|
||||||
|
QString host = QString::fromLatin1(target);
|
||||||
|
host = host.left(host.lastIndexOf(u':')).toLower();
|
||||||
|
connection->tunnelHost = host;
|
||||||
|
connection->socket->write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
|
connection->socket->flush();
|
||||||
|
startTls(connection);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connection->tunnelHost.isEmpty()) {
|
||||||
|
request.scheme = QStringLiteral("https");
|
||||||
|
request.host = connection->tunnelHost;
|
||||||
|
const QUrl relative(QString::fromLatin1(target));
|
||||||
|
request.path = relative.path(QUrl::FullyDecoded);
|
||||||
|
request.query = relative.query(QUrl::FullyEncoded);
|
||||||
|
} else {
|
||||||
|
// Простой http через прокси — адрес в absolute-form.
|
||||||
|
const QUrl absolute(QString::fromLatin1(target));
|
||||||
|
request.scheme = absolute.scheme();
|
||||||
|
request.host = absolute.host().toLower();
|
||||||
|
request.path = absolute.path(QUrl::FullyDecoded);
|
||||||
|
request.query = absolute.query(QUrl::FullyEncoded);
|
||||||
|
}
|
||||||
|
if (request.path.isEmpty())
|
||||||
|
request.path = QStringLiteral("/");
|
||||||
|
|
||||||
|
dispatch(connection, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::startTls(Connection *connection)
|
||||||
|
{
|
||||||
|
QSslSocket *socket = connection->socket;
|
||||||
|
socket->setLocalCertificate(QSslCertificate(m_certificate, QSsl::Pem));
|
||||||
|
socket->setPrivateKey(QSslKey(m_key, QSsl::Rsa, QSsl::Pem));
|
||||||
|
socket->setPeerVerifyMode(QSslSocket::VerifyNone);
|
||||||
|
socket->startServerEncryption();
|
||||||
|
}
|
||||||
|
|
||||||
|
const FakeInternet::Route *FakeInternet::match(const FakeRequest &request) const
|
||||||
|
{
|
||||||
|
// Последний добавленный маршрут главнее: тест может перекрыть общий частным.
|
||||||
|
for (qsizetype i = m_routes.size() - 1; i >= 0; --i) {
|
||||||
|
const Route &route = m_routes.at(i);
|
||||||
|
if (route.host != request.host)
|
||||||
|
continue;
|
||||||
|
if (route.prefix ? request.path.startsWith(route.path) : request.path == route.path)
|
||||||
|
return &route;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
||||||
|
{
|
||||||
|
m_requests << request;
|
||||||
|
const QString url = request.url().toString();
|
||||||
|
|
||||||
|
FakeResponse response;
|
||||||
|
if (const Route *route = match(request)) {
|
||||||
|
response = route->handler(request);
|
||||||
|
} else {
|
||||||
|
m_unmatched << url;
|
||||||
|
qWarning("FakeInternet: нет маршрута для %s %s", request.method.constData(),
|
||||||
|
qPrintable(url));
|
||||||
|
response = m_fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool keepAlive = request.header("connection").toLower() != "close";
|
||||||
|
QPointer<QTcpSocket> socket(connection->socket);
|
||||||
|
if (response.hang)
|
||||||
|
return;
|
||||||
|
if (response.delayMs > 0) {
|
||||||
|
QTimer::singleShot(response.delayMs, this,
|
||||||
|
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
||||||
|
} else {
|
||||||
|
respond(socket, response, keepAlive);
|
||||||
|
}
|
||||||
|
emit requestServed(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive)
|
||||||
|
{
|
||||||
|
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||||
|
return;
|
||||||
|
if (response.drop) {
|
||||||
|
socket->abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray head = "HTTP/1.1 " + QByteArray::number(response.status) + ' '
|
||||||
|
+ reasonPhrase(response.status) + "\r\n";
|
||||||
|
head += "Content-Type: " + response.contentType + "\r\n";
|
||||||
|
head += "Content-Length: " + QByteArray::number(response.body.size()) + "\r\n";
|
||||||
|
for (const auto &[name, value] : response.headers)
|
||||||
|
head += name + ": " + value + "\r\n";
|
||||||
|
const bool close = !keepAlive || response.truncateTo >= 0;
|
||||||
|
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
||||||
|
head += "\r\n";
|
||||||
|
|
||||||
|
socket->write(head);
|
||||||
|
if (response.truncateTo >= 0) {
|
||||||
|
socket->write(response.body.left(response.truncateTo));
|
||||||
|
socket->flush();
|
||||||
|
socket->disconnectFromHost();
|
||||||
|
// Schannel (Windows) на disconnectFromHost шлёт только TLS close_notify и держит
|
||||||
|
// TCP открытым, ожидая ответного close_notify, — клиент тогда висит до таймаута.
|
||||||
|
// Обрыв на то и обрыв: если сокет не закрылся сам, рвём соединение.
|
||||||
|
QTimer::singleShot(200, socket, [socket] {
|
||||||
|
if (socket && socket->state() != QAbstractSocket::UnconnectedState)
|
||||||
|
socket->abort();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket->write(response.body);
|
||||||
|
if (close)
|
||||||
|
socket->disconnectFromHost();
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// «Фейковый интернет» для тестов сетевых сервисов без правки их кода.
|
||||||
|
//
|
||||||
|
// Сервисы лаунчера ходят на захардкоженные https-адреса через собственные
|
||||||
|
// QNetworkAccessManager. Прокси и SSL-конфигурация по умолчанию у Qt общие
|
||||||
|
// на весь процесс, поэтому start():
|
||||||
|
// - ставит QNetworkProxy::setApplicationProxy() на локальный порт;
|
||||||
|
// - отключает проверку сертификата в QSslConfiguration::defaultConfiguration().
|
||||||
|
// Дальше каждое https-соединение приходит сюда как CONNECT host:443; сокет
|
||||||
|
// переводится в TLS с тестовым самоподписанным сертификатом и обслуживается
|
||||||
|
// как обычный HTTP/1.1 — по таблице маршрутов (host + path).
|
||||||
|
//
|
||||||
|
// Запрос на неизвестный адрес получает 404 и попадает в unmatched(): тест
|
||||||
|
// может проверить, что код не ушёл куда-то, куда не ожидали.
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QList>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPair>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTcpServer>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class QSslSocket;
|
||||||
|
class QTcpSocket;
|
||||||
|
|
||||||
|
struct FakeRequest
|
||||||
|
{
|
||||||
|
QByteArray method;
|
||||||
|
QString scheme; // https | http
|
||||||
|
QString host;
|
||||||
|
QString path;
|
||||||
|
QString query; // без «?»
|
||||||
|
QHash<QByteArray, QByteArray> headers; // имена в нижнем регистре
|
||||||
|
QByteArray body;
|
||||||
|
|
||||||
|
QUrl url() const;
|
||||||
|
QUrlQuery queryItems() const { return QUrlQuery(query); }
|
||||||
|
QUrlQuery formItems() const { return QUrlQuery(QString::fromUtf8(body)); }
|
||||||
|
QByteArray header(const QByteArray &name) const { return headers.value(name.toLower()); }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FakeResponse
|
||||||
|
{
|
||||||
|
int status = 200;
|
||||||
|
QByteArray body;
|
||||||
|
QByteArray contentType = "application/octet-stream";
|
||||||
|
QList<QPair<QByteArray, QByteArray>> headers;
|
||||||
|
int delayMs = 0; // ответить с задержкой
|
||||||
|
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
||||||
|
bool drop = false; // оборвать соединение без ответа
|
||||||
|
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
||||||
|
|
||||||
|
static FakeResponse json(const QByteArray &body, int status = 200);
|
||||||
|
static FakeResponse json(const class QJsonObject &object, int status = 200);
|
||||||
|
static FakeResponse text(const QByteArray &body, int status = 200);
|
||||||
|
static FakeResponse bytes(const QByteArray &body);
|
||||||
|
static FakeResponse error(int status, const QByteArray &body = {});
|
||||||
|
};
|
||||||
|
|
||||||
|
class FakeInternet : public QTcpServer
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
using Handler = std::function<FakeResponse(const FakeRequest &)>;
|
||||||
|
|
||||||
|
explicit FakeInternet(QObject *parent = nullptr);
|
||||||
|
~FakeInternet() override;
|
||||||
|
|
||||||
|
// Слушает 127.0.0.1 и перенаправляет на себя весь трафик процесса.
|
||||||
|
// При ошибке пишет причину в error (например, нет TLS-бэкенда с поддержкой сервера).
|
||||||
|
bool start(QString *error = nullptr);
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
// Есть ли TLS вообще (без него сетевые тесты пропускаются).
|
||||||
|
static bool tlsAvailable(QString *why = nullptr);
|
||||||
|
|
||||||
|
// Маршрут: "https://host/path" — точное совпадение пути (query не учитывается),
|
||||||
|
// "https://host/prefix/*" — всё, что начинается с prefix/.
|
||||||
|
void route(const QString &pattern, const Handler &handler);
|
||||||
|
void route(const QString &pattern, const FakeResponse &response);
|
||||||
|
void unroute(const QString &pattern);
|
||||||
|
|
||||||
|
// Ответ на всё, что не подошло ни к одному маршруту (по умолчанию 404).
|
||||||
|
void setFallback(const FakeResponse &response) { m_fallback = response; }
|
||||||
|
|
||||||
|
// Сбросить маршруты и журнал.
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
QList<FakeRequest> requests() const;
|
||||||
|
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
||||||
|
int hits(const QString &url) const;
|
||||||
|
QStringList unmatched() const;
|
||||||
|
QStringList requestedUrls() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void requestServed(const QString &url);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void incomingConnection(qintptr descriptor) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Connection;
|
||||||
|
struct Route
|
||||||
|
{
|
||||||
|
QString pattern;
|
||||||
|
QString host;
|
||||||
|
QString path;
|
||||||
|
bool prefix = false;
|
||||||
|
Handler handler;
|
||||||
|
};
|
||||||
|
|
||||||
|
void onReadyRead(Connection *connection);
|
||||||
|
void processBuffer(Connection *connection);
|
||||||
|
void dispatch(Connection *connection, FakeRequest request);
|
||||||
|
void respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive);
|
||||||
|
void startTls(Connection *connection);
|
||||||
|
const Route *match(const FakeRequest &request) const;
|
||||||
|
|
||||||
|
QList<Route> m_routes;
|
||||||
|
FakeResponse m_fallback;
|
||||||
|
QList<FakeRequest> m_requests;
|
||||||
|
QStringList m_unmatched;
|
||||||
|
QByteArray m_certificate;
|
||||||
|
QByteArray m_key;
|
||||||
|
bool m_started = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#include "fakemojang.h"
|
||||||
|
|
||||||
|
#include "fixtures.h"
|
||||||
|
|
||||||
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const QString kMeta = QStringLiteral("https://piston-meta.mojang.com/v1/packages/");
|
||||||
|
const QString kData = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
|
||||||
|
const QString kLibraries = QStringLiteral("https://libraries.minecraft.net/");
|
||||||
|
const QString kResources = QStringLiteral("https://resources.download.minecraft.net/");
|
||||||
|
|
||||||
|
QJsonObject download(const QString &url, const QByteArray &data, const QString &path = {})
|
||||||
|
{
|
||||||
|
QJsonObject object{{"url", url}, {"sha1", Fixtures::sha1(data)}, {"size", data.size()}};
|
||||||
|
if (!path.isEmpty())
|
||||||
|
object.insert("path", path);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
FakeMojang::FakeMojang(FakeInternet &net)
|
||||||
|
: m_net(net)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FakeMojang::manifestUrl()
|
||||||
|
{
|
||||||
|
return QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FakeMojang::addObject(const QString &relativePath, const QByteArray &data)
|
||||||
|
{
|
||||||
|
QString url;
|
||||||
|
if (relativePath.startsWith(QLatin1String("libraries/")))
|
||||||
|
url = kLibraries + relativePath.mid(int(sizeof("libraries/") - 1));
|
||||||
|
else if (relativePath.startsWith(QLatin1String("assets/objects/")))
|
||||||
|
url = kResources + relativePath.mid(int(sizeof("assets/objects/") - 1));
|
||||||
|
else
|
||||||
|
url = kData + Fixtures::sha1(data) + u'/' + relativePath.section(u'/', -1);
|
||||||
|
|
||||||
|
m_files.insert(relativePath, {url, data});
|
||||||
|
m_net.route(url, FakeResponse::bytes(data));
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeMojang::overrideFile(const QString &relativePath, const FakeResponse &response)
|
||||||
|
{
|
||||||
|
m_net.route(m_files.value(relativePath).url, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FakeMojang::fileUrl(const QString &relativePath) const
|
||||||
|
{
|
||||||
|
return m_files.value(relativePath).url;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeMojang::publish(const VersionSpec &spec)
|
||||||
|
{
|
||||||
|
QHash<QString, QByteArray> expected;
|
||||||
|
QJsonObject json{{"id", spec.id}, {"type", spec.type}, {"releaseTime", spec.releaseTime}};
|
||||||
|
|
||||||
|
if (!spec.inheritsFrom.isEmpty()) {
|
||||||
|
json.insert("inheritsFrom", spec.inheritsFrom);
|
||||||
|
json.insert("mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient");
|
||||||
|
const QString libPath = QStringLiteral("net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar");
|
||||||
|
const QByteArray libData = "fabric-loader-" + spec.id.toUtf8();
|
||||||
|
const QString url = addObject("libraries/" + libPath, libData);
|
||||||
|
json.insert("libraries", QJsonArray{QJsonObject{
|
||||||
|
{"name", "net.fabricmc:fabric-loader:0.16.10"},
|
||||||
|
{"downloads", QJsonObject{{"artifact", download(url, libData, libPath)}}}}});
|
||||||
|
expected.insert("libraries/" + libPath, libData);
|
||||||
|
} else {
|
||||||
|
json.insert("mainClass", "net.minecraft.client.main.Main");
|
||||||
|
json.insert("arguments", QJsonObject{
|
||||||
|
{"game", QJsonArray{"--username", "${auth_player_name}", "--version", "${version_name}",
|
||||||
|
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
|
||||||
|
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
|
||||||
|
"--accessToken", "${auth_access_token}", "--userType", "${user_type}",
|
||||||
|
"--versionType", "${version_type}"}},
|
||||||
|
{"jvm", QJsonArray{"-Djava.library.path=${natives_directory}", "-cp", "${classpath}"}}});
|
||||||
|
json.insert("javaVersion", QJsonObject{{"component", "java-runtime-gamma"}, {"majorVersion", spec.javaMajor}});
|
||||||
|
|
||||||
|
// client.jar
|
||||||
|
const QString clientPath = "versions/" + spec.id + u'/' + spec.id + ".jar";
|
||||||
|
const QByteArray clientData = "client-jar-of-" + spec.id.toUtf8();
|
||||||
|
const QString clientUrl = addObject(clientPath, clientData);
|
||||||
|
json.insert("downloads", QJsonObject{{"client", download(clientUrl, clientData)}});
|
||||||
|
expected.insert(clientPath, clientData);
|
||||||
|
|
||||||
|
// Обычная библиотека и натив под текущую платформу.
|
||||||
|
const QString native = VersionLoader::nativeClassifier();
|
||||||
|
const QString libPath = QStringLiteral("com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||||
|
const QByteArray libData = "brigadier";
|
||||||
|
const QString nativePath = QStringLiteral("org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-%1.jar").arg(native);
|
||||||
|
const QByteArray nativeData = Fixtures::zipBytes({{"liblwjgl.so", "so"}, {"lwjgl.dll", "dll"},
|
||||||
|
{"liblwjgl.dylib", "dylib"}});
|
||||||
|
const QString libUrl = addObject("libraries/" + libPath, libData);
|
||||||
|
const QString nativeUrl = addObject("libraries/" + nativePath, nativeData);
|
||||||
|
json.insert("libraries", QJsonArray{
|
||||||
|
QJsonObject{{"name", "com.mojang:brigadier:1.1.8"},
|
||||||
|
{"downloads", QJsonObject{{"artifact", download(libUrl, libData, libPath)}}}},
|
||||||
|
QJsonObject{{"name", "org.lwjgl:lwjgl:3.3.1:" + native},
|
||||||
|
{"downloads", QJsonObject{{"artifact", download(nativeUrl, nativeData, nativePath)}}},
|
||||||
|
{"rules", QJsonArray{QJsonObject{{"action", "allow"},
|
||||||
|
{"os", QJsonObject{{"name", VersionLoader::osName()}}}}}}}});
|
||||||
|
expected.insert("libraries/" + libPath, libData);
|
||||||
|
expected.insert("libraries/" + nativePath, nativeData);
|
||||||
|
|
||||||
|
// Ассеты.
|
||||||
|
const QString indexId = spec.legacyAssets ? QStringLiteral("legacy") : spec.id + "-assets";
|
||||||
|
QJsonObject objects;
|
||||||
|
qint64 total = 0;
|
||||||
|
for (const QString &name : {QStringLiteral("minecraft/sounds/click.ogg"), QStringLiteral("icons/icon_16x16.png")}) {
|
||||||
|
const QByteArray data = (name + "@" + spec.id).toUtf8();
|
||||||
|
const QString hash = Fixtures::sha1(data);
|
||||||
|
const QString objectPath = "assets/objects/" + hash.left(2) + u'/' + hash;
|
||||||
|
addObject(objectPath, data);
|
||||||
|
objects.insert(name, QJsonObject{{"hash", hash}, {"size", data.size()}});
|
||||||
|
expected.insert(objectPath, data);
|
||||||
|
if (spec.legacyAssets)
|
||||||
|
expected.insert("assets/virtual/legacy/" + name, data);
|
||||||
|
total += data.size();
|
||||||
|
}
|
||||||
|
QJsonObject index{{"objects", objects}};
|
||||||
|
if (spec.legacyAssets)
|
||||||
|
index.insert("virtual", true);
|
||||||
|
const QByteArray indexData = QJsonDocument(index).toJson(QJsonDocument::Compact);
|
||||||
|
const QString indexPath = "assets/indexes/" + indexId + ".json";
|
||||||
|
const QString indexUrl = addObject(indexPath, indexData);
|
||||||
|
QJsonObject assetIndex = download(indexUrl, indexData);
|
||||||
|
assetIndex.insert("id", indexId);
|
||||||
|
assetIndex.insert("totalSize", total);
|
||||||
|
json.insert("assetIndex", assetIndex);
|
||||||
|
json.insert("assets", indexId);
|
||||||
|
expected.insert(indexPath, indexData);
|
||||||
|
|
||||||
|
// Конфиг логирования.
|
||||||
|
const QByteArray logData = "<Configuration/>";
|
||||||
|
const QString logPath = QStringLiteral("assets/log_configs/client-1.12.xml");
|
||||||
|
const QString logUrl = addObject(logPath, logData);
|
||||||
|
QJsonObject logFile = download(logUrl, logData);
|
||||||
|
logFile.insert("id", "client-1.12.xml");
|
||||||
|
json.insert("logging", QJsonObject{{"client", QJsonObject{
|
||||||
|
{"argument", "-Dlog4j.configurationFile=${path}"},
|
||||||
|
{"file", logFile}, {"type", "log4j2-xml"}}}});
|
||||||
|
expected.insert(logPath, logData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray jsonData = QJsonDocument(json).toJson(QJsonDocument::Compact);
|
||||||
|
m_versionJson.insert(spec.id, jsonData);
|
||||||
|
expected.insert("versions/" + spec.id + u'/' + spec.id + ".json", jsonData);
|
||||||
|
m_expected.insert(spec.id, expected);
|
||||||
|
m_net.route(versionJsonUrl(spec.id), FakeResponse::json(jsonData));
|
||||||
|
|
||||||
|
m_specs.removeIf([&](const VersionSpec &s) { return s.id == spec.id; });
|
||||||
|
m_specs << spec;
|
||||||
|
routeManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FakeMojang::versionJsonUrl(const QString &id) const
|
||||||
|
{
|
||||||
|
return kMeta + Fixtures::sha1(m_versionJson.value(id)) + u'/' + id + ".json";
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray FakeMojang::versionJson(const QString &id) const
|
||||||
|
{
|
||||||
|
return m_versionJson.value(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FakeMojang::routeManifest()
|
||||||
|
{
|
||||||
|
QJsonArray versions;
|
||||||
|
for (const VersionSpec &spec : std::as_const(m_specs)) {
|
||||||
|
if (!spec.listed)
|
||||||
|
continue;
|
||||||
|
versions << QJsonObject{{"id", spec.id},
|
||||||
|
{"type", spec.type},
|
||||||
|
{"url", versionJsonUrl(spec.id)},
|
||||||
|
{"sha1", Fixtures::sha1(m_versionJson.value(spec.id))},
|
||||||
|
{"releaseTime", spec.releaseTime},
|
||||||
|
{"time", spec.releaseTime}};
|
||||||
|
}
|
||||||
|
m_net.route(manifestUrl(), FakeResponse::json(QJsonObject{{"versions", versions}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<QString, QByteArray> FakeMojang::expectedFiles(const QString &id) const
|
||||||
|
{
|
||||||
|
return m_expected.value(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Миниатюрный Mojang для FakeInternet: манифест версий, json версий, client.jar,
|
||||||
|
// библиотеки (в т.ч. натив под текущую ОС), индекс ассетов, сами ассеты и
|
||||||
|
// конфиг логирования. Содержимое крошечное, но устроено как настоящее —
|
||||||
|
// VersionInstaller и GameLauncher работают с ним без поблажек.
|
||||||
|
|
||||||
|
#include "fakeinternet.h"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class FakeMojang
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
struct VersionSpec
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QString type = QStringLiteral("release");
|
||||||
|
QString inheritsFrom; // непусто — «дочерняя» версия без своих файлов
|
||||||
|
bool legacyAssets = false; // индекс с virtual: true (как у 1.6 и старше)
|
||||||
|
int javaMajor = 17;
|
||||||
|
QString releaseTime = QStringLiteral("2023-06-12T13:25:51+00:00");
|
||||||
|
bool listed = true; // попадает ли в манифест
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit FakeMojang(FakeInternet &net);
|
||||||
|
|
||||||
|
// Публикует версию: json + все файлы, на которые он ссылается.
|
||||||
|
void publish(const VersionSpec &spec);
|
||||||
|
// Переопубликовать манифест (после publish всех версий вызывается автоматически).
|
||||||
|
void routeManifest();
|
||||||
|
|
||||||
|
// Подменить ответ на конкретный файл (по относительному пути внутри .minecraft,
|
||||||
|
// например "libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar").
|
||||||
|
void overrideFile(const QString &relativePath, const FakeResponse &response);
|
||||||
|
|
||||||
|
// Что должно лечь на диск после установки: относительный путь → содержимое.
|
||||||
|
QHash<QString, QByteArray> expectedFiles(const QString &id) const;
|
||||||
|
// URL json версии и URL конкретного файла.
|
||||||
|
QString versionJsonUrl(const QString &id) const;
|
||||||
|
QString fileUrl(const QString &relativePath) const;
|
||||||
|
QByteArray versionJson(const QString &id) const;
|
||||||
|
|
||||||
|
static QString manifestUrl();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct File
|
||||||
|
{
|
||||||
|
QString url;
|
||||||
|
QByteArray data;
|
||||||
|
};
|
||||||
|
|
||||||
|
QString addObject(const QString &relativePath, const QByteArray &data);
|
||||||
|
|
||||||
|
FakeInternet &m_net;
|
||||||
|
QList<VersionSpec> m_specs;
|
||||||
|
QHash<QString, QByteArray> m_versionJson; // id → json
|
||||||
|
QHash<QString, QHash<QString, QByteArray>> m_expected; // id → (путь → данные)
|
||||||
|
QHash<QString, File> m_files; // относительный путь → файл
|
||||||
|
};
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#include "fixtures.h"
|
||||||
|
|
||||||
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
|
#include <QBuffer>
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QtCore/private/qzipreader_p.h>
|
||||||
|
#include <QtCore/private/qzipwriter_p.h>
|
||||||
|
|
||||||
|
QString Fixtures::path(const QString &relative)
|
||||||
|
{
|
||||||
|
return QStringLiteral(BILAUNCHER_FIXTURES_DIR) + u'/' + relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray Fixtures::read(const QString &relative)
|
||||||
|
{
|
||||||
|
QFile file(path(relative));
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
qFatal("Нет фикстуры %s", qPrintable(file.fileName()));
|
||||||
|
return file.readAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject Fixtures::readJson(const QString &relative)
|
||||||
|
{
|
||||||
|
return QJsonDocument::fromJson(read(relative)).object();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Fixtures::otherOsName()
|
||||||
|
{
|
||||||
|
return VersionLoader::osName() == QLatin1String("windows") ? QStringLiteral("linux")
|
||||||
|
: QStringLiteral("windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray Fixtures::applyTemplate(QByteArray text)
|
||||||
|
{
|
||||||
|
text.replace("%OS%", VersionLoader::osName().toUtf8());
|
||||||
|
text.replace("%OTHER%", otherOsName().toUtf8());
|
||||||
|
text.replace("%ARCH%", VersionLoader::osArch().toUtf8());
|
||||||
|
text.replace("%NATIVE%", VersionLoader::nativeClassifier().toUtf8());
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray Fixtures::readTemplate(const QString &relative)
|
||||||
|
{
|
||||||
|
return applyTemplate(read(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Fixtures::write(const QString &filePath, const QByteArray &data)
|
||||||
|
{
|
||||||
|
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||||
|
return false;
|
||||||
|
return file.write(data) == data.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Fixtures::writeJson(const QString &filePath, const QJsonObject &object)
|
||||||
|
{
|
||||||
|
return write(filePath, QJsonDocument(object).toJson(QJsonDocument::Indented));
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray Fixtures::readFile(const QString &filePath)
|
||||||
|
{
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return {};
|
||||||
|
return file.readAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject Fixtures::readJsonFile(const QString &filePath)
|
||||||
|
{
|
||||||
|
return QJsonDocument::fromJson(readFile(filePath)).object();
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray Fixtures::zipBytes(const QMap<QString, QByteArray> &entries)
|
||||||
|
{
|
||||||
|
QBuffer buffer;
|
||||||
|
buffer.open(QIODevice::WriteOnly);
|
||||||
|
{
|
||||||
|
QZipWriter writer(&buffer);
|
||||||
|
writer.setCompressionPolicy(QZipWriter::AutoCompress);
|
||||||
|
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
|
||||||
|
if (it.key().endsWith(u'/'))
|
||||||
|
writer.addDirectory(it.key());
|
||||||
|
else
|
||||||
|
writer.addFile(it.key(), it.value());
|
||||||
|
}
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
return buffer.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Fixtures::makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries)
|
||||||
|
{
|
||||||
|
return write(zipPath, zipBytes(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList Fixtures::zipEntries(const QString &zipPath)
|
||||||
|
{
|
||||||
|
QZipReader reader(zipPath);
|
||||||
|
QStringList result;
|
||||||
|
for (const QZipReader::FileInfo &info : reader.fileInfoList()) {
|
||||||
|
if (info.isFile)
|
||||||
|
result << info.filePath;
|
||||||
|
}
|
||||||
|
result.sort();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Fixtures::makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries)
|
||||||
|
{
|
||||||
|
QTemporaryDir staging;
|
||||||
|
if (!staging.isValid())
|
||||||
|
return false;
|
||||||
|
QStringList tops;
|
||||||
|
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
|
||||||
|
if (!write(staging.path() + u'/' + it.key(), it.value()))
|
||||||
|
return false;
|
||||||
|
const QString top = it.key().section(u'/', 0, 0);
|
||||||
|
if (!tops.contains(top))
|
||||||
|
tops << top;
|
||||||
|
}
|
||||||
|
QDir().mkpath(QFileInfo(archivePath).absolutePath());
|
||||||
|
|
||||||
|
QProcess tar;
|
||||||
|
tar.setWorkingDirectory(staging.path());
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
// Системный bsdtar по полному пути: GNU tar из Git, который бывает раньше в PATH,
|
||||||
|
// принимает «C:» за имя удалённого хоста (та же история в release.yml).
|
||||||
|
const QString program = qEnvironmentVariable("SystemRoot") + QStringLiteral("\\System32\\tar.exe");
|
||||||
|
#else
|
||||||
|
const QString program = QStringLiteral("tar");
|
||||||
|
#endif
|
||||||
|
tar.start(program,
|
||||||
|
QStringList{QStringLiteral("-czf"), QDir::toNativeSeparators(archivePath)} + tops);
|
||||||
|
return tar.waitForFinished(30000) && tar.exitStatus() == QProcess::NormalExit
|
||||||
|
&& tar.exitCode() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Fixtures::sha1(const QByteArray &data)
|
||||||
|
{
|
||||||
|
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha1).toHex());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Fixtures::sha256(const QByteArray &data)
|
||||||
|
{
|
||||||
|
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex());
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList Fixtures::listTree(const QString &dir)
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
QDirIterator it(dir, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot,
|
||||||
|
QDirIterator::Subdirectories);
|
||||||
|
const QDir base(dir);
|
||||||
|
while (it.hasNext())
|
||||||
|
result << base.relativeFilePath(it.next());
|
||||||
|
result.sort();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Fixtures::javaExecutableName()
|
||||||
|
{
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
return QStringLiteral("java.exe");
|
||||||
|
#else
|
||||||
|
return QStringLiteral("java");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Fixtures::configureFakeJava(const QString &javaPath, const QString &version,
|
||||||
|
const QStringList &config)
|
||||||
|
{
|
||||||
|
QStringList lines{QStringLiteral("version=") + version};
|
||||||
|
lines += config;
|
||||||
|
write(QFileInfo(javaPath).absolutePath() + QStringLiteral("/fake-java.cfg"),
|
||||||
|
lines.join(u'\n').toUtf8() + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Fixtures::installFakeJava(const QString &dir, const QString &version,
|
||||||
|
const QStringList &config)
|
||||||
|
{
|
||||||
|
const QString binDir = dir + QStringLiteral("/bin");
|
||||||
|
QDir().mkpath(binDir);
|
||||||
|
const QString java = binDir + u'/' + javaExecutableName();
|
||||||
|
QFile::remove(java);
|
||||||
|
if (!QFile::copy(QStringLiteral(BILAUNCHER_FAKE_JAVA), java))
|
||||||
|
qFatal("Не удалось скопировать fake_java в %s", qPrintable(java));
|
||||||
|
QFile::setPermissions(java, QFile::permissions(java) | QFileDevice::ExeOwner
|
||||||
|
| QFileDevice::ExeGroup | QFileDevice::ExeOther);
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
const QString javaw = binDir + QStringLiteral("/javaw.exe");
|
||||||
|
QFile::remove(javaw);
|
||||||
|
QFile::copy(java, javaw);
|
||||||
|
#endif
|
||||||
|
configureFakeJava(java, version, config);
|
||||||
|
return java;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Фикстуры и мелкие помощники для подготовки файлов в песочнице.
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
namespace Fixtures {
|
||||||
|
|
||||||
|
// Абсолютный путь к tests/fixtures/<relative>.
|
||||||
|
QString path(const QString &relative);
|
||||||
|
|
||||||
|
// Содержимое фикстуры; при отсутствии файла тест падает через qFatal.
|
||||||
|
QByteArray read(const QString &relative);
|
||||||
|
QJsonObject readJson(const QString &relative);
|
||||||
|
|
||||||
|
// Фикстура-шаблон: %OS%, %OTHER%, %ARCH%, %NATIVE% заменяются на значения
|
||||||
|
// текущей платформы (имя ОС в терминах Mojang, «чужая» ОС, архитектура,
|
||||||
|
// классификатор нативов LWJGL) — один json годится для всех трёх ОС.
|
||||||
|
QByteArray readTemplate(const QString &relative);
|
||||||
|
QByteArray applyTemplate(QByteArray text);
|
||||||
|
QString otherOsName();
|
||||||
|
|
||||||
|
// Пишет файл, создавая недостающие папки.
|
||||||
|
bool write(const QString &path, const QByteArray &data);
|
||||||
|
bool writeJson(const QString &path, const QJsonObject &object);
|
||||||
|
QByteArray readFile(const QString &path);
|
||||||
|
QJsonObject readJsonFile(const QString &path);
|
||||||
|
|
||||||
|
// Zip из пар «путь внутри архива → содержимое» (через QZipWriter, как в коде лаунчера).
|
||||||
|
bool makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries);
|
||||||
|
QByteArray zipBytes(const QMap<QString, QByteArray> &entries);
|
||||||
|
// Список файлов в zip — как его видит QZipReader (ведущие «.» и «/» он срезает).
|
||||||
|
QStringList zipEntries(const QString &zipPath);
|
||||||
|
|
||||||
|
// tar.gz через системный tar (есть и на Windows 10+, и на Unix).
|
||||||
|
bool makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries);
|
||||||
|
|
||||||
|
QString sha1(const QByteArray &data);
|
||||||
|
QString sha256(const QByteArray &data);
|
||||||
|
|
||||||
|
// Все файлы под папкой (относительные пути, «/» в качестве разделителя), отсортированы.
|
||||||
|
QStringList listTree(const QString &dir);
|
||||||
|
|
||||||
|
// Подставная java (см. tests/fake_java). Копирует бинарник в <dir>/bin/java[.exe]
|
||||||
|
// (на Windows ещё и javaw.exe) и пишет рядом fake-java.cfg из строк config.
|
||||||
|
// Возвращает путь к java.
|
||||||
|
QString installFakeJava(const QString &dir, const QString &version,
|
||||||
|
const QStringList &config = {});
|
||||||
|
// Переписать fake-java.cfg у уже установленной подставной java.
|
||||||
|
void configureFakeJava(const QString &javaPath, const QString &version,
|
||||||
|
const QStringList &config);
|
||||||
|
|
||||||
|
QString javaExecutableName();
|
||||||
|
|
||||||
|
} // namespace Fixtures
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Помощники для тестов сетевых сервисов.
|
||||||
|
|
||||||
|
#include "fakeinternet.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QtTest/QTest>
|
||||||
|
|
||||||
|
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
|
||||||
|
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на Linux) недоступность —
|
||||||
|
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
||||||
|
#define BILAUNCHER_START_FAKE_INTERNET(net) \
|
||||||
|
do { \
|
||||||
|
QString startError_; \
|
||||||
|
if (!(net).start(&startError_)) { \
|
||||||
|
const QString message_ = QStringLiteral("FakeInternet недоступен: ") + startError_; \
|
||||||
|
if (qEnvironmentVariableIntValue("BILAUNCHER_REQUIRE_FAKE_INTERNET")) \
|
||||||
|
QFAIL(qPrintable(message_)); \
|
||||||
|
QSKIP(qPrintable(message_)); \
|
||||||
|
} \
|
||||||
|
} while (false)
|
||||||
|
|
||||||
|
// Результат колбэка вида (bool ok, QString warning).
|
||||||
|
struct CallResult
|
||||||
|
{
|
||||||
|
int calls = 0;
|
||||||
|
bool ok = false;
|
||||||
|
QString warning;
|
||||||
|
|
||||||
|
auto callback()
|
||||||
|
{
|
||||||
|
return [this](bool okValue, const QString &warningValue) {
|
||||||
|
++calls;
|
||||||
|
ok = okValue;
|
||||||
|
warning = warningValue;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#include "testenv.h"
|
||||||
|
|
||||||
|
#include "launcherpaths.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
#include <qt_windows.h>
|
||||||
|
#include <stdlib.h> // _wputenv_s
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QTemporaryDir *g_sandbox = nullptr;
|
||||||
|
|
||||||
|
[[noreturn]] void abortSetup(const QString &message)
|
||||||
|
{
|
||||||
|
const QString line = QStringLiteral("TestEnv: ") + message + u'\n';
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
// В живую консоль пишем UTF-16 напрямую: кодовая страница консоли тогда не важна.
|
||||||
|
const HANDLE handle = GetStdHandle(STD_ERROR_HANDLE);
|
||||||
|
DWORD mode = 0;
|
||||||
|
if (handle != INVALID_HANDLE_VALUE && GetConsoleMode(handle, &mode)) {
|
||||||
|
DWORD written = 0;
|
||||||
|
WriteConsoleW(handle, reinterpret_cast<const wchar_t *>(line.utf16()),
|
||||||
|
DWORD(line.size()), &written, nullptr);
|
||||||
|
std::exit(2);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
// Вывод в трубу (ctest, CI) — UTF-8, как его и читает ctest. Локальная 8-битная
|
||||||
|
// кодировка Windows (cp1252/cp1251) превращала кириллицу в «???».
|
||||||
|
std::fputs(line.toUtf8().constData(), stderr);
|
||||||
|
std::fflush(stderr);
|
||||||
|
std::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void TestEnv::prepareSandbox()
|
||||||
|
{
|
||||||
|
g_sandbox = new QTemporaryDir(QDir::tempPath() + QStringLiteral("/bilauncher-test-XXXXXX"));
|
||||||
|
if (!g_sandbox->isValid())
|
||||||
|
abortSetup(QStringLiteral("не удалось создать временную папку"));
|
||||||
|
|
||||||
|
const QString home = QDir::toNativeSeparators(g_sandbox->path());
|
||||||
|
setEnvPath("HOME", home);
|
||||||
|
setEnvPath("USERPROFILE", home);
|
||||||
|
// LauncherPaths: на Windows QDir::homePath() HOME/USERPROFILE не читает, а эту
|
||||||
|
// переменную лаунчер читает везде. Дочерние процессы получают её по наследству.
|
||||||
|
setEnvPath("BILAUNCHER_HOME", home);
|
||||||
|
// Чужая Java из окружения машины не должна влиять на выбор Java в тестах.
|
||||||
|
qunsetenv("JAVA_HOME");
|
||||||
|
|
||||||
|
std::atexit([] {
|
||||||
|
delete g_sandbox;
|
||||||
|
g_sandbox = nullptr;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestEnv::finishSetup()
|
||||||
|
{
|
||||||
|
QCoreApplication::setApplicationName(QStringLiteral("BiLauncher"));
|
||||||
|
QCoreApplication::setOrganizationName(QStringLiteral("GaleonDev"));
|
||||||
|
|
||||||
|
const QString root = QDir::cleanPath(LauncherPaths::rootDir());
|
||||||
|
const QString sandbox = QDir::cleanPath(g_sandbox->path());
|
||||||
|
const QString game = QDir::cleanPath(LauncherPaths::defaultMinecraftDir());
|
||||||
|
// Ни одна папка лаунчера не должна указывать на настоящий профиль: resetLauncherRoot()
|
||||||
|
// стирает rootDir() целиком.
|
||||||
|
for (const QString &path : {root, game}) {
|
||||||
|
if (!path.startsWith(sandbox + u'/'))
|
||||||
|
abortSetup(QStringLiteral("путь лаунчера %1 вне песочницы %2 — отказываюсь запускаться")
|
||||||
|
.arg(QDir::toNativeSeparators(path), QDir::toNativeSeparators(sandbox)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Localization::instance().load())
|
||||||
|
abortSetup(QStringLiteral("не загрузился каталог переводов"));
|
||||||
|
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestEnv::setEnvPath(const char *name, const QString &path)
|
||||||
|
{
|
||||||
|
// На Windows qputenv() пишет в ANSI-кодировке и испортил бы, например,
|
||||||
|
// кириллическое имя пользователя во временном пути.
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
_wputenv_s(reinterpret_cast<const wchar_t *>(QString::fromLatin1(name).utf16()),
|
||||||
|
reinterpret_cast<const wchar_t *>(path.utf16()));
|
||||||
|
#else
|
||||||
|
qputenv(name, QFile::encodeName(path));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
QString TestEnv::homeDir()
|
||||||
|
{
|
||||||
|
return g_sandbox->path();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString TestEnv::freshDir(const QString &name)
|
||||||
|
{
|
||||||
|
const QString path = g_sandbox->path() + QStringLiteral("/work/") + name;
|
||||||
|
QDir(path).removeRecursively();
|
||||||
|
QDir().mkpath(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestEnv::resetLauncherRoot()
|
||||||
|
{
|
||||||
|
QDir(LauncherPaths::rootDir()).removeRecursively();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Общая обвязка тестов: песочница для домашней папки и main для QtTest.
|
||||||
|
//
|
||||||
|
// LauncherPaths строит все пути от домашней папки. Каждый тестовый процесс до
|
||||||
|
// создания QCoreApplication заводит свою временную папку и направляет туда
|
||||||
|
// BILAUNCHER_HOME (её LauncherPaths читает на всех ОС), а заодно HOME и
|
||||||
|
// USERPROFILE (их читает QDir::homePath() на Linux и macOS). На Windows
|
||||||
|
// QDir::homePath() переменные окружения игнорирует, поэтому без BILAUNCHER_HOME
|
||||||
|
// там не обойтись. Реальные папка лаунчера и .minecraft машины, на которой гоняются
|
||||||
|
// тесты, не трогаются: если путь лаунчера всё же оказался вне песочницы,
|
||||||
|
// процесс сразу завершается с кодом 2.
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QString>
|
||||||
|
#include <QtTest/QTest>
|
||||||
|
|
||||||
|
namespace TestEnv {
|
||||||
|
|
||||||
|
// До QCoreApplication: временная папка и подмена BILAUNCHER_HOME/HOME/USERPROFILE.
|
||||||
|
void prepareSandbox();
|
||||||
|
|
||||||
|
// После QCoreApplication: проверка, что пути лаунчера ушли в песочницу,
|
||||||
|
// загрузка переводов, английский язык (тексты ошибок не зависят от локали машины).
|
||||||
|
void finishSetup();
|
||||||
|
|
||||||
|
// Переменная окружения с путём, без потерь символов вне ANSI-кодировки на Windows.
|
||||||
|
void setEnvPath(const char *name, const QString &path);
|
||||||
|
|
||||||
|
// Корень песочницы — домашняя папка, от которой LauncherPaths строит пути в тестах.
|
||||||
|
QString homeDir();
|
||||||
|
|
||||||
|
// Свежая пустая папка внутри песочницы (для .minecraft, архивов и т. п.).
|
||||||
|
QString freshDir(const QString &name);
|
||||||
|
|
||||||
|
// Стирает папку лаунчера (LauncherPaths::rootDir()) — между тестами.
|
||||||
|
void resetLauncherRoot();
|
||||||
|
|
||||||
|
} // namespace TestEnv
|
||||||
|
|
||||||
|
#define BILAUNCHER_TEST_MAIN_IMPL(AppType, TestObject) \
|
||||||
|
int main(int argc, char *argv[]) \
|
||||||
|
{ \
|
||||||
|
TestEnv::prepareSandbox(); \
|
||||||
|
AppType app(argc, argv); \
|
||||||
|
TestEnv::finishSetup(); \
|
||||||
|
TestObject tc; \
|
||||||
|
QTEST_SET_MAIN_SOURCE_PATH \
|
||||||
|
return QTest::qExec(&tc, argc, argv); \
|
||||||
|
}
|
||||||
|
|
||||||
|
#define BILAUNCHER_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QCoreApplication, TestObject)
|
||||||
|
|
||||||
|
// Тесту с GUI нужно самому подключить <QGuiApplication>.
|
||||||
|
#define BILAUNCHER_GUI_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QGuiApplication, TestObject)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Подставная «java» для тестов. Без Qt, чтобы её копию можно было положить
|
||||||
|
// в любую папку и запустить без поиска библиотек Qt.
|
||||||
|
//
|
||||||
|
// Поведение задаётся файлом fake-java.cfg рядом с исполняемым файлом,
|
||||||
|
// по строке «ключ=значение»:
|
||||||
|
// version=21.0.2 что печатать на «-version» (в stderr, как настоящая java)
|
||||||
|
// exit=0 код выхода обычного запуска
|
||||||
|
// sleep_ms=0 сколько «работать» перед выходом
|
||||||
|
// args_file=<путь> куда записать аргументы, по одному на строку
|
||||||
|
// dump_env=<ИМЯ> дописать в args_file строку «env:ИМЯ=значение» (можно несколько)
|
||||||
|
// stdout=<строка> что напечатать в stdout (можно несколько)
|
||||||
|
// create=<путь>\t<текст> создать файл с текстом; относительный путь — от рабочей папки
|
||||||
|
// crash=1 завершиться аварийно (abort)
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <windows.h>
|
||||||
|
#elif defined(__APPLE__)
|
||||||
|
#include <mach-o/dyld.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
fs::path executablePath(const char *argv0)
|
||||||
|
{
|
||||||
|
#if defined(_WIN32)
|
||||||
|
wchar_t buffer[MAX_PATH * 4];
|
||||||
|
const DWORD size = GetModuleFileNameW(nullptr, buffer, DWORD(std::size(buffer)));
|
||||||
|
return fs::path(std::wstring(buffer, size));
|
||||||
|
#elif defined(__APPLE__)
|
||||||
|
char buffer[4096];
|
||||||
|
uint32_t size = sizeof(buffer);
|
||||||
|
if (_NSGetExecutablePath(buffer, &size) == 0)
|
||||||
|
return fs::canonical(buffer);
|
||||||
|
return fs::absolute(argv0);
|
||||||
|
#else
|
||||||
|
std::error_code ec;
|
||||||
|
const fs::path self = fs::read_symlink("/proc/self/exe", ec);
|
||||||
|
return ec ? fs::absolute(argv0) : self;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
std::string version = "17.0.8";
|
||||||
|
int exitCode = 0;
|
||||||
|
int sleepMs = 0;
|
||||||
|
bool crash = false;
|
||||||
|
std::string argsFile;
|
||||||
|
std::vector<std::string> dumpEnv;
|
||||||
|
std::vector<std::string> stdoutLines;
|
||||||
|
std::vector<std::pair<std::string, std::string>> creates;
|
||||||
|
|
||||||
|
std::ifstream config(executablePath(argv[0]).parent_path() / "fake-java.cfg");
|
||||||
|
for (std::string line; std::getline(config, line);) {
|
||||||
|
if (!line.empty() && line.back() == '\r')
|
||||||
|
line.pop_back();
|
||||||
|
const auto eq = line.find('=');
|
||||||
|
if (eq == std::string::npos)
|
||||||
|
continue;
|
||||||
|
const std::string key = line.substr(0, eq);
|
||||||
|
const std::string value = line.substr(eq + 1);
|
||||||
|
if (key == "version")
|
||||||
|
version = value;
|
||||||
|
else if (key == "exit")
|
||||||
|
exitCode = std::atoi(value.c_str());
|
||||||
|
else if (key == "sleep_ms")
|
||||||
|
sleepMs = std::atoi(value.c_str());
|
||||||
|
else if (key == "crash")
|
||||||
|
crash = value == "1";
|
||||||
|
else if (key == "args_file")
|
||||||
|
argsFile = value;
|
||||||
|
else if (key == "dump_env")
|
||||||
|
dumpEnv.push_back(value);
|
||||||
|
else if (key == "stdout")
|
||||||
|
stdoutLines.push_back(value);
|
||||||
|
else if (key == "create") {
|
||||||
|
const auto tab = value.find('\t');
|
||||||
|
creates.emplace_back(value.substr(0, tab),
|
||||||
|
tab == std::string::npos ? std::string() : value.substr(tab + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
if (std::string(argv[i]) == "-version") {
|
||||||
|
std::cerr << "openjdk version \"" << version << "\" 2024-01-16\n"
|
||||||
|
<< "OpenJDK Runtime Environment (fake)\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!argsFile.empty()) {
|
||||||
|
// binary: в текстовом режиме Windows превратит '\n' в "\r\n", и тесты увидят «-jar\r».
|
||||||
|
std::ofstream out(argsFile, std::ios::trunc | std::ios::binary);
|
||||||
|
for (int i = 1; i < argc; ++i)
|
||||||
|
out << argv[i] << '\n';
|
||||||
|
for (const std::string &name : dumpEnv) {
|
||||||
|
const char *value = std::getenv(name.c_str());
|
||||||
|
out << "env:" << name << '=' << (value ? value : "") << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &[path, text] : creates) {
|
||||||
|
const fs::path target(path);
|
||||||
|
std::error_code ec;
|
||||||
|
if (target.has_parent_path())
|
||||||
|
fs::create_directories(target.parent_path(), ec);
|
||||||
|
std::ofstream(target, std::ios::trunc | std::ios::binary) << text;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const std::string &line : stdoutLines)
|
||||||
|
std::cout << line << '\n';
|
||||||
|
std::cout.flush();
|
||||||
|
|
||||||
|
if (sleepMs > 0)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs));
|
||||||
|
if (crash) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
// QProcess считает аварией коды 0x8xxxxxxx–0xCxxxxxxx; abort() дал бы код 3.
|
||||||
|
TerminateProcess(GetCurrentProcess(), 0xC0000005);
|
||||||
|
#endif
|
||||||
|
std::abort();
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{"loader": {"separator": ".", "build": 9, "maven": "net.fabricmc:fabric-loader:0.16.9", "version": "0.16.9", "stable": true},
|
||||||
|
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
|
||||||
|
{"loader": {"separator": ".", "build": 10, "maven": "net.fabricmc:fabric-loader:0.16.10", "version": "0.16.10", "stable": true},
|
||||||
|
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
|
||||||
|
{"loader": {"separator": "+", "build": 1, "maven": "net.fabricmc:fabric-loader:0.17.0-beta.1", "version": "0.17.0-beta.1", "stable": false},
|
||||||
|
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
|
||||||
|
{"loader": {"version": ""}}
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata>
|
||||||
|
<groupId>net.minecraftforge</groupId>
|
||||||
|
<artifactId>forge</artifactId>
|
||||||
|
<versioning>
|
||||||
|
<latest>1.21.1-52.0.10</latest>
|
||||||
|
<release>1.21.1-52.0.10</release>
|
||||||
|
<versions>
|
||||||
|
<version>1.7.10-10.13.4.1614-1.7.10</version>
|
||||||
|
<version>1.20.1-47.3.0</version>
|
||||||
|
<version>1.20.1-47.4.0</version>
|
||||||
|
<version>1.20.1-47.4.1</version>
|
||||||
|
<version>1.21.1-52.0.10</version>
|
||||||
|
<version>1.21.1-52.0.2-beta</version>
|
||||||
|
<version>broken-</version>
|
||||||
|
</versions>
|
||||||
|
</versioning>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"homepage": "https://files.minecraftforge.net/net/minecraftforge/forge/",
|
||||||
|
"promos": {"1.20.1-latest": "47.4.1", "1.20.1-recommended": "47.4.0", "1.21.1-latest": "52.0.10"}}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata>
|
||||||
|
<groupId>net.neoforged</groupId>
|
||||||
|
<artifactId>forge</artifactId>
|
||||||
|
<versioning>
|
||||||
|
<versions>
|
||||||
|
<version>1.20.1-47.1.106</version>
|
||||||
|
<version>1.20.1-47.1.79</version>
|
||||||
|
</versions>
|
||||||
|
</versioning>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata>
|
||||||
|
<groupId>net.neoforged</groupId>
|
||||||
|
<artifactId>neoforge</artifactId>
|
||||||
|
<versioning>
|
||||||
|
<versions>
|
||||||
|
<version>20.4.80-beta</version>
|
||||||
|
<version>20.4.237</version>
|
||||||
|
<version>21.0.10</version>
|
||||||
|
<version>21.1.200</version>
|
||||||
|
<version>21.1.248</version>
|
||||||
|
<version>26.1.0.5</version>
|
||||||
|
<version>26.1.1.3</version>
|
||||||
|
</versions>
|
||||||
|
</versioning>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
{"loader": {"separator": ".", "build": 21, "maven": "org.quiltmc:quilt-loader:0.26.4-beta.1", "version": "0.26.4-beta.1"}},
|
||||||
|
{"loader": {"separator": ".", "build": 20, "maven": "org.quiltmc:quilt-loader:0.26.3", "version": "0.26.3"}}
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"latest": {"release": "1.21.8", "snapshot": "25w33a"},
|
||||||
|
"versions": [
|
||||||
|
{"id": "1.20.1", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/aaaa/1.20.1.json",
|
||||||
|
"time": "2023-06-12T13:25:51+00:00", "releaseTime": "2023-06-12T13:25:51+00:00", "sha1": "aaaa", "complianceLevel": 1},
|
||||||
|
{"id": "25w33a", "type": "snapshot", "url": "https://piston-meta.mojang.com/v1/packages/cccc/25w33a.json",
|
||||||
|
"time": "2025-08-13T12:00:00+00:00", "releaseTime": "2025-08-13T12:00:00+00:00", "sha1": "cccc", "complianceLevel": 1},
|
||||||
|
{"id": "1.21.8", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/bbbb/1.21.8.json",
|
||||||
|
"time": "2025-07-17T12:00:00+00:00", "releaseTime": "2025-07-17T12:00:00+00:00", "sha1": "bbbb", "complianceLevel": 1},
|
||||||
|
{"id": "b1.7.3", "type": "old_beta", "url": "https://piston-meta.mojang.com/v1/packages/dddd/b1.7.3.json",
|
||||||
|
"time": "2011-07-08T00:00:00+00:00", "releaseTime": "2011-07-08T00:00:00+00:00", "sha1": "dddd", "complianceLevel": 0},
|
||||||
|
{"id": "insecure", "type": "release", "url": "http://piston-meta.mojang.com/insecure.json",
|
||||||
|
"releaseTime": "2024-01-01T00:00:00+00:00", "sha1": "eeee"},
|
||||||
|
{"type": "release", "url": "https://piston-meta.mojang.com/noid.json", "releaseTime": "2024-01-01T00:00:00+00:00"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"id": "season-4",
|
||||||
|
"name": "Сезон 4",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-03-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/s4.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
},
|
||||||
|
"seasonEnd": "2025-06-01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "season-5",
|
||||||
|
"name": "Сезон 5: Пустоши",
|
||||||
|
"revision": 3,
|
||||||
|
"minecraftVersion": "1.21.1",
|
||||||
|
"loader": "fabric",
|
||||||
|
"loaderVersion": "0.16.10",
|
||||||
|
"modCount": 120,
|
||||||
|
"seasonStart": "2025-07-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 21,
|
||||||
|
"description": " Пустоши ",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/s5.zip",
|
||||||
|
"size": 123456,
|
||||||
|
"sha256": "ABCDEF0000000000000000000000000000000000000000000000000000000000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vanilla-plus",
|
||||||
|
"name": "Vanilla+",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "",
|
||||||
|
"loaderVersion": "",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-05-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/v.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Bad-ID",
|
||||||
|
"name": "caps",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no-name",
|
||||||
|
"name": " ",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zero-rev",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 0,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bad-mc",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "../1.20",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bad-loader",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "liteloader",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no-loader-version",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bad-sha",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "xyz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "neg-size",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": -1,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "foreign-host",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://evil.example/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "http-scheme",
|
||||||
|
"name": "X",
|
||||||
|
"revision": 1,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 10,
|
||||||
|
"seasonStart": "2025-01-01",
|
||||||
|
"serverUrl": "play.example.com",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "d",
|
||||||
|
"archive": {
|
||||||
|
"url": "http://launcher-builds.galeonworx.com/packs/x.zip",
|
||||||
|
"size": 100,
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||