Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e60a4c2333 | ||
|
|
438666c9d5 | ||
|
|
04b7799fd3 | ||
|
|
ae57940c62 | ||
|
|
1b18cf48bb | ||
|
|
c11da87908 | ||
|
|
6567e731d1 | ||
|
|
441021b9a6 | ||
|
|
5dba4e89cb | ||
|
|
01af372ef2 | ||
|
|
00e7c957e4 | ||
|
|
f1a840174b | ||
|
|
d6986057d1 | ||
|
|
c25e071b29 | ||
|
|
4689e59b5a | ||
|
|
616840fda9 | ||
|
|
bbc8633257 | ||
|
|
af41017446 | ||
|
|
dddc0261d4 | ||
|
|
08e0dc4965 | ||
|
|
168884127b | ||
|
|
5bc59f8018 | ||
|
|
671fbe169d | ||
|
|
2c51c4736a | ||
|
|
af4230500e | ||
|
|
2c4b594c90 | ||
|
|
56e56a370f | ||
|
|
2b67938385 | ||
|
|
966cd46049 | ||
|
|
bbbed6aa89 | ||
|
|
2d35c613a9 | ||
|
|
4bf16a183f | ||
|
|
af2139c671 | ||
|
|
ca2a6bdbff | ||
|
|
d3bee159bc | ||
|
|
2b8119c64e | ||
|
|
5dd96fc4dc | ||
|
|
81877df00f | ||
|
|
49cbded003 | ||
|
|
3b3d9e7af0 | ||
|
|
0d18e48783 | ||
|
|
b5b964713e | ||
|
|
8ea52d0d70 | ||
|
|
c5e16bc295 | ||
|
|
a22dca1093 | ||
|
|
a485621830 | ||
|
|
249476a7ff | ||
|
|
0832ebc0a7 | ||
|
|
3be6cca929 | ||
|
|
7903775959 | ||
|
|
26ab5acb15 | ||
|
|
8dcc3de486 | ||
|
|
e9e1885d17 | ||
|
|
11fea3785b | ||
|
|
eb4695d75f |
No files matched your search
@@ -0,0 +1,305 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# master -> релиз v<версия>, если такого ещё нет
|
||||||
|
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
|
||||||
|
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
|
||||||
|
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
#branches: [master, rc]
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-release:
|
||||||
|
runs-on: linux
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.meta.outputs.version }}
|
||||||
|
tag: ${{ steps.meta.outputs.tag }}
|
||||||
|
skip: ${{ steps.meta.outputs.skip }}
|
||||||
|
release_id: ${{ steps.release.outputs.id }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
name: Версия из CMakeLists.txt, тег и тип релиза
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
|
||||||
|
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
|
||||||
|
|
||||||
|
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
|
||||||
|
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
|
||||||
|
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
|
||||||
|
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
|
||||||
|
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
|
||||||
|
known() {
|
||||||
|
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
|
||||||
|
printf '%s\n' "$release_tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
tag=""; skip=false
|
||||||
|
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
|
||||||
|
tag:*)
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
base="${tag#v}"; base="${base%%-*}"
|
||||||
|
if [ "$base" != "$version" ]; then
|
||||||
|
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||||
|
fi
|
||||||
|
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
|
||||||
|
if has_release "$tag"; then
|
||||||
|
echo "Релиз $tag уже есть"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:master)
|
||||||
|
tag="v$version"
|
||||||
|
if known | grep -xF "$tag" > /dev/null; then
|
||||||
|
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:rc)
|
||||||
|
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
|
||||||
|
tag="v$version-rc$(( ${n:-0} + 1 ))"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ -n "$tag" ] && version="${tag#v}"
|
||||||
|
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip=$skip" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
|
||||||
|
|
||||||
|
- id: release
|
||||||
|
name: Черновик релиза
|
||||||
|
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
TAG: ${{ steps.meta.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
base="${TAG#v}"; base="${base%%-*}"
|
||||||
|
notes=$(awk -v v="$base" '
|
||||||
|
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||||
|
on' CHANGELOG.md)
|
||||||
|
prerelease=false; case "$TAG" in *-rc*) prerelease=true;; esac
|
||||||
|
# target_commitish: у новой ветки тег появится на этом коммите при публикации
|
||||||
|
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"target_commitish":sys.argv[4],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
||||||
|
"$TAG" "$notes" "$prerelease" "$GITHUB_SHA")
|
||||||
|
id=$(curl -fsS -X POST "$API/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
|
runs-on: linux
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит 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
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ---> C++
|
||||||
|
# Prerequisites
|
||||||
|
*.d
|
||||||
|
|
||||||
|
# Compiled Object files
|
||||||
|
*.slo
|
||||||
|
*.lo
|
||||||
|
*.o
|
||||||
|
*.obj
|
||||||
|
|
||||||
|
# Precompiled Headers
|
||||||
|
*.gch
|
||||||
|
*.pch
|
||||||
|
|
||||||
|
# Compiled Dynamic libraries
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Fortran module files
|
||||||
|
*.mod
|
||||||
|
*.smod
|
||||||
|
|
||||||
|
# Compiled Static libraries
|
||||||
|
*.lai
|
||||||
|
*.la
|
||||||
|
*.a
|
||||||
|
*.lib
|
||||||
|
|
||||||
|
# Executables
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
*.app
|
||||||
|
|
||||||
|
# ---> Java
|
||||||
|
# Compiled class file
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Log file
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# BlueJ files
|
||||||
|
*.ctxt
|
||||||
|
|
||||||
|
# Mobile Tools for Java (J2ME)
|
||||||
|
.mtj.tmp/
|
||||||
|
|
||||||
|
# Package Files #
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.nar
|
||||||
|
*.ear
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||||
|
hs_err_pid*
|
||||||
|
replay_pid*
|
||||||
|
|
||||||
|
build/
|
||||||
|
.qtcreator/
|
||||||
|
.DS_Store
|
||||||
|
/doc/adr/
|
||||||
|
CMakeLists.txt.user*
|
||||||
|
stage/
|
||||||
|
dist/
|
||||||
|
build-tests/
|
||||||
|
build-*/
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 2.15
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: buildsDialog
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
property int editIndex: -1
|
||||||
|
property bool loading: false
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 880
|
||||||
|
height: 600
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||||
|
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBuild(index) {
|
||||||
|
const data = buildsDialog.backend.customBuildAt(index)
|
||||||
|
buildsDialog.loading = true
|
||||||
|
buildsDialog.editIndex = index
|
||||||
|
bdName.text = data.name || ""
|
||||||
|
bdServer.text = data.serverUrl || ""
|
||||||
|
bdMinecraft.selectedId = data.minecraftVersion || ""
|
||||||
|
loaderPanel.applyBuild(data.loader || "", data.loaderVersion || "")
|
||||||
|
buildsDialog.loading = false
|
||||||
|
buildsDialog.refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemove(index) {
|
||||||
|
const info = buildsDialog.backend.customBuildRemovalInfo(index)
|
||||||
|
if (!info || info.name === undefined)
|
||||||
|
return
|
||||||
|
removeConfirm.buildIndex = index
|
||||||
|
removeConfirm.buildName = info.name
|
||||||
|
removeConfirm.hasArchive = info.hasArchive === true
|
||||||
|
removeConfirm.active = info.active === true
|
||||||
|
removeConfirm.lastOne = info.lastOne === true
|
||||||
|
removeConfirm.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function performRemove(index) {
|
||||||
|
buildsDialog.backend.removeCustomBuild(index)
|
||||||
|
const left = buildsDialog.backend.customBuildNames.length
|
||||||
|
if (left === 0)
|
||||||
|
buildsDialog.editIndex = -1
|
||||||
|
else
|
||||||
|
buildsDialog.selectBuild(Math.min(index, left - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(fields) {
|
||||||
|
if (buildsDialog.loading || buildsDialog.editIndex < 0)
|
||||||
|
return
|
||||||
|
buildsDialog.backend.updateCustomBuild(buildsDialog.editIndex, fields)
|
||||||
|
buildsDialog.refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStatus() {
|
||||||
|
if (buildsDialog.editIndex < 0) {
|
||||||
|
bdStatus.text = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const problems = buildsDialog.backend.checkInstallation(buildsDialog.editIndex)
|
||||||
|
bdStatus.ok = problems.length === 0
|
||||||
|
bdStatus.text = problems.length === 0
|
||||||
|
? Loc.t.build.status.ready
|
||||||
|
: Loc.t.build.error.missingFiles.arg(problems.length).arg(problems[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function newBuildName() {
|
||||||
|
const taken = buildsDialog.backend.customBuildNames
|
||||||
|
for (var n = 1; ; ++n) {
|
||||||
|
const candidate = Loc.t.build.title.numbered.arg(n)
|
||||||
|
if (taken.indexOf(candidate) === -1)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: removeConfirm
|
||||||
|
|
||||||
|
property int buildIndex: -1
|
||||||
|
property string buildName: ""
|
||||||
|
property bool hasArchive: false
|
||||||
|
property bool active: false
|
||||||
|
property bool lastOne: false
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 420
|
||||||
|
parent: buildsDialog.parent
|
||||||
|
x: (buildsDialog.parent.width - width) / 2
|
||||||
|
y: (buildsDialog.parent.height - height) / 2
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.build.title.removeConfirm
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Column {
|
||||||
|
spacing: 10
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 18
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: Loc.t.build.hint.removeIrreversible
|
||||||
|
.arg(removeConfirm.buildName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.hasArchive
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.build.warning.archiveGoesToo
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.active && !removeConfirm.lastOne
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.build.warning.removingActive
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.lastOne
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.build.hint.removingLast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: removeConfirm.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.remove
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#8a3a3a" : "#663333"
|
||||||
|
radius: 6
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
const index = removeConfirm.buildIndex
|
||||||
|
removeConfirm.close()
|
||||||
|
buildsDialog.performRemove(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClosed: removeConfirm.buildIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionPickerDialog {
|
||||||
|
id: versionPicker
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
parent: buildsDialog.parent
|
||||||
|
x: (buildsDialog.parent.width - width) / 2
|
||||||
|
y: (buildsDialog.parent.height - height) / 2
|
||||||
|
onVersionChosen: (versionId) => bdMinecraft.selectedId = versionId
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: buildsDialog.backend
|
||||||
|
function onInstalledVersionsChanged() { buildsDialog.refreshStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
onAboutToShow: {
|
||||||
|
buildsDialog.backend.refreshVersionCatalog()
|
||||||
|
const names = buildsDialog.backend.customBuildNames
|
||||||
|
if (names.length === 0)
|
||||||
|
buildsDialog.editIndex = -1
|
||||||
|
else
|
||||||
|
buildsDialog.selectBuild(Math.max(0, buildsDialog.backend.activeBuildIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.title.customBuilds
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.preferredWidth: 260
|
||||||
|
Layout.fillHeight: true
|
||||||
|
enabled: !buildsDialog.backend.switching
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: buildList
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: addBuildRow.top
|
||||||
|
anchors.margins: 8
|
||||||
|
clip: true
|
||||||
|
spacing: 2
|
||||||
|
model: buildsDialog.backend.customBuildNames
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
id: buildRow
|
||||||
|
required property string modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: buildList.width
|
||||||
|
height: 40
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.right: activeBadge.left
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: buildRow.modelData
|
||||||
|
color: "#ffffff"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: activeBadge
|
||||||
|
anchors.right: buildTrash.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||||
|
text: Loc.t.build.label.active
|
||||||
|
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||||
|
? "#ffffff" : "#91B315"
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
Image {
|
||||||
|
id: buildTrash
|
||||||
|
width: 16; height: 16
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: buildRow.hovered
|
||||||
|
opacity: buildTrashArea.containsMouse ? 1.0 : 0.7
|
||||||
|
source: "images/Trash.svg"
|
||||||
|
sourceSize.width: 16; sourceSize.height: 16
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: buildTrashArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -6
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: buildsDialog.askRemove(buildRow.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||||
|
? "#91B315" : "#232323"
|
||||||
|
radius: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: buildsDialog.selectBuild(buildRow.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: addBuildRow
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 8
|
||||||
|
height: 34
|
||||||
|
radius: 5
|
||||||
|
color: addBuildArea.containsPress ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.build.button.new
|
||||||
|
color: "#91B315"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: addBuildArea
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: {
|
||||||
|
buildsDialog.backend.addCustomBuild(buildsDialog.newBuildName(), "", "")
|
||||||
|
buildsDialog.selectBuild(buildsDialog.backend.customBuildNames.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 1
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.switching
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 20
|
||||||
|
contentHeight: detailsColumn.height
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: detailsColumn
|
||||||
|
width: parent.width
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
LabelledField {
|
||||||
|
id: bdName
|
||||||
|
width: parent.width
|
||||||
|
label: Loc.t.build.label.name
|
||||||
|
placeholder: Loc.t.build.placeholder.name
|
||||||
|
onEditingFinished: buildsDialog.commit({"name": bdName.text.trim()})
|
||||||
|
}
|
||||||
|
|
||||||
|
LabelledField {
|
||||||
|
id: bdServer
|
||||||
|
width: parent.width
|
||||||
|
label: Loc.t.build.label.serverAddress
|
||||||
|
placeholder: Loc.t.build.placeholder.serverAddress
|
||||||
|
onEditingFinished: buildsDialog.commit({"serverUrl": bdServer.text.trim()})
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: Loc.t.common.label.minecraftVersion
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: bdMinecraft
|
||||||
|
property string selectedId: ""
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: 36
|
||||||
|
radius: 6
|
||||||
|
color: "#2a2a2a"
|
||||||
|
border.color: versionFieldArea.containsMouse ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
leftPadding: 10
|
||||||
|
rightPadding: 110
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: bdMinecraft.selectedId === "" ? "#666666" : "#ffffff"
|
||||||
|
text: bdMinecraft.selectedId === "" ? Loc.t.build.button.pickVersion
|
||||||
|
: bdMinecraft.selectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: Loc.t.common.button.choose
|
||||||
|
color: "#91B315"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: versionFieldArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: versionPicker.openFor(bdMinecraft.selectedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectedIdChanged: buildsDialog.commit({"minecraftVersion":
|
||||||
|
bdMinecraft.selectedId})
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: Loc.t.build.label.modLoader
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: loaderPanel
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
property var rows: [bdForge, bdFabric, bdNeoForge, bdQuilt]
|
||||||
|
|
||||||
|
function applyBuild(loader, loaderVersion) {
|
||||||
|
for (var i = 0; i < loaderPanel.rows.length; ++i)
|
||||||
|
loaderPanel.rows[i].applyBuild(loader, loaderVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedRow() {
|
||||||
|
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||||
|
if (loaderPanel.rows[i].checked)
|
||||||
|
return loaderPanel.rows[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepOnly(row) {
|
||||||
|
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||||
|
if (loaderPanel.rows[i] === row)
|
||||||
|
continue
|
||||||
|
loaderPanel.rows[i].applyBuild("", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitSelection() {
|
||||||
|
const row = loaderPanel.selectedRow()
|
||||||
|
buildsDialog.commit({
|
||||||
|
"loader": row ? row.loaderKey : "",
|
||||||
|
"loaderVersion": row ? row.selectedVersion : "",
|
||||||
|
"resolvedVersionId": ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
LoaderRow {
|
||||||
|
id: bdForge
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "forge"
|
||||||
|
title: Loc.t.loader.name.forge
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdForge)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
LoaderRow {
|
||||||
|
id: bdFabric
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "fabric"
|
||||||
|
title: Loc.t.loader.name.fabric
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdFabric)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
LoaderRow {
|
||||||
|
id: bdNeoForge
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "neoforge"
|
||||||
|
title: Loc.t.loader.name.neoforge
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdNeoForge)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
LoaderRow {
|
||||||
|
id: bdQuilt
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "quilt"
|
||||||
|
title: Loc.t.loader.name.quilt
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdQuilt)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: bdStatus
|
||||||
|
property bool ok: false
|
||||||
|
width: parent.width
|
||||||
|
color: bdStatus.ok ? "#8fbf5a" : "#cc6666"
|
||||||
|
font.pixelSize: 11
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.install
|
||||||
|
width: 140; height: 36
|
||||||
|
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.busy
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: buildsDialog.backend.installCustomBuild(buildsDialog.editIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.build.button.makeActive
|
||||||
|
width: 170; height: 36
|
||||||
|
enabled: buildsDialog.editIndex >= 0
|
||||||
|
&& buildsDialog.editIndex !== buildsDialog.backend.activeBuildIndex
|
||||||
|
&& !buildsDialog.backend.busy
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: buildsDialog.backend.activeBuildIndex = buildsDialog.editIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.close
|
||||||
|
width: 120; height: 36
|
||||||
|
enabled: !buildsDialog.backend.switching
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: buildsDialog.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-10-07
|
||||||
|
### Добавлено
|
||||||
|
- Настройки запуска Minecraft
|
||||||
|
- Автоматическая загрузка версий java из предложенного списка
|
||||||
|
- Система хранения сборок, автоматического скачивания версий игры из меню и модлоадеры к каждой версии
|
||||||
|
- Сохранение сборок в zip формате и отображение в списке
|
||||||
|
- Система сезонных сборок: полная автоматизация в один клик
|
||||||
|
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
||||||
|
### Исправлено
|
||||||
|
- UI фиксы
|
||||||
|
|
||||||
|
## [1.0.1-rc1] — 2026-10-23
|
||||||
|
### Добавлено
|
||||||
|
- Отображене текущей версии сборки лаунчера на главном окне
|
||||||
|
### Исправлено
|
||||||
|
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
|
||||||
|
- Исправлен баг отображения статуса активной сборки на зелёном фоне
|
||||||
|
- Исправлен баг, что во время игры можно было потенциально сменить сборку
|
||||||
|
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C)
|
||||||
|
|
||||||
|
# Канал сборки (dev / rc / релиз) не хранится в репозитории, а вычисляется
|
||||||
|
# при сборке — см. cmake/LauncherVersion.cmake. CI передаёт полную версию:
|
||||||
|
# -DLAUNCHER_BUILD_VERSION=1.0.1-rc2.
|
||||||
|
set(LAUNCHER_BUILD_VERSION "" CACHE STRING "Полная версия от CI (X.Y.Z[-rcN]); пусто — по тегу и ветке git")
|
||||||
|
set(LAUNCHER_VERSION_NUMERIC "${PROJECT_VERSION}")
|
||||||
|
set(LAUNCHER_SOURCE_DIR "${PROJECT_SOURCE_DIR}")
|
||||||
|
set(LAUNCHER_GENERATED_DIR "${PROJECT_BINARY_DIR}/generated")
|
||||||
|
set(LAUNCHER_VERSION_HEADER "${LAUNCHER_GENERATED_DIR}/launcherversion.h")
|
||||||
|
include(cmake/LauncherVersion.cmake)
|
||||||
|
message(STATUS "Версия лаунчера: ${LAUNCHER_VERSION} (канал ${LAUNCHER_CHANNEL})")
|
||||||
|
add_custom_target(launcher_version
|
||||||
|
COMMAND "${CMAKE_COMMAND}"
|
||||||
|
"-DLAUNCHER_VERSION_NUMERIC=${LAUNCHER_VERSION_NUMERIC}"
|
||||||
|
"-DLAUNCHER_SOURCE_DIR=${LAUNCHER_SOURCE_DIR}"
|
||||||
|
"-DLAUNCHER_VERSION_HEADER=${LAUNCHER_VERSION_HEADER}"
|
||||||
|
"-DLAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION}"
|
||||||
|
-P "${PROJECT_SOURCE_DIR}/cmake/LauncherVersion.cmake"
|
||||||
|
BYPRODUCTS "${LAUNCHER_VERSION_HEADER}"
|
||||||
|
COMMENT "Версия лаунчера из git"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||||
|
find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Qml Svg Core CorePrivate Gui Network)
|
||||||
|
find_package(Qt6 QUIET COMPONENTS WebEngineQuick)
|
||||||
|
if(Qt6WebEngineQuick_FOUND)
|
||||||
|
message(STATUS "Qt WebEngine найден: вход через аккаунт Microsoft будет доступен")
|
||||||
|
set(LAUNCHER_MSA_QML_FILES MicrosoftLoginDialog.qml)
|
||||||
|
else()
|
||||||
|
message(WARNING
|
||||||
|
"Qt WebEngine не найден — лаунчер соберётся без входа через аккаунт Microsoft. "
|
||||||
|
"Чтобы его включить, доустановите в MaintenanceTool для своей версии Qt сразу три "
|
||||||
|
"компонента: Qt WebEngine, Qt WebChannel и Qt Positioning.")
|
||||||
|
set(LAUNCHER_MSA_QML_FILES "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_standard_project_setup(REQUIRES 6.8)
|
||||||
|
|
||||||
|
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
configure_file("${LAUNCHER_ICON_DIR}/icon.rc.in"
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/icon.rc" @ONLY)
|
||||||
|
set(LAUNCHER_APP_ICON "${CMAKE_CURRENT_BINARY_DIR}/icon.rc")
|
||||||
|
elseif(APPLE)
|
||||||
|
set(LAUNCHER_APP_ICON "${LAUNCHER_ICON_DIR}/icon.icns")
|
||||||
|
set_source_files_properties(${LAUNCHER_APP_ICON} PROPERTIES
|
||||||
|
MACOSX_PACKAGE_LOCATION "Resources")
|
||||||
|
else()
|
||||||
|
set(LAUNCHER_APP_ICON "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_add_executable(BiLauncher
|
||||||
|
main.cpp
|
||||||
|
${LAUNCHER_APP_ICON}
|
||||||
|
)
|
||||||
|
|
||||||
|
qt_add_resources(BiLauncher "appicon"
|
||||||
|
PREFIX "/icons"
|
||||||
|
BASE "icons"
|
||||||
|
FILES
|
||||||
|
icons/icon_16x16.png
|
||||||
|
icons/icon_16x16@2x.png
|
||||||
|
icons/icon_24x24.png
|
||||||
|
icons/icon_32x32.png
|
||||||
|
icons/icon_32x32@2x.png
|
||||||
|
icons/icon_48x48.png
|
||||||
|
icons/icon_64x64.png
|
||||||
|
icons/icon_128x128.png
|
||||||
|
icons/icon_128x128@2x.png
|
||||||
|
icons/icon_256x256.png
|
||||||
|
icons/icon_256x256@2x.png
|
||||||
|
icons/icon_512x512.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
|
||||||
|
URI BiLauncher
|
||||||
|
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
||||||
|
SOURCES
|
||||||
|
localization.h
|
||||||
|
localization.cpp
|
||||||
|
launcherbackend.h
|
||||||
|
launcherbackend.cpp
|
||||||
|
launcherpaths.h
|
||||||
|
launcherpaths.cpp
|
||||||
|
minecraftversion.h
|
||||||
|
minecraftversion.cpp
|
||||||
|
javalocator.h
|
||||||
|
javalocator.cpp
|
||||||
|
zlibreference.h
|
||||||
|
zlibreference.cpp
|
||||||
|
javaruntime.h
|
||||||
|
javaruntime.cpp
|
||||||
|
javaruntimeservice.h
|
||||||
|
javaruntimeservice.cpp
|
||||||
|
javainstaller.h
|
||||||
|
javainstaller.cpp
|
||||||
|
authservice.h
|
||||||
|
authservice.cpp
|
||||||
|
msaauthservice.h
|
||||||
|
msaauthservice.cpp
|
||||||
|
gamelauncher.h
|
||||||
|
gamelauncher.cpp
|
||||||
|
versionmanifestservice.h
|
||||||
|
versionmanifestservice.cpp
|
||||||
|
versioninstaller.h
|
||||||
|
versioninstaller.cpp
|
||||||
|
modloader.h
|
||||||
|
modloaderversionservice.h
|
||||||
|
modloaderversionservice.cpp
|
||||||
|
modloaderinstaller.h
|
||||||
|
modloaderinstaller.cpp
|
||||||
|
buildarchiveworker.h
|
||||||
|
buildarchiveworker.cpp
|
||||||
|
buildswitcher.h
|
||||||
|
buildswitcher.cpp
|
||||||
|
gamedirimport.h
|
||||||
|
gamedirimport.cpp
|
||||||
|
seasonalbuildservice.h
|
||||||
|
seasonalbuildservice.cpp
|
||||||
|
seasonalpackdownloader.h
|
||||||
|
seasonalpackdownloader.cpp
|
||||||
|
QML_FILES
|
||||||
|
Main.qml
|
||||||
|
DarkCombo.qml
|
||||||
|
VersionPickerDialog.qml
|
||||||
|
JavaPickerDialog.qml
|
||||||
|
LabelledField.qml
|
||||||
|
BuildsDialog.qml
|
||||||
|
SeasonalBuildsDialog.qml
|
||||||
|
LoaderRow.qml
|
||||||
|
ProgressPanel.qml
|
||||||
|
${LAUNCHER_MSA_QML_FILES}
|
||||||
|
RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg i18n/translations.json
|
||||||
|
RESOURCES images/launcherBackground.png
|
||||||
|
RESOURCES images/launcherBackground2static.jpg
|
||||||
|
RESOURCES CHANGELOG.md
|
||||||
|
RESOURCES LICENSE
|
||||||
|
RESOURCES README.md
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
|
||||||
|
# If you are developing for iOS or macOS you should consider setting an
|
||||||
|
# explicit, fixed bundle identifier manually though.
|
||||||
|
set_target_properties(BiLauncher PROPERTIES
|
||||||
|
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
||||||
|
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||||
|
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
||||||
|
MACOSX_BUNDLE_ICON_FILE icon.icns
|
||||||
|
MACOSX_BUNDLE TRUE
|
||||||
|
WIN32_EXECUTABLE TRUE
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(BiLauncher
|
||||||
|
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
|
||||||
|
add_dependencies(BiLauncher launcher_version)
|
||||||
|
|
||||||
|
if(Qt6WebEngineQuick_FOUND)
|
||||||
|
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
||||||
|
target_compile_definitions(BiLauncher PRIVATE LAUNCHER_HAS_WEBENGINE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
|
if(UNIX AND NOT APPLE)
|
||||||
|
set(LAUNCHER_ZLIB_DIR "${CMAKE_INSTALL_LIBDIR}/BiLauncher")
|
||||||
|
set(LAUNCHER_ZLIB_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/zlib")
|
||||||
|
|
||||||
|
add_library(launcher_zlib_reference SHARED
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/adler32.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/compress.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/crc32.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/deflate.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzclose.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzlib.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzread.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzwrite.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/infback.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/inffast.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/inflate.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/inftrees.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/trees.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/uncompr.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/zutil.c
|
||||||
|
)
|
||||||
|
target_include_directories(launcher_zlib_reference PRIVATE ${LAUNCHER_ZLIB_SOURCES})
|
||||||
|
|
||||||
|
target_compile_definitions(launcher_zlib_reference PRIVATE
|
||||||
|
HAVE_UNISTD_H=1 HAVE_STDARG_H=1 _LARGEFILE64_SOURCE=1)
|
||||||
|
|
||||||
|
set_target_properties(launcher_zlib_reference PROPERTIES
|
||||||
|
OUTPUT_NAME z
|
||||||
|
VERSION 1.3.1
|
||||||
|
SOVERSION 1
|
||||||
|
C_VISIBILITY_PRESET default
|
||||||
|
LIBRARY_OUTPUT_DIRECTORY $<TARGET_FILE_DIR:BiLauncher>/zlib
|
||||||
|
)
|
||||||
|
add_dependencies(BiLauncher launcher_zlib_reference)
|
||||||
|
|
||||||
|
target_compile_definitions(BiLauncher PRIVATE
|
||||||
|
LAUNCHER_ZLIB_INSTALL_DIR="${CMAKE_INSTALL_FULL_LIBDIR}/BiLauncher"
|
||||||
|
)
|
||||||
|
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
||||||
|
|
||||||
|
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
||||||
|
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
||||||
|
RENAME bilauncher.png)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
install(FILES "${LAUNCHER_ICON_DIR}/bilauncher.desktop"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT APPLE)
|
||||||
|
install(FILES
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/LICENSE"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/CHANGELOG.md"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/packaging/THIRD_PARTY_LICENSES.txt"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/doc/BiLauncher")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(TARGETS BiLauncher
|
||||||
|
BUNDLE DESTINATION .
|
||||||
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
)
|
||||||
|
set(deploy_opts "")
|
||||||
|
if(APPLE)
|
||||||
|
set(deploy_opts -hardened-runtime)
|
||||||
|
endif()
|
||||||
|
qt_generate_deploy_qml_app_script(
|
||||||
|
TARGET BiLauncher
|
||||||
|
OUTPUT_SCRIPT deploy_script
|
||||||
|
MACOS_BUNDLE_POST_BUILD
|
||||||
|
NO_UNSUPPORTED_PLATFORM_ERROR
|
||||||
|
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
||||||
|
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
||||||
|
install(SCRIPT ${deploy_script})
|
||||||
|
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
|
||||||
|
if(BILAUNCHER_BUILD_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
ComboBox {
|
||||||
|
id: darkCombo
|
||||||
|
|
||||||
|
indicator: Image {
|
||||||
|
width: 10; height: 10
|
||||||
|
x: darkCombo.width - width - 12
|
||||||
|
y: (darkCombo.height - height) / 2
|
||||||
|
source: darkCombo.down ? "images/Profile_Box/Asset_23.svg"
|
||||||
|
: "images/Profile_Box/Asset_24.svg"
|
||||||
|
rotation: 180
|
||||||
|
sourceSize.width: 10; sourceSize.height: 10
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
leftPadding: 10
|
||||||
|
rightPadding: darkCombo.indicator.width + 22
|
||||||
|
text: darkCombo.displayText
|
||||||
|
color: "#ffffff"
|
||||||
|
font: darkCombo.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#2a2a2a"
|
||||||
|
radius: 6
|
||||||
|
border.color: darkCombo.activeFocus ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: darkCombo.width
|
||||||
|
highlighted: darkCombo.highlightedIndex === index
|
||||||
|
contentItem: Text {
|
||||||
|
text: modelData
|
||||||
|
color: "#ffffff"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle { color: highlighted ? "#3a3a3a" : "#2a2a2a" }
|
||||||
|
}
|
||||||
|
|
||||||
|
popup: Popup {
|
||||||
|
y: darkCombo.height
|
||||||
|
width: darkCombo.width
|
||||||
|
implicitHeight: Math.min(contentItem.implicitHeight, 220)
|
||||||
|
padding: 1
|
||||||
|
|
||||||
|
contentItem: ListView {
|
||||||
|
clip: true
|
||||||
|
implicitHeight: contentHeight
|
||||||
|
model: darkCombo.delegateModel
|
||||||
|
currentIndex: darkCombo.highlightedIndex
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#2a2a2a"
|
||||||
|
radius: 6
|
||||||
|
border.color: "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 2.15
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: picker
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
|
||||||
|
property string selectedId: ""
|
||||||
|
property string category: "java"
|
||||||
|
property string filterText: ""
|
||||||
|
property bool installedOnly: false
|
||||||
|
|
||||||
|
property int requiredMajor: 0
|
||||||
|
|
||||||
|
signal runtimeChosen(string runtimeId)
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 720
|
||||||
|
height: 480
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var categories: [
|
||||||
|
{ key: "java", title: Loc.t.common.java.kind.java, hint: Loc.t.java.filter.mojang },
|
||||||
|
{ key: "jdk", title: Loc.t.common.java.kind.jdk, hint: Loc.t.java.filter.temurinJdk },
|
||||||
|
{ key: "jre", title: Loc.t.common.java.kind.jre, hint: Loc.t.java.filter.temurinJre }
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly property var visibleEntries: {
|
||||||
|
const query = picker.filterText.trim().toLowerCase()
|
||||||
|
const source = picker.backend.javaCatalog
|
||||||
|
const out = []
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
const entry = source[i]
|
||||||
|
if (entry.kind !== picker.category)
|
||||||
|
continue
|
||||||
|
if (picker.installedOnly && !entry.installed)
|
||||||
|
continue
|
||||||
|
if (query !== "" && entry.search.indexOf(query) === -1)
|
||||||
|
continue
|
||||||
|
out.push(entry)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var selectedEntry: {
|
||||||
|
const list = picker.backend.javaCatalog
|
||||||
|
for (var i = 0; i < list.length; ++i) {
|
||||||
|
if (list[i].id === picker.selectedId)
|
||||||
|
return list[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFor(runtimeId, required) {
|
||||||
|
picker.selectedId = runtimeId
|
||||||
|
picker.requiredMajor = required || 0
|
||||||
|
picker.filterText = ""
|
||||||
|
searchField.text = ""
|
||||||
|
picker.category = picker.categoryOf(runtimeId)
|
||||||
|
picker.backend.refreshJavaCatalog()
|
||||||
|
picker.open()
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryOf(runtimeId) {
|
||||||
|
const source = picker.backend.javaCatalog
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
if (source[i].id === runtimeId)
|
||||||
|
return source[i].kind
|
||||||
|
}
|
||||||
|
return "java"
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexOfSelected() {
|
||||||
|
const list = picker.visibleEntries
|
||||||
|
for (var i = 0; i < list.length; ++i) {
|
||||||
|
if (list[i].id === picker.selectedId)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealSelected() {
|
||||||
|
const index = picker.indexOfSelected()
|
||||||
|
runtimeList.currentIndex = index
|
||||||
|
if (index >= 0)
|
||||||
|
runtimeList.positionViewAtIndex(index, ListView.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptSelection() {
|
||||||
|
const entry = picker.selectedEntry
|
||||||
|
if (!entry)
|
||||||
|
return
|
||||||
|
picker.runtimeChosen(entry.id)
|
||||||
|
if (!entry.installed && entry.downloadable)
|
||||||
|
picker.backend.installJavaRuntime(entry.id)
|
||||||
|
picker.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.java.label.version
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 170
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: picker.categories
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: categoryRow
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: 44
|
||||||
|
radius: 5
|
||||||
|
color: picker.category === categoryRow.modelData.key ? "#91B315"
|
||||||
|
: categoryArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: categoryRow.modelData.title
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: categoryRow.modelData.hint
|
||||||
|
color: picker.category === categoryRow.modelData.key
|
||||||
|
? "#1e1e1e" : "#777777"
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: categoryArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
picker.category = categoryRow.modelData.key
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { width: 1; height: 8 }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width - 4
|
||||||
|
visible: picker.requiredMajor > 0
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 10
|
||||||
|
text: Loc.t.java.hint.required
|
||||||
|
.arg(picker.requiredMajor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 1
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: searchField
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 8
|
||||||
|
height: 32
|
||||||
|
placeholderText: Loc.t.common.placeholder.searchVersion
|
||||||
|
color: "#ffffff"
|
||||||
|
placeholderTextColor: "#666666"
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#232323"
|
||||||
|
radius: 5
|
||||||
|
border.color: searchField.activeFocus ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
onTextChanged: {
|
||||||
|
picker.filterText = text
|
||||||
|
runtimeList.currentIndex = picker.indexOfSelected()
|
||||||
|
}
|
||||||
|
Keys.onEscapePressed: picker.close()
|
||||||
|
Keys.onReturnPressed: picker.acceptSelection()
|
||||||
|
Keys.onEnterPressed: picker.acceptSelection()
|
||||||
|
Keys.onDownPressed: runtimeList.step(1)
|
||||||
|
Keys.onUpPressed: runtimeList.step(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
id: installedOnlyBox
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 4
|
||||||
|
height: 28
|
||||||
|
checked: picker.installedOnly
|
||||||
|
onToggled: {
|
||||||
|
picker.installedOnly = checked
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator: Rectangle {
|
||||||
|
implicitWidth: 16
|
||||||
|
implicitHeight: 16
|
||||||
|
x: 0
|
||||||
|
y: (installedOnlyBox.height - height) / 2
|
||||||
|
radius: 4
|
||||||
|
color: installedOnlyBox.checked ? "#91B315" : "#2a2a2a"
|
||||||
|
border.color: installedOnlyBox.checked ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: installedOnlyBox.checked
|
||||||
|
text: "✓"
|
||||||
|
color: "#1e1e1e"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
leftPadding: installedOnlyBox.indicator.width + 8
|
||||||
|
text: Loc.t.java.filter.downloadedOnly
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: runtimeList
|
||||||
|
anchors.top: searchField.bottom
|
||||||
|
anchors.topMargin: 6
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: installedOnlyBox.top
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
clip: true
|
||||||
|
spacing: 1
|
||||||
|
model: picker.visibleEntries
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
|
||||||
|
function step(delta) {
|
||||||
|
const next = runtimeList.currentIndex + delta
|
||||||
|
if (next < 0 || next >= runtimeList.count)
|
||||||
|
return
|
||||||
|
runtimeList.currentIndex = next
|
||||||
|
runtimeList.positionViewAtIndex(next, ListView.Contain)
|
||||||
|
picker.selectedId = picker.visibleEntries[next].id
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: runtimeRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool chosen: picker.selectedId === runtimeRow.modelData.id
|
||||||
|
|
||||||
|
readonly property bool tooOld: picker.requiredMajor > 0
|
||||||
|
&& runtimeRow.modelData.major < picker.requiredMajor
|
||||||
|
|
||||||
|
width: runtimeList.width
|
||||||
|
height: 44
|
||||||
|
radius: 4
|
||||||
|
color: runtimeRow.chosen ? "#91B315"
|
||||||
|
: runtimeArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: rightMarks.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: 6
|
||||||
|
Text {
|
||||||
|
text: runtimeRow.modelData.label
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: runtimeRow.modelData.lts
|
||||||
|
text: "LTS"
|
||||||
|
color: runtimeRow.chosen ? "#1e1e1e" : "#91B315"
|
||||||
|
font.pixelSize: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: runtimeRow.chosen ? "#1e1e1e"
|
||||||
|
: runtimeRow.tooOld ? "#aa7777" : "#888888"
|
||||||
|
font.pixelSize: 10
|
||||||
|
text: {
|
||||||
|
const parts = [runtimeRow.modelData.detail,
|
||||||
|
runtimeRow.modelData.coverage]
|
||||||
|
if (runtimeRow.modelData.sizeMb > 0)
|
||||||
|
parts.push(Loc.t.common.status.megabytes.arg(runtimeRow.modelData.sizeMb))
|
||||||
|
if (runtimeRow.tooOld)
|
||||||
|
parts.push(Loc.t.java.warning.tooOldForVersion)
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: rightMarks
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: runtimeRow.modelData.installed
|
||||||
|
text: "✓"
|
||||||
|
color: runtimeRow.chosen ? "#1e1e1e" : "#91B315"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
visible: runtimeRow.modelData.installed
|
||||||
|
source: "images/Trash.svg"
|
||||||
|
width: 14
|
||||||
|
height: 14
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
opacity: trashArea.containsMouse ? 1.0 : 0.55
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: trashArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -5
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
picker.backend.removeJavaRuntime(runtimeRow.modelData.id)
|
||||||
|
if (picker.selectedId === runtimeRow.modelData.id)
|
||||||
|
picker.selectedId = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: runtimeArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.rightMargin: 40
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
picker.selectedId = runtimeRow.modelData.id
|
||||||
|
runtimeList.currentIndex = runtimeRow.index
|
||||||
|
}
|
||||||
|
onDoubleClicked: {
|
||||||
|
picker.selectedId = runtimeRow.modelData.id
|
||||||
|
picker.acceptSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: runtimeList
|
||||||
|
width: runtimeList.width - 20
|
||||||
|
visible: picker.visibleEntries.length === 0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.italic: true
|
||||||
|
text: picker.backend.javaCatalogLoading
|
||||||
|
? Loc.t.java.progress.loadingCatalog
|
||||||
|
: picker.backend.javaCatalog.length === 0
|
||||||
|
? Loc.t.java.error.catalogUnavailable
|
||||||
|
: Loc.t.common.status.nothingFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 20
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width - 320
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: picker.selectedEntry === null ? "#666666" : "#91B315"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: picker.selectedEntry === null
|
||||||
|
? Loc.t.common.status.noBuildSelected
|
||||||
|
: picker.selectedEntry.installed
|
||||||
|
? picker.selectedEntry.label
|
||||||
|
: Loc.t.java.status.willDownload.arg(picker.selectedEntry.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 20
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
width: 110; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: picker.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: picker.selectedEntry !== null && !picker.selectedEntry.installed
|
||||||
|
? Loc.t.common.button.download : Loc.t.common.button.select
|
||||||
|
width: 110; height: 36
|
||||||
|
enabled: picker.selectedEntry !== null
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: picker.acceptSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: searchField.forceActiveFocus()
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: labelled
|
||||||
|
|
||||||
|
property alias text: field.text
|
||||||
|
property alias validator: field.validator
|
||||||
|
property string label: ""
|
||||||
|
property string placeholder: ""
|
||||||
|
|
||||||
|
signal editingFinished()
|
||||||
|
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: labelled.label
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: field
|
||||||
|
width: labelled.width
|
||||||
|
onEditingFinished: labelled.editingFinished()
|
||||||
|
height: 32
|
||||||
|
placeholderText: labelled.placeholder
|
||||||
|
color: "#ffffff"
|
||||||
|
placeholderTextColor: "#666666"
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#2a2a2a"
|
||||||
|
radius: 6
|
||||||
|
border.color: field.activeFocus ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: loaderRow
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
required property string loaderKey
|
||||||
|
required property string title
|
||||||
|
|
||||||
|
property string gameVersion: ""
|
||||||
|
property alias checked: box.checked
|
||||||
|
property string selectedVersion: ""
|
||||||
|
property var entries: []
|
||||||
|
signal userChecked()
|
||||||
|
signal changed()
|
||||||
|
|
||||||
|
property bool applying: false
|
||||||
|
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
function versionIndex(version) {
|
||||||
|
for (var i = 0; i < loaderRow.entries.length; ++i) {
|
||||||
|
if (loaderRow.entries[i].version === version)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEntries(list) {
|
||||||
|
loaderRow.entries = list
|
||||||
|
|
||||||
|
if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) {
|
||||||
|
const fallback = list.length > 0 ? list[0].version : ""
|
||||||
|
const changedValue = loaderRow.selectedVersion !== fallback
|
||||||
|
loaderRow.selectedVersion = fallback
|
||||||
|
if (changedValue && !loaderRow.applying)
|
||||||
|
loaderRow.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
combo.currentIndex = loaderRow.versionIndex(loaderRow.selectedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
if (!box.checked || loaderRow.gameVersion === "") {
|
||||||
|
loaderRow.applyEntries([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey,
|
||||||
|
loaderRow.gameVersion))
|
||||||
|
loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBuild(loader, loaderVersion) {
|
||||||
|
loaderRow.applying = true
|
||||||
|
box.checked = loader === loaderRow.loaderKey
|
||||||
|
loaderRow.selectedVersion = box.checked ? loaderVersion : ""
|
||||||
|
loaderRow.reload()
|
||||||
|
loaderRow.applying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onGameVersionChanged: {
|
||||||
|
loaderRow.selectedVersion = ""
|
||||||
|
loaderRow.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: loaderRow.backend
|
||||||
|
function onLoaderVersionsChanged(key, game) {
|
||||||
|
if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked)
|
||||||
|
return
|
||||||
|
loaderRow.applyEntries(loaderRow.backend.loaderVersions(key, game))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
id: box
|
||||||
|
text: loaderRow.title
|
||||||
|
enabled: loaderRow.gameVersion !== ""
|
||||||
|
|
||||||
|
indicator: Rectangle {
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
x: 0
|
||||||
|
y: (box.height - height) / 2
|
||||||
|
radius: 4
|
||||||
|
color: box.checked ? "#91B315" : "#2a2a2a"
|
||||||
|
border.color: box.checked ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: box.checked
|
||||||
|
text: "✓"
|
||||||
|
color: "#1e1e1e"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
leftPadding: box.indicator.width + 8
|
||||||
|
text: box.text
|
||||||
|
color: box.enabled ? "#ffffff" : "#666666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggled: {
|
||||||
|
if (box.checked)
|
||||||
|
loaderRow.userChecked()
|
||||||
|
else
|
||||||
|
loaderRow.selectedVersion = ""
|
||||||
|
loaderRow.reload()
|
||||||
|
loaderRow.changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DarkCombo {
|
||||||
|
id: combo
|
||||||
|
visible: box.checked && loaderRow.entries.length > 0
|
||||||
|
width: loaderRow.width - 26
|
||||||
|
height: 32
|
||||||
|
x: 26
|
||||||
|
model: loaderRow.entries.map(entry => entry.label)
|
||||||
|
|
||||||
|
onActivated: (index) => {
|
||||||
|
if (index < 0 || index >= loaderRow.entries.length)
|
||||||
|
return
|
||||||
|
loaderRow.selectedVersion = loaderRow.entries[index].version
|
||||||
|
loaderRow.changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: box.checked && loaderRow.entries.length === 0
|
||||||
|
x: 26
|
||||||
|
width: loaderRow.width - 26
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
font.pixelSize: 11
|
||||||
|
color: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||||
|
? "#888888" : "#cc6666"
|
||||||
|
text: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||||
|
? Loc.t.loader.progress.loadingVersions.arg(loaderRow.title)
|
||||||
|
: Loc.t.loader.status.unsupported.arg(loaderRow.title)
|
||||||
|
.arg(loaderRow.gameVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtWebEngine
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: msLogin
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
|
||||||
|
signal failed(string message)
|
||||||
|
property bool codeTaken: false
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
modal: true
|
||||||
|
width: 560
|
||||||
|
height: 680
|
||||||
|
padding: 0
|
||||||
|
closePolicy: Popup.NoAutoClose
|
||||||
|
|
||||||
|
function openAt(url) {
|
||||||
|
codeTaken = false
|
||||||
|
web.url = url
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUrl(url) {
|
||||||
|
if (codeTaken)
|
||||||
|
return
|
||||||
|
const info = backend.inspectMicrosoftRedirect(String(url))
|
||||||
|
if (!info.matched)
|
||||||
|
return
|
||||||
|
|
||||||
|
codeTaken = true
|
||||||
|
close()
|
||||||
|
if (info.code !== "") {
|
||||||
|
backend.finishMicrosoftLogin(info.code)
|
||||||
|
} else {
|
||||||
|
backend.cancelMicrosoftLogin()
|
||||||
|
msLogin.failed(info.error !== "" ? info.error : Loc.t.auth.msa.error.incomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.auth.msa.title.signIn
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
WebEngineProfilePrototype {
|
||||||
|
id: msProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
WebEngineView {
|
||||||
|
id: web
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
|
||||||
|
profile: msProfile.instance()
|
||||||
|
|
||||||
|
onUrlChanged: msLogin.handleUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
running: web.loading
|
||||||
|
visible: running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
width: 110; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
msLogin.codeTaken = true
|
||||||
|
msLogin.close()
|
||||||
|
msLogin.backend.cancelMicrosoftLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: panel
|
||||||
|
|
||||||
|
property string title: ""
|
||||||
|
property string status: ""
|
||||||
|
property double fraction: -1
|
||||||
|
property string detail: ""
|
||||||
|
property bool cancellable: true
|
||||||
|
|
||||||
|
signal cancelRequested()
|
||||||
|
|
||||||
|
width: 320
|
||||||
|
height: 72
|
||||||
|
radius: 8
|
||||||
|
color: "#1e1e1e"
|
||||||
|
opacity: 0.94
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 12; y: 8
|
||||||
|
width: parent.width - 70
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.bold: true
|
||||||
|
text: panel.title
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
y: 8
|
||||||
|
color: "#91B315"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: panel.fraction < 0 ? "…" : Math.round(panel.fraction * 100) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: track
|
||||||
|
x: 12; y: 30
|
||||||
|
width: parent.width - 24
|
||||||
|
height: 6
|
||||||
|
radius: 3
|
||||||
|
color: "#2a2a2a"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: panel.fraction > 0 ? track.width * panel.fraction : 0
|
||||||
|
height: parent.height
|
||||||
|
radius: 3
|
||||||
|
color: "#91B315"
|
||||||
|
Behavior on width { NumberAnimation { duration: 120 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 12; y: 44
|
||||||
|
width: parent.width - 40
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 10
|
||||||
|
text: panel.detail !== "" ? panel.detail : panel.status
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.bottomMargin: 6
|
||||||
|
visible: panel.cancellable
|
||||||
|
text: "✕"
|
||||||
|
color: cancelArea.containsMouse ? "#cc6666" : "#666666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: cancelArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -6
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: panel.cancelRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# BiLauncher
|
||||||
|
|
||||||
|
Самописный простой лаунчер для Minecraft без рекламы и лишнего мусора.
|
||||||
|
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/menu.png" alt="drawing" width="65%"/>
|
||||||
|
|
||||||
|
# Основной функционал
|
||||||
|
|
||||||
|
<h3>Возможность гибкой настройки параметров запуска игры с динамическим выбором версии Java под каждую версию Minecraft</h3>
|
||||||
|
|
||||||
|
<p float="left">
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/settings.png" alt="drawing" width="45%"/>
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/javaSelect.png" alt="drawing" width="52%"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Сохранение профилей пользователей и авторизация тремя доступными способами</h3>
|
||||||
|
|
||||||
|
<p float="left">
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/profiles.png" alt="drawing" width="45%"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Сборки: централизованное управление вашей сборкой с возможностью выбора любой версии игры, добавления модлоадеров и сохранение каждой сборки без необходимости очищать .minecraft</h3>
|
||||||
|
|
||||||
|
<p float="left">
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versions.png" alt="drawing" width="65%"/>
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsLoaders.png" alt="drawing" width="65%"/>
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависиомстей для конечных пользователей</h3>
|
||||||
|
|
||||||
|
<p float="left">
|
||||||
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
|
||||||
|
</p>
|
||||||
|
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 |
@@ -0,0 +1,313 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: seasons
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
property string selectedId: ""
|
||||||
|
readonly property var columns: [
|
||||||
|
{ key: "name", title: Loc.t.common.header.name, width: 250, align: Text.AlignLeft },
|
||||||
|
{ key: "minecraftVersion", title: Loc.t.common.header.version, width: 90, align: Text.AlignLeft },
|
||||||
|
{ key: "loaderLabel", title: Loc.t.common.header.loader, width: 150, align: Text.AlignLeft },
|
||||||
|
{ key: "modCount", title: Loc.t.seasonal.header.mods, width: 70, align: Text.AlignRight },
|
||||||
|
{ key: "seasonStart", title: Loc.t.seasonal.header.start, width: 110, align: Text.AlignLeft },
|
||||||
|
{ key: "seasonEnd", title: Loc.t.seasonal.header.end, width: 110, align: Text.AlignLeft },
|
||||||
|
{ key: "status", title: Loc.t.common.header.status, width: 140, align: Text.AlignLeft }
|
||||||
|
]
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 960
|
||||||
|
height: 560
|
||||||
|
// Закрывается и во время установки: отменить её можно только на панели
|
||||||
|
// загрузки главного окна, а модальное окно эту панель перекрывает.
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var entries: seasons.backend.seasonalCatalog
|
||||||
|
|
||||||
|
readonly property var selectedEntry: {
|
||||||
|
const list = seasons.entries
|
||||||
|
for (var i = 0; i < list.length; ++i) {
|
||||||
|
if (list[i].id === seasons.selectedId)
|
||||||
|
return list[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCatalog() {
|
||||||
|
seasons.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMb(bytes) {
|
||||||
|
if (!bytes || bytes <= 0)
|
||||||
|
return ""
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(entry, key) {
|
||||||
|
if (key === "loaderLabel") {
|
||||||
|
return entry.loaderVersion === ""
|
||||||
|
? entry.loaderTitle
|
||||||
|
: entry.loaderTitle + " " + entry.loaderVersion
|
||||||
|
}
|
||||||
|
return String(entry[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.title.seasonalBuilds
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: tableHeader
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 18
|
||||||
|
height: 30
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: seasons.columns
|
||||||
|
|
||||||
|
delegate: Text {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: modelData.width
|
||||||
|
height: tableHeader.height
|
||||||
|
text: modelData.title
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 12
|
||||||
|
elide: Text.ElideRight
|
||||||
|
horizontalAlignment: modelData.align
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
rightPadding: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: headerLine
|
||||||
|
anchors.top: tableHeader.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 80
|
||||||
|
visible: seasons.entries.length === 0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
color: seasons.backend.seasonalCatalogError === "" ? "#888888" : "#cc6666"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: seasons.backend.seasonalCatalogLoading
|
||||||
|
? Loc.t.seasonal.progress.loadingCatalog
|
||||||
|
: (seasons.backend.seasonalCatalogError === ""
|
||||||
|
? Loc.t.seasonal.status.catalogEmptyYet
|
||||||
|
: seasons.backend.seasonalCatalogError)
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: buildList
|
||||||
|
anchors.top: headerLine.bottom
|
||||||
|
anchors.topMargin: 4
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
clip: true
|
||||||
|
spacing: 1
|
||||||
|
model: seasons.entries
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: buildRow
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: buildList.width
|
||||||
|
height: 36
|
||||||
|
radius: 4
|
||||||
|
color: seasons.selectedId === buildRow.modelData.id ? "#91B315"
|
||||||
|
: rowArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
readonly property bool highlighted: seasons.selectedId === buildRow.modelData.id
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: seasons.columns
|
||||||
|
|
||||||
|
delegate: Text {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: modelData.width
|
||||||
|
text: seasons.cellText(buildRow.modelData, modelData.key)
|
||||||
|
// Подсвеченная строка залита зелёным — тёмный текст
|
||||||
|
// на ней читается, светлый нет.
|
||||||
|
color: buildRow.highlighted
|
||||||
|
? "#1e1e1e"
|
||||||
|
: (modelData.key === "status"
|
||||||
|
&& buildRow.modelData.updateAvailable
|
||||||
|
? "#91B315" : "#ffffff")
|
||||||
|
font.pixelSize: 13
|
||||||
|
elide: Text.ElideRight
|
||||||
|
horizontalAlignment: modelData.align
|
||||||
|
rightPadding: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: rowArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: seasons.selectedId = buildRow.modelData.id
|
||||||
|
onDoubleClicked: {
|
||||||
|
seasons.selectedId = buildRow.modelData.id
|
||||||
|
seasons.installSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSelected() {
|
||||||
|
const entry = seasons.selectedEntry
|
||||||
|
if (!entry || seasons.backend.busy)
|
||||||
|
return
|
||||||
|
seasons.backend.installSeasonalBuild(entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 76
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 18
|
||||||
|
anchors.right: footerButtons.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: seasons.selectedEntry ? seasons.selectedEntry.description : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 11
|
||||||
|
text: {
|
||||||
|
if (!seasons.selectedEntry)
|
||||||
|
return Loc.t.seasonal.hint.pickBuild
|
||||||
|
const entry = seasons.selectedEntry
|
||||||
|
var line = Loc.t.seasonal.status.revision.arg(entry.revision)
|
||||||
|
const size = seasons.formatMb(entry.sizeBytes)
|
||||||
|
if (size !== "")
|
||||||
|
line += Loc.t.seasonal.status.sizeSuffix.arg(size)
|
||||||
|
if (entry.serverUrl !== "")
|
||||||
|
line += " · " + entry.serverUrl
|
||||||
|
if (entry.updateAvailable)
|
||||||
|
line += Loc.t.seasonal.status.installedRevisionSuffix.arg(entry.installedRevision)
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: footerButtons
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 18
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: refreshButton
|
||||||
|
width: 150
|
||||||
|
height: 36
|
||||||
|
enabled: !seasons.backend.seasonalCatalogLoading
|
||||||
|
contentItem: Text {
|
||||||
|
text: seasons.backend.seasonalCatalogLoading ? Loc.t.common.status.updating
|
||||||
|
: Loc.t.common.button.refreshList
|
||||||
|
color: refreshButton.enabled ? "#ffffff" : "#888888"
|
||||||
|
font.pixelSize: 13
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: refreshButton.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: seasons.backend.refreshSeasonalCatalog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: installButton
|
||||||
|
width: 150
|
||||||
|
height: 36
|
||||||
|
enabled: seasons.selectedEntry !== null && !seasons.backend.busy
|
||||||
|
contentItem: Text {
|
||||||
|
text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable
|
||||||
|
? Loc.t.common.button.update
|
||||||
|
: Loc.t.common.button.install
|
||||||
|
color: installButton.enabled ? "#ffffff" : "#888888"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: !installButton.enabled ? "#2a2a2a"
|
||||||
|
: installButton.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: seasons.installSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 2.15
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: picker
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
|
||||||
|
property string selectedId: ""
|
||||||
|
property string category: "release"
|
||||||
|
property string filterText: ""
|
||||||
|
property bool installedOnly: false
|
||||||
|
|
||||||
|
signal versionChosen(string versionId)
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 720
|
||||||
|
height: 480
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var categories: [
|
||||||
|
{ key: "release", title: Loc.t.version.filter.releases },
|
||||||
|
{ key: "snapshot", title: Loc.t.version.filter.snapshots },
|
||||||
|
{ key: "old_beta", title: Loc.t.version.filter.betas },
|
||||||
|
{ key: "old_alpha", title: Loc.t.version.filter.alphas },
|
||||||
|
{ key: "other", title: Loc.t.version.filter.other }
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly property var visibleEntries: {
|
||||||
|
const query = picker.filterText.trim().toLowerCase()
|
||||||
|
const source = picker.backend.versionCatalog
|
||||||
|
const out = []
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
const entry = source[i]
|
||||||
|
if (entry.category !== picker.category)
|
||||||
|
continue
|
||||||
|
if (picker.installedOnly && !entry.installed)
|
||||||
|
continue
|
||||||
|
if (query !== "" && entry.search.indexOf(query) === -1)
|
||||||
|
continue
|
||||||
|
out.push(entry)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFor(versionId) {
|
||||||
|
picker.selectedId = versionId
|
||||||
|
picker.filterText = ""
|
||||||
|
searchField.text = ""
|
||||||
|
picker.category = picker.categoryOf(versionId)
|
||||||
|
picker.backend.refreshVersionCatalog()
|
||||||
|
picker.open()
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryOf(versionId) {
|
||||||
|
const source = picker.backend.versionCatalog
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
if (source[i].id === versionId)
|
||||||
|
return source[i].category
|
||||||
|
}
|
||||||
|
return "release"
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexOfSelected() {
|
||||||
|
const list = picker.visibleEntries
|
||||||
|
for (var i = 0; i < list.length; ++i) {
|
||||||
|
if (list[i].id === picker.selectedId)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealSelected() {
|
||||||
|
const index = picker.indexOfSelected()
|
||||||
|
versionList.currentIndex = index
|
||||||
|
if (index >= 0)
|
||||||
|
versionList.positionViewAtIndex(index, ListView.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptSelection() {
|
||||||
|
if (picker.selectedId === "")
|
||||||
|
return
|
||||||
|
picker.versionChosen(picker.selectedId)
|
||||||
|
picker.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogHas(versionId) {
|
||||||
|
const source = picker.backend.versionCatalog
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
if (source[i].id === versionId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemove(versionId) {
|
||||||
|
const info = picker.backend.versionRemovalInfo(versionId)
|
||||||
|
if (!info || info.installed !== true)
|
||||||
|
return
|
||||||
|
removeConfirm.versionId = versionId
|
||||||
|
removeConfirm.sizeMb = info.sizeMb || 0
|
||||||
|
removeConfirm.dependents = info.dependents || []
|
||||||
|
removeConfirm.builds = info.builds || []
|
||||||
|
removeConfirm.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function performRemove(versionId) {
|
||||||
|
picker.backend.removeVersion(versionId)
|
||||||
|
if (picker.selectedId === versionId && !picker.catalogHas(versionId))
|
||||||
|
picker.selectedId = ""
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: removeConfirm
|
||||||
|
|
||||||
|
property string versionId: ""
|
||||||
|
property int sizeMb: 0
|
||||||
|
property var dependents: []
|
||||||
|
property var builds: []
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 420
|
||||||
|
parent: picker.parent
|
||||||
|
x: (picker.parent.width - width) / 2
|
||||||
|
y: (picker.parent.height - height) / 2
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.version.title.removeConfirm
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Column {
|
||||||
|
spacing: 10
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 18
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: removeConfirm.sizeMb > 0
|
||||||
|
? Loc.t.version.hint.removeWithSize
|
||||||
|
.arg(removeConfirm.versionId).arg(removeConfirm.sizeMb)
|
||||||
|
: Loc.t.version.hint.remove
|
||||||
|
.arg(removeConfirm.versionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.dependents.length > 0
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.version.warning.loaderProfilesDepend
|
||||||
|
.arg(removeConfirm.dependents.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.builds.length > 0
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.version.warning.buildsDepend
|
||||||
|
.arg(removeConfirm.builds.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.version.hint.sharedFilesStay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: removeConfirm.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.remove
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#8a3a3a" : "#663333"
|
||||||
|
radius: 6
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
const id = removeConfirm.versionId
|
||||||
|
removeConfirm.close()
|
||||||
|
picker.performRemove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClosed: removeConfirm.versionId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.label.minecraftVersion
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 150
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: picker.categories
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: categoryRow
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: 34
|
||||||
|
radius: 5
|
||||||
|
color: picker.category === categoryRow.modelData.key ? "#91B315"
|
||||||
|
: categoryArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: categoryRow.modelData.title
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: categoryArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
picker.category = categoryRow.modelData.key
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 1
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: searchField
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 8
|
||||||
|
height: 32
|
||||||
|
placeholderText: Loc.t.common.placeholder.searchVersion
|
||||||
|
color: "#ffffff"
|
||||||
|
placeholderTextColor: "#666666"
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#232323"
|
||||||
|
radius: 5
|
||||||
|
border.color: searchField.activeFocus ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
onTextChanged: {
|
||||||
|
picker.filterText = text
|
||||||
|
versionList.currentIndex = picker.indexOfSelected()
|
||||||
|
}
|
||||||
|
Keys.onEscapePressed: picker.close()
|
||||||
|
Keys.onReturnPressed: picker.acceptSelection()
|
||||||
|
Keys.onEnterPressed: picker.acceptSelection()
|
||||||
|
Keys.onDownPressed: versionList.step(1)
|
||||||
|
Keys.onUpPressed: versionList.step(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
id: installedOnlyBox
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 4
|
||||||
|
height: 28
|
||||||
|
checked: picker.installedOnly
|
||||||
|
onToggled: {
|
||||||
|
picker.installedOnly = checked
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator: Rectangle {
|
||||||
|
implicitWidth: 16
|
||||||
|
implicitHeight: 16
|
||||||
|
x: 0
|
||||||
|
y: (installedOnlyBox.height - height) / 2
|
||||||
|
radius: 4
|
||||||
|
color: installedOnlyBox.checked ? "#91B315" : "#2a2a2a"
|
||||||
|
border.color: installedOnlyBox.checked ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: installedOnlyBox.checked
|
||||||
|
text: "✓"
|
||||||
|
color: "#1e1e1e"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
leftPadding: installedOnlyBox.indicator.width + 8
|
||||||
|
text: Loc.t.version.filter.installedOnly
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: versionList
|
||||||
|
anchors.top: searchField.bottom
|
||||||
|
anchors.topMargin: 6
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: installedOnlyBox.top
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
clip: true
|
||||||
|
spacing: 1
|
||||||
|
model: picker.visibleEntries
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
|
||||||
|
function step(delta) {
|
||||||
|
const next = versionList.currentIndex + delta
|
||||||
|
if (next < 0 || next >= versionList.count)
|
||||||
|
return
|
||||||
|
versionList.currentIndex = next
|
||||||
|
versionList.positionViewAtIndex(next, ListView.Contain)
|
||||||
|
picker.selectedId = picker.visibleEntries[next].id
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: versionRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: versionList.width
|
||||||
|
height: 32
|
||||||
|
radius: 4
|
||||||
|
color: picker.selectedId === versionRow.modelData.id ? "#91B315"
|
||||||
|
: versionArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: rightMarks.left
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: versionRow.modelData.label
|
||||||
|
color: "#ffffff"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: rightMarks
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: versionRow.modelData.installed
|
||||||
|
text: "✓"
|
||||||
|
color: picker.selectedId === versionRow.modelData.id ? "#1e1e1e" : "#91B315"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
visible: versionRow.modelData.installed
|
||||||
|
source: "images/Trash.svg"
|
||||||
|
width: 14
|
||||||
|
height: 14
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
opacity: trashArea.containsMouse ? 1.0 : 0.55
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: trashArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -5
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: picker.askRemove(versionRow.modelData.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: versionArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.rightMargin: 40
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
picker.selectedId = versionRow.modelData.id
|
||||||
|
versionList.currentIndex = versionRow.index
|
||||||
|
}
|
||||||
|
onDoubleClicked: {
|
||||||
|
picker.selectedId = versionRow.modelData.id
|
||||||
|
picker.acceptSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: versionList
|
||||||
|
width: versionList.width - 20
|
||||||
|
visible: picker.visibleEntries.length === 0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.italic: true
|
||||||
|
text: picker.backend.catalogLoading
|
||||||
|
? Loc.t.version.progress.loadingList
|
||||||
|
: picker.backend.versionCatalog.length === 0
|
||||||
|
? Loc.t.version.error.listUnreachable
|
||||||
|
: Loc.t.common.status.nothingFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 20
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width - 300
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: picker.selectedId === "" ? "#666666" : "#91B315"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: picker.selectedId === "" ? Loc.t.version.status.notSelected : picker.selectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 20
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
width: 110; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: picker.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.select
|
||||||
|
width: 110; height: 36
|
||||||
|
enabled: picker.selectedId !== ""
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: picker.acceptSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: searchField.forceActiveFocus()
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
#include "authservice.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const QString kAuthServer = QStringLiteral("https://authserver.ely.by");
|
||||||
|
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
|
||||||
|
const QString kInjectorFileName = QStringLiteral("authlib-injector.jar");
|
||||||
|
|
||||||
|
QString normalizeUuid(const QString &uuid)
|
||||||
|
{
|
||||||
|
return QString(uuid).remove(u'-');
|
||||||
|
}
|
||||||
|
|
||||||
|
QString describeElyError(const QJsonObject &response, int status)
|
||||||
|
{
|
||||||
|
const QString message = response.value(QStringLiteral("errorMessage")).toString();
|
||||||
|
if (!message.isEmpty())
|
||||||
|
return message;
|
||||||
|
const QString error = response.value(QStringLiteral("error")).toString();
|
||||||
|
if (!error.isEmpty())
|
||||||
|
return error;
|
||||||
|
return Loc::text("auth.error.serverCode").arg(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthService::AuthService(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_network(new QNetworkAccessManager(this))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthResult AuthService::offline(const QString &nickname)
|
||||||
|
{
|
||||||
|
AuthResult result;
|
||||||
|
if (nickname.isEmpty()) {
|
||||||
|
result.error = Loc::text("auth.offline.error.noNickname");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray digest = QCryptographicHash::hash(
|
||||||
|
QStringLiteral("OfflinePlayer:%1").arg(nickname).toUtf8(), QCryptographicHash::Md5);
|
||||||
|
digest[6] = static_cast<char>((digest[6] & 0x0f) | 0x30); // версия UUID = 3
|
||||||
|
digest[8] = static_cast<char>((digest[8] & 0x3f) | 0x80); // вариант RFC 4122
|
||||||
|
|
||||||
|
result.ok = true;
|
||||||
|
result.playerName = nickname;
|
||||||
|
result.uuid = QString::fromLatin1(digest.toHex());
|
||||||
|
result.accessToken = QStringLiteral("0");
|
||||||
|
result.clientToken = QStringLiteral("0");
|
||||||
|
result.userType = QStringLiteral("legacy");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AuthService::generateClientToken()
|
||||||
|
{
|
||||||
|
return QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::postJson(
|
||||||
|
const QString &endpoint,
|
||||||
|
const QJsonObject &body,
|
||||||
|
std::function<void(int, const QJsonObject &, const QString &)> handler)
|
||||||
|
{
|
||||||
|
QNetworkRequest request{QUrl(kAuthServer + endpoint)};
|
||||||
|
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||||
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
|
||||||
|
QNetworkReply *reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
|
||||||
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
const QByteArray payload = reply->readAll();
|
||||||
|
|
||||||
|
// Сетевую ошибку отличаем от ответа сервера: при 401 статус есть, а error — есть всегда.
|
||||||
|
QString transportError;
|
||||||
|
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
||||||
|
transportError = reply->errorString();
|
||||||
|
|
||||||
|
handler(status, QJsonDocument::fromJson(payload).object(), transportError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::loginElyBy(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
const QString &accessToken,
|
||||||
|
Callback callback)
|
||||||
|
{
|
||||||
|
if (login.isEmpty()) {
|
||||||
|
AuthResult result;
|
||||||
|
result.error = Loc::text("auth.ely.error.noLogin");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString token = clientToken.isEmpty() ? generateClientToken() : clientToken;
|
||||||
|
|
||||||
|
if (accessToken.isEmpty()) {
|
||||||
|
authenticate(login, password, token, std::move(callback));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("auth.ely.progress.checkingToken"));
|
||||||
|
postJson(QStringLiteral("/auth/validate"),
|
||||||
|
{{QStringLiteral("accessToken"), accessToken}},
|
||||||
|
[this, login, password, token, accessToken, callback](
|
||||||
|
int status, const QJsonObject &, const QString &transportError) {
|
||||||
|
if (!transportError.isEmpty()) {
|
||||||
|
AuthResult result;
|
||||||
|
result.error = Loc::text("auth.ely.error.unreachable").arg(transportError);
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == 200) {
|
||||||
|
AuthResult result;
|
||||||
|
result.ok = true;
|
||||||
|
result.accessToken = accessToken;
|
||||||
|
result.clientToken = token;
|
||||||
|
result.userType = QStringLiteral("ELYBY");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Токен протух — пробуем продлить его, не спрашивая пароль.
|
||||||
|
emit progress(Loc::text("auth.ely.progress.refreshingToken"));
|
||||||
|
postJson(QStringLiteral("/auth/refresh"),
|
||||||
|
{{QStringLiteral("accessToken"), accessToken},
|
||||||
|
{QStringLiteral("clientToken"), token},
|
||||||
|
{QStringLiteral("requestUser"), true}},
|
||||||
|
[this, login, password, token, callback](
|
||||||
|
int refreshStatus, const QJsonObject &response, const QString &error) {
|
||||||
|
if (error.isEmpty() && refreshStatus == 200) {
|
||||||
|
AuthResult result;
|
||||||
|
result.ok = true;
|
||||||
|
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
||||||
|
result.clientToken = token;
|
||||||
|
const QJsonObject profile =
|
||||||
|
response.value(QStringLiteral("selectedProfile")).toObject();
|
||||||
|
result.playerName = profile.value(QStringLiteral("name")).toString();
|
||||||
|
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
||||||
|
result.userType = QStringLiteral("ELYBY");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authenticate(login, password, token, callback);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::loginElyByWithTotp(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &totp,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback)
|
||||||
|
{
|
||||||
|
authenticate(login,
|
||||||
|
password + u':' + totp,
|
||||||
|
clientToken.isEmpty() ? generateClientToken() : clientToken,
|
||||||
|
std::move(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::authenticate(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback)
|
||||||
|
{
|
||||||
|
if (password.isEmpty()) {
|
||||||
|
AuthResult result;
|
||||||
|
result.error = Loc::text("auth.ely.error.noPassword");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("auth.ely.progress.signingIn"));
|
||||||
|
postJson(QStringLiteral("/auth/authenticate"),
|
||||||
|
{{QStringLiteral("username"), login},
|
||||||
|
{QStringLiteral("password"), password},
|
||||||
|
{QStringLiteral("clientToken"), clientToken},
|
||||||
|
{QStringLiteral("requestUser"), true}},
|
||||||
|
[clientToken, callback](int status, const QJsonObject &response, const QString &transportError) {
|
||||||
|
AuthResult result;
|
||||||
|
result.clientToken = clientToken;
|
||||||
|
|
||||||
|
if (!transportError.isEmpty()) {
|
||||||
|
result.error = Loc::text("auth.ely.error.unreachable").arg(transportError);
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != 200) {
|
||||||
|
const QString message = describeElyError(response, status);
|
||||||
|
result.twoFactorRequired = message.contains(QStringLiteral("two factor"),
|
||||||
|
Qt::CaseInsensitive);
|
||||||
|
result.error = result.twoFactorRequired
|
||||||
|
? Loc::text("auth.ely.error.twoFactor")
|
||||||
|
: message;
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject profile = response.value(QStringLiteral("selectedProfile")).toObject();
|
||||||
|
result.playerName = profile.value(QStringLiteral("name")).toString();
|
||||||
|
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
||||||
|
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
||||||
|
result.userType = QStringLiteral("ELYBY");
|
||||||
|
result.ok = !result.accessToken.isEmpty() && !result.playerName.isEmpty();
|
||||||
|
if (!result.ok)
|
||||||
|
result.error = Loc::text("auth.ely.error.noGameProfile");
|
||||||
|
callback(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::ensureAuthlibInjector(
|
||||||
|
const QString &targetDir,
|
||||||
|
std::function<void(const QString &, const QString &)> callback)
|
||||||
|
{
|
||||||
|
QDir().mkpath(targetDir);
|
||||||
|
const QString jarPath = targetDir + u'/' + kInjectorFileName;
|
||||||
|
if (QFileInfo::exists(jarPath)) {
|
||||||
|
callback(jarPath, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("auth.ely.progress.downloadingInjector"));
|
||||||
|
QNetworkRequest manifestRequest{QUrl(kInjectorManifest)};
|
||||||
|
manifestRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
|
||||||
|
QNetworkReply *manifestReply = m_network->get(manifestRequest);
|
||||||
|
connect(manifestReply, &QNetworkReply::finished, this, [this, manifestReply, jarPath, callback]() {
|
||||||
|
manifestReply->deleteLater();
|
||||||
|
if (manifestReply->error() != QNetworkReply::NoError) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorInfoFailed")
|
||||||
|
.arg(manifestReply->errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject manifest = QJsonDocument::fromJson(manifestReply->readAll()).object();
|
||||||
|
const QString url = manifest.value(QStringLiteral("download_url")).toString();
|
||||||
|
const QString expectedSha256 = manifest.value(QStringLiteral("checksums"))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("sha256"))
|
||||||
|
.toString();
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorNoUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkRequest jarRequest{QUrl(url)};
|
||||||
|
jarRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
QNetworkReply *jarReply = m_network->get(jarRequest);
|
||||||
|
connect(jarReply, &QNetworkReply::finished, this, [jarReply, jarPath, expectedSha256, callback]() {
|
||||||
|
jarReply->deleteLater();
|
||||||
|
if (jarReply->error() != QNetworkReply::NoError) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorDownloadFailed").arg(jarReply->errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray payload = jarReply->readAll();
|
||||||
|
if (!expectedSha256.isEmpty()) {
|
||||||
|
const QString actual = QString::fromLatin1(
|
||||||
|
QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex());
|
||||||
|
if (actual != expectedSha256) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorChecksum"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(jarPath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
|
||||||
|
callback({}, Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(jarPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
callback(jarPath, {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class QNetworkAccessManager;
|
||||||
|
|
||||||
|
struct AuthResult
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
bool twoFactorRequired = false;
|
||||||
|
bool licenseMissing = false;
|
||||||
|
QString error;
|
||||||
|
|
||||||
|
QString playerName; // ${auth_player_name}
|
||||||
|
QString uuid; // ${auth_uuid}, hex без дефисов
|
||||||
|
QString accessToken; // ${auth_access_token}
|
||||||
|
QString clientToken; // ${clientid}
|
||||||
|
QString userType; // ${user_type}: legacy / msa / ELYBY
|
||||||
|
|
||||||
|
// Только для аккаунтов Microsoft.
|
||||||
|
QString refreshToken; // продлевает сессию без ввода пароля
|
||||||
|
QString xuid; // ${auth_xuid}
|
||||||
|
QDateTime expiresAt; // UTC-время, когда протухает accessToken
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuthService : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
using Callback = std::function<void(const AuthResult &)>;
|
||||||
|
|
||||||
|
explicit AuthService(QObject *parent = nullptr);
|
||||||
|
static AuthResult offline(const QString &nickname);
|
||||||
|
static QString generateClientToken();
|
||||||
|
|
||||||
|
void loginElyBy(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
const QString &accessToken,
|
||||||
|
Callback callback);
|
||||||
|
|
||||||
|
void loginElyByWithTotp(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &totp,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback);
|
||||||
|
|
||||||
|
void ensureAuthlibInjector(const QString &targetDir,
|
||||||
|
std::function<void(const QString &path, const QString &error)> callback);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progress(const QString &message);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void postJson(const QString &endpoint,
|
||||||
|
const QJsonObject &body,
|
||||||
|
std::function<void(int status, const QJsonObject &response, const QString &transportError)> handler);
|
||||||
|
|
||||||
|
void authenticate(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback);
|
||||||
|
|
||||||
|
QNetworkAccessManager *m_network;
|
||||||
|
};
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
#include "buildarchiveworker.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <QtCore/private/qzipreader_p.h>
|
||||||
|
#include <QtCore/private/qzipwriter_p.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool isSafeRelativePath(const QString &path)
|
||||||
|
{
|
||||||
|
if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
|
||||||
|
return false;
|
||||||
|
const QStringList parts = path.split(u'/');
|
||||||
|
for (const QString &part : parts) {
|
||||||
|
if (part == QLatin1String(".") || part == QLatin1String(".."))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool isInside(const QDir &root, const QString &absolutePath)
|
||||||
|
{
|
||||||
|
const QString base = QDir::cleanPath(root.absolutePath()) + u'/';
|
||||||
|
return QDir::cleanPath(absolutePath).startsWith(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString topSegment(const QString &path)
|
||||||
|
{
|
||||||
|
const int slash = path.indexOf(u'/');
|
||||||
|
return slash < 0 ? path : path.left(slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pruneEmptyDirs(const QDir &root, QStringList dirs)
|
||||||
|
{
|
||||||
|
std::sort(dirs.begin(), dirs.end(),
|
||||||
|
[](const QString &a, const QString &b) { return a.size() > b.size(); });
|
||||||
|
for (const QString &relative : std::as_const(dirs)) {
|
||||||
|
if (relative.isEmpty() || relative == QLatin1String("."))
|
||||||
|
continue;
|
||||||
|
QDir dir(root.absoluteFilePath(relative));
|
||||||
|
if (dir.exists() && dir.isEmpty())
|
||||||
|
root.rmdir(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
const QDir root(gameDir);
|
||||||
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||||
|
| QDir::Hidden | QDir::System);
|
||||||
|
for (const QFileInfo &entry : top) {
|
||||||
|
if (excludeTop.contains(entry.fileName()))
|
||||||
|
continue;
|
||||||
|
if (entry.isSymLink())
|
||||||
|
continue;
|
||||||
|
if (entry.isFile()) {
|
||||||
|
result << entry.fileName();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isDir())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
QDirIterator it(entry.absoluteFilePath(),
|
||||||
|
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||||
|
QDirIterator::Subdirectories);
|
||||||
|
while (it.hasNext())
|
||||||
|
result << root.relativeFilePath(it.next());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildArchiveWorker::BuildArchiveWorker(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::archive(const QString &gameDir,
|
||||||
|
const QString &tempZipPath,
|
||||||
|
const QStringList &excludeTop)
|
||||||
|
{
|
||||||
|
const QDir root(gameDir);
|
||||||
|
if (!root.exists()) {
|
||||||
|
emit finished(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(gameDir)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList files = collectFiles(gameDir, excludeTop);
|
||||||
|
emit progress(0, files.size(), {});
|
||||||
|
|
||||||
|
{
|
||||||
|
QZipWriter writer(tempZipPath);
|
||||||
|
if (writer.status() != QZipWriter::NoError) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveCreateFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(tempZipPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writer.setCompressionPolicy(QZipWriter::AutoCompress);
|
||||||
|
|
||||||
|
int done = 0;
|
||||||
|
for (const QString &relative : files) {
|
||||||
|
if (canceled()) {
|
||||||
|
writer.close();
|
||||||
|
QFile::remove(tempZipPath);
|
||||||
|
emit finished(false, Loc::text("switch.status.archivingCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(root.absoluteFilePath(relative));
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
writer.close();
|
||||||
|
QFile::remove(tempZipPath);
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.readFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writer.addFile(relative, &file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (writer.status() != QZipWriter::NoError) {
|
||||||
|
writer.close();
|
||||||
|
QFile::remove(tempZipPath);
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveWriteFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(relative)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit progress(++done, files.size(), relative);
|
||||||
|
}
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop)
|
||||||
|
{
|
||||||
|
QDir root(gameDir);
|
||||||
|
if (!root.exists()) {
|
||||||
|
emit finished(true, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFileInfoList victims;
|
||||||
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||||
|
| QDir::Hidden | QDir::System);
|
||||||
|
for (const QFileInfo &entry : top) {
|
||||||
|
if (!keepTop.contains(entry.fileName()))
|
||||||
|
victims << entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(0, victims.size(), {});
|
||||||
|
int done = 0;
|
||||||
|
for (const QFileInfo &entry : victims) {
|
||||||
|
const bool ok = entry.isDir() && !entry.isSymLink()
|
||||||
|
? QDir(entry.absoluteFilePath()).removeRecursively()
|
||||||
|
: QFile::remove(entry.absoluteFilePath());
|
||||||
|
if (!ok) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.removeFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(entry.absoluteFilePath())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit progress(++done, victims.size(), entry.fileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
|
||||||
|
{
|
||||||
|
QZipReader reader(zipPath);
|
||||||
|
if (!reader.isReadable()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||||
|
emit progress(0, entries.size(), {});
|
||||||
|
|
||||||
|
const QDir root(gameDir);
|
||||||
|
if (!QDir().mkpath(gameDir)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int done = 0;
|
||||||
|
for (const QZipReader::FileInfo &entry : entries) {
|
||||||
|
if (canceled()) {
|
||||||
|
emit finished(false, Loc::text("switch.status.extractCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entry.isFile) {
|
||||||
|
emit progress(++done, entries.size(), entry.filePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isSafeRelativePath(entry.filePath)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveBadPath").arg(entry.filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString target = root.absoluteFilePath(entry.filePath);
|
||||||
|
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.createFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(target);
|
||||||
|
const QByteArray data = reader.fileData(entry.filePath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.writeFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(entry.filePath), file.errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
emit progress(++done, entries.size(), entry.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::applyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QStringList &forbiddenTop)
|
||||||
|
{
|
||||||
|
QZipReader reader(zipPath);
|
||||||
|
if (!reader.isReadable()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||||
|
QStringList applied;
|
||||||
|
applied.reserve(entries.size());
|
||||||
|
for (const QZipReader::FileInfo &entry : entries) {
|
||||||
|
if (!entry.isFile)
|
||||||
|
continue;
|
||||||
|
if (!isSafeRelativePath(entry.filePath)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.packBadPath").arg(entry.filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (forbiddenTop.contains(topSegment(entry.filePath))) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.packTouchesLauncher")
|
||||||
|
.arg(entry.filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applied << entry.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QDir root(gameDir);
|
||||||
|
if (!QDir().mkpath(gameDir)) {
|
||||||
|
emit finished(false, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int total = removeRelative.size() + applied.size();
|
||||||
|
emit progress(0, total, {});
|
||||||
|
int done = 0;
|
||||||
|
QStringList touchedDirs;
|
||||||
|
for (const QString &relative : removeRelative) {
|
||||||
|
if (canceled()) {
|
||||||
|
emit finished(false, Loc::text("switch.status.installCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) {
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString target = root.absoluteFilePath(relative);
|
||||||
|
if (!isInside(root, target)) {
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (QFileInfo::exists(target) && !QFile::remove(target)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.removeFailed").arg(QDir::toNativeSeparators(target)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString parent = QFileInfo(relative).path();
|
||||||
|
if (!touchedDirs.contains(parent))
|
||||||
|
touchedDirs << parent;
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
}
|
||||||
|
pruneEmptyDirs(root, touchedDirs);
|
||||||
|
for (const QString &relative : std::as_const(applied)) {
|
||||||
|
if (canceled()) {
|
||||||
|
emit finished(false, Loc::text("switch.status.installCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString target = root.absoluteFilePath(relative);
|
||||||
|
if (!isInside(root, target)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.packEscapesGameDir")
|
||||||
|
.arg(relative));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.createFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(target);
|
||||||
|
const QByteArray data = reader.fileData(relative);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||||
|
|| file.write(data) != data.size()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.writeFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit packEntries(applied);
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAtomicInt>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class BuildArchiveWorker : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit BuildArchiveWorker(QObject *parent = nullptr);
|
||||||
|
void requestCancel() { m_cancel.storeRelaxed(1); }
|
||||||
|
void clearCancel() { m_cancel.storeRelaxed(0); }
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop);
|
||||||
|
|
||||||
|
void clear(const QString &gameDir, const QStringList &keepTop);
|
||||||
|
|
||||||
|
void restore(const QString &zipPath, const QString &gameDir);
|
||||||
|
|
||||||
|
void applyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QStringList &forbiddenTop);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progress(int done, int total, const QString ¤tPath);
|
||||||
|
void finished(bool ok, const QString &error);
|
||||||
|
void packEntries(const QStringList &entries);
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
|
||||||
|
|
||||||
|
QAtomicInt m_cancel;
|
||||||
|
};
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
#include "buildswitcher.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include "buildarchiveworker.h"
|
||||||
|
#include "launcherpaths.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QStorageInfo>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const QString kIndexFile = QStringLiteral("index.json");
|
||||||
|
const QString kStateSwitching = QStringLiteral("switching");
|
||||||
|
const QString kStateApplying = QStringLiteral("applying");
|
||||||
|
const QString kStateIdle = QStringLiteral("idle");
|
||||||
|
constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024;
|
||||||
|
|
||||||
|
QString slug(const QString &name)
|
||||||
|
{
|
||||||
|
QString result;
|
||||||
|
result.reserve(name.size());
|
||||||
|
for (const QChar c : name) {
|
||||||
|
if (c.isLetterOrNumber() || c == u'-' || c == u'_')
|
||||||
|
result.append(c);
|
||||||
|
else if (c.isSpace() || c == u'.')
|
||||||
|
result.append(u'_');
|
||||||
|
}
|
||||||
|
while (result.startsWith(u'_'))
|
||||||
|
result.remove(0, 1);
|
||||||
|
return result.isEmpty() ? QStringLiteral("build") : result.left(48);
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 directorySize(const QString &path, const QStringList &excludeTop)
|
||||||
|
{
|
||||||
|
qint64 total = 0;
|
||||||
|
const QDir root(path);
|
||||||
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||||
|
| QDir::Hidden | QDir::System);
|
||||||
|
for (const QFileInfo &entry : top) {
|
||||||
|
if (excludeTop.contains(entry.fileName()) || entry.isSymLink())
|
||||||
|
continue;
|
||||||
|
if (entry.isFile()) {
|
||||||
|
total += entry.size();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QDirIterator it(entry.absoluteFilePath(),
|
||||||
|
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||||
|
QDirIterator::Subdirectories);
|
||||||
|
while (it.hasNext()) {
|
||||||
|
it.next();
|
||||||
|
total += it.fileInfo().size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList BuildSwitcher::sharedTopLevel()
|
||||||
|
{
|
||||||
|
return {QStringLiteral("assets"),
|
||||||
|
QStringLiteral("libraries"),
|
||||||
|
QStringLiteral("versions"),
|
||||||
|
QStringLiteral("launcher_profiles.json"),
|
||||||
|
QStringLiteral("runtime")};
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildSwitcher::BuildSwitcher(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_thread(new QThread(this))
|
||||||
|
, m_worker(new BuildArchiveWorker)
|
||||||
|
{
|
||||||
|
m_worker->moveToThread(m_thread);
|
||||||
|
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
|
||||||
|
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
|
||||||
|
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
|
||||||
|
connect(m_worker, &BuildArchiveWorker::packEntries, this,
|
||||||
|
[this](const QStringList &entries) { m_lastPackEntries = entries; });
|
||||||
|
m_thread->start();
|
||||||
|
|
||||||
|
sweepTempArchives();
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildSwitcher::~BuildSwitcher()
|
||||||
|
{
|
||||||
|
m_worker->requestCancel();
|
||||||
|
m_thread->quit();
|
||||||
|
m_thread->wait(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
double BuildSwitcher::fraction() const
|
||||||
|
{
|
||||||
|
if (m_state == Idle || m_total <= 0)
|
||||||
|
return -1.0;
|
||||||
|
return double(m_done) / double(m_total);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BuildSwitcher::buildDir(int buildId) const
|
||||||
|
{
|
||||||
|
return LauncherPaths::buildDir(buildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BuildSwitcher::archivePathOf(int buildId) const
|
||||||
|
{
|
||||||
|
const QDir dir(buildDir(buildId));
|
||||||
|
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
|
||||||
|
for (const QString &name : zips) {
|
||||||
|
if (!name.startsWith(u'.'))
|
||||||
|
return dir.absoluteFilePath(name);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::setStage(const QString &stage)
|
||||||
|
{
|
||||||
|
m_stage = stage;
|
||||||
|
emit progressChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::switchTo(int fromBuildId,
|
||||||
|
const QString &fromName,
|
||||||
|
int toBuildId,
|
||||||
|
const QString &toName,
|
||||||
|
const QString &gameDir)
|
||||||
|
{
|
||||||
|
if (m_state != Idle) {
|
||||||
|
emit failed(toBuildId, Loc::text("common.error.switchInProgress"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString rootError;
|
||||||
|
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||||
|
emit failed(toBuildId, rootError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_fromId = fromBuildId;
|
||||||
|
m_toId = toBuildId;
|
||||||
|
m_fromName = fromName;
|
||||||
|
m_toName = toName;
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_done = 0;
|
||||||
|
m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
|
||||||
|
if (fromBuildId < 0 || !QDir(gameDir).exists()) {
|
||||||
|
m_state = Clearing;
|
||||||
|
beginClearing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QDir().mkpath(buildDir(fromBuildId))) {
|
||||||
|
emit failed(toBuildId,
|
||||||
|
Loc::text("common.error.createFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(buildDir(fromBuildId))),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes;
|
||||||
|
const QStorageInfo storage(LauncherPaths::buildStorageDir());
|
||||||
|
if (storage.isValid() && storage.bytesAvailable() < needed) {
|
||||||
|
emit failed(toBuildId,
|
||||||
|
Loc::text("switch.error.notEnoughSpace")
|
||||||
|
.arg(fromName)
|
||||||
|
.arg(needed / (1024 * 1024)),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-")
|
||||||
|
+ QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip");
|
||||||
|
|
||||||
|
m_state = Archiving;
|
||||||
|
setStage(Loc::text("switch.progress.saving").arg(fromName));
|
||||||
|
requestArchive(gameDir, m_tempZip, sharedTopLevel());
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::cancel()
|
||||||
|
{
|
||||||
|
if (m_state == Archiving)
|
||||||
|
m_worker->requestCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestArchive(const QString &gameDir,
|
||||||
|
const QString &tempZip,
|
||||||
|
const QStringList &exclude)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, gameDir), Q_ARG(QString, tempZip),
|
||||||
|
Q_ARG(QStringList, exclude));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, gameDir), Q_ARG(QStringList, keep));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestApplyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &remove)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "applyPack", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir),
|
||||||
|
Q_ARG(QStringList, remove), Q_ARG(QStringList, sharedTopLevel()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath)
|
||||||
|
{
|
||||||
|
m_done = done;
|
||||||
|
m_total = total;
|
||||||
|
m_status = currentPath;
|
||||||
|
emit progressChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
|
||||||
|
{
|
||||||
|
switch (m_state) {
|
||||||
|
case Idle:
|
||||||
|
return;
|
||||||
|
case Archiving:
|
||||||
|
if (!ok) {
|
||||||
|
QFile::remove(m_tempZip);
|
||||||
|
fail(error, /*gameDirIntact=*/true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitArchive();
|
||||||
|
return;
|
||||||
|
case Clearing:
|
||||||
|
if (!ok) {
|
||||||
|
fail(error, /*gameDirIntact=*/false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
beginRestoring();
|
||||||
|
return;
|
||||||
|
case Restoring:
|
||||||
|
if (!ok) {
|
||||||
|
fail(error, /*gameDirIntact=*/false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
case Applying:
|
||||||
|
if (!ok) {
|
||||||
|
fail(error, /*gameDirIntact=*/m_done == 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishApplying();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::commitArchive()
|
||||||
|
{
|
||||||
|
const QString target = buildDir(m_fromId) + u'/'
|
||||||
|
+ QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))
|
||||||
|
+ u'_' + slug(m_fromName) + QStringLiteral(".zip");
|
||||||
|
|
||||||
|
if (QFile::exists(target) && !QFile::remove(target)) {
|
||||||
|
QFile::remove(m_tempZip);
|
||||||
|
fail(Loc::text("switch.error.archiveReplaceFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!QFile::rename(m_tempZip, target)) {
|
||||||
|
QFile::remove(m_tempZip);
|
||||||
|
fail(Loc::text("switch.error.archiveSaveFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QDir dir(buildDir(m_fromId));
|
||||||
|
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
|
||||||
|
for (const QString &name : zips) {
|
||||||
|
if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target)
|
||||||
|
QFile::remove(dir.absoluteFilePath(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateSwitching);
|
||||||
|
index.insert(QStringLiteral("from"), m_fromId);
|
||||||
|
index.insert(QStringLiteral("to"), m_toId);
|
||||||
|
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||||
|
builds.insert(QString::number(m_fromId),
|
||||||
|
QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()},
|
||||||
|
{QStringLiteral("name"), m_fromName},
|
||||||
|
{QStringLiteral("savedAt"),
|
||||||
|
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||||||
|
index.insert(QStringLiteral("builds"), builds);
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
m_state = Clearing;
|
||||||
|
beginClearing();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::beginClearing()
|
||||||
|
{
|
||||||
|
setStage(Loc::text("switch.progress.preparing").arg(m_toName));
|
||||||
|
requestClear(m_gameDir, sharedTopLevel());
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::beginRestoring()
|
||||||
|
{
|
||||||
|
const QString archive = archivePathOf(m_toId);
|
||||||
|
if (archive.isEmpty()) {
|
||||||
|
m_state = Restoring;
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_state = Restoring;
|
||||||
|
setStage(Loc::text("switch.progress.restoring").arg(m_toName));
|
||||||
|
requestRestore(archive, m_gameDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::applyPack(int buildId,
|
||||||
|
const QString &buildName,
|
||||||
|
const QString &packZipPath,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QJsonObject ¬e,
|
||||||
|
const QString &gameDir)
|
||||||
|
{
|
||||||
|
if (m_state != Idle) {
|
||||||
|
emit failed(buildId, Loc::text("switch.error.busy"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString rootError;
|
||||||
|
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||||
|
emit failed(buildId, rootError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!QFile::exists(packZipPath)) {
|
||||||
|
emit failed(buildId,
|
||||||
|
Loc::text("switch.error.archiveMissing")
|
||||||
|
.arg(QDir::toNativeSeparators(packZipPath)),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_fromId = -1;
|
||||||
|
m_toId = buildId;
|
||||||
|
m_toName = buildName;
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_packPath = packZipPath;
|
||||||
|
m_packRemove = removeRelative;
|
||||||
|
m_packNote = note;
|
||||||
|
m_lastPackEntries.clear();
|
||||||
|
m_lastPackNote = {};
|
||||||
|
m_done = 0;
|
||||||
|
m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateApplying);
|
||||||
|
index.insert(QStringLiteral("to"), buildId);
|
||||||
|
index.insert(QStringLiteral("pack"), QFileInfo(packZipPath).fileName());
|
||||||
|
index.insert(QStringLiteral("packName"), buildName);
|
||||||
|
index.insert(QStringLiteral("packRemove"), QJsonArray::fromStringList(removeRelative));
|
||||||
|
index.insert(QStringLiteral("packNote"), note);
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
m_state = Applying;
|
||||||
|
beginApplying();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::beginApplying()
|
||||||
|
{
|
||||||
|
setStage(Loc::text("switch.progress.installing").arg(m_toName));
|
||||||
|
requestApplyPack(m_packPath, m_gameDir, m_packRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::finishApplying()
|
||||||
|
{
|
||||||
|
QFile::remove(m_packPath);
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateIdle);
|
||||||
|
index.insert(QStringLiteral("active"), m_toId);
|
||||||
|
index.remove(QStringLiteral("from"));
|
||||||
|
index.remove(QStringLiteral("to"));
|
||||||
|
index.remove(QStringLiteral("pack"));
|
||||||
|
index.remove(QStringLiteral("packName"));
|
||||||
|
index.remove(QStringLiteral("packRemove"));
|
||||||
|
index.remove(QStringLiteral("packNote"));
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
m_lastPackNote = m_packNote;
|
||||||
|
m_packPath.clear();
|
||||||
|
m_packRemove.clear();
|
||||||
|
m_packNote = {};
|
||||||
|
|
||||||
|
const int toId = m_toId;
|
||||||
|
m_state = Idle;
|
||||||
|
m_stage.clear();
|
||||||
|
m_status.clear();
|
||||||
|
m_done = m_total = 0;
|
||||||
|
emit progressChanged();
|
||||||
|
emit finished(toId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::finish()
|
||||||
|
{
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateIdle);
|
||||||
|
index.insert(QStringLiteral("active"), m_toId);
|
||||||
|
index.remove(QStringLiteral("from"));
|
||||||
|
index.remove(QStringLiteral("to"));
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
const int toId = m_toId;
|
||||||
|
m_state = Idle;
|
||||||
|
m_stage.clear();
|
||||||
|
m_status.clear();
|
||||||
|
m_done = m_total = 0;
|
||||||
|
emit progressChanged();
|
||||||
|
emit finished(toId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::fail(const QString &message, bool gameDirIntact)
|
||||||
|
{
|
||||||
|
const int toId = m_toId;
|
||||||
|
m_state = Idle;
|
||||||
|
m_stage.clear();
|
||||||
|
m_status.clear();
|
||||||
|
m_done = m_total = 0;
|
||||||
|
emit progressChanged();
|
||||||
|
emit failed(toId, message, gameDirIntact);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildSwitcher::forgetBuild(int buildId)
|
||||||
|
{
|
||||||
|
if (buildId < 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||||
|
builds.remove(QString::number(buildId));
|
||||||
|
index.insert(QStringLiteral("builds"), builds);
|
||||||
|
if (index.value(QStringLiteral("active")).toInt(-1) == buildId)
|
||||||
|
index.remove(QStringLiteral("active"));
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
QDir dir(buildDir(buildId));
|
||||||
|
return !dir.exists() || dir.removeRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BuildSwitcher::interruptedSwitchWarning() const
|
||||||
|
{
|
||||||
|
const QJsonObject index = readIndex();
|
||||||
|
const QString state = index.value(QStringLiteral("state")).toString();
|
||||||
|
|
||||||
|
if (state == kStateApplying) {
|
||||||
|
const QString name = index.value(QStringLiteral("packName")).toString();
|
||||||
|
return Loc::text("switch.status.applyInterrupted")
|
||||||
|
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.nameSuffix").arg(name));
|
||||||
|
}
|
||||||
|
if (state != kStateSwitching)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||||
|
const QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||||
|
const QString name = builds.value(QString::number(toId))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("name"))
|
||||||
|
.toString();
|
||||||
|
return Loc::text("switch.status.switchInterrupted")
|
||||||
|
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.targetSuffix").arg(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||||||
|
{
|
||||||
|
const QJsonObject index = readIndex();
|
||||||
|
const QString state = index.value(QStringLiteral("state")).toString();
|
||||||
|
|
||||||
|
if (state == kStateApplying) {
|
||||||
|
resumeApplying(index, gameDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state != kStateSwitching)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||||
|
if (toId < 0 || m_state != Idle)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_fromId = -1;
|
||||||
|
m_toId = toId;
|
||||||
|
m_toName = index.value(QStringLiteral("builds"))
|
||||||
|
.toObject()
|
||||||
|
.value(QString::number(toId))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("name"))
|
||||||
|
.toString();
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_done = m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
m_state = Clearing;
|
||||||
|
beginClearing();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &gameDir)
|
||||||
|
{
|
||||||
|
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||||
|
if (toId < 0 || m_state != Idle)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString packName = index.value(QStringLiteral("pack")).toString();
|
||||||
|
const QString packPath = buildDir(toId) + u'/' + packName;
|
||||||
|
if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) {
|
||||||
|
QJsonObject cleaned = index;
|
||||||
|
cleaned.insert(QStringLiteral("state"), kStateIdle);
|
||||||
|
cleaned.remove(QStringLiteral("pack"));
|
||||||
|
cleaned.remove(QStringLiteral("packName"));
|
||||||
|
cleaned.remove(QStringLiteral("packRemove"));
|
||||||
|
cleaned.remove(QStringLiteral("packNote"));
|
||||||
|
cleaned.remove(QStringLiteral("to"));
|
||||||
|
writeIndex(cleaned);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList remove;
|
||||||
|
const QJsonArray removeArray = index.value(QStringLiteral("packRemove")).toArray();
|
||||||
|
for (const QJsonValue &value : removeArray)
|
||||||
|
remove << value.toString();
|
||||||
|
|
||||||
|
m_fromId = -1;
|
||||||
|
m_toId = toId;
|
||||||
|
m_toName = index.value(QStringLiteral("packName")).toString();
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_packPath = packPath;
|
||||||
|
m_packRemove = remove;
|
||||||
|
m_packNote = index.value(QStringLiteral("packNote")).toObject();
|
||||||
|
m_lastPackEntries.clear();
|
||||||
|
m_lastPackNote = {};
|
||||||
|
m_done = m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
m_state = Applying;
|
||||||
|
beginApplying();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject BuildSwitcher::readIndex() const
|
||||||
|
{
|
||||||
|
QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return {};
|
||||||
|
return QJsonDocument::fromJson(file.readAll()).object();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::writeIndex(const QJsonObject &index) const
|
||||||
|
{
|
||||||
|
if (!LauncherPaths::ensureRootExists())
|
||||||
|
return;
|
||||||
|
QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||||
|
if (!file.open(QIODevice::WriteOnly))
|
||||||
|
return;
|
||||||
|
const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented);
|
||||||
|
if (file.write(payload) != payload.size()) {
|
||||||
|
file.cancelWriting();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::sweepTempArchives() const
|
||||||
|
{
|
||||||
|
QDir root(LauncherPaths::buildStorageDir());
|
||||||
|
const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||||
|
for (const QString &build : builds) {
|
||||||
|
QDir dir(root.absoluteFilePath(build));
|
||||||
|
const QStringList leftovers =
|
||||||
|
dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden);
|
||||||
|
for (const QString &name : leftovers)
|
||||||
|
QFile::remove(dir.absoluteFilePath(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QThread;
|
||||||
|
class BuildArchiveWorker;
|
||||||
|
class BuildSwitcher : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit BuildSwitcher(QObject *parent = nullptr);
|
||||||
|
~BuildSwitcher() override;
|
||||||
|
|
||||||
|
bool isRunning() const { return m_state != Idle; }
|
||||||
|
QString stage() const { return m_stage; }
|
||||||
|
QString status() const { return m_status; }
|
||||||
|
double fraction() const;
|
||||||
|
static QStringList sharedTopLevel();
|
||||||
|
|
||||||
|
void switchTo(int fromBuildId,
|
||||||
|
const QString &fromName,
|
||||||
|
int toBuildId,
|
||||||
|
const QString &toName,
|
||||||
|
const QString &gameDir);
|
||||||
|
|
||||||
|
void applyPack(int buildId,
|
||||||
|
const QString &buildName,
|
||||||
|
const QString &packZipPath,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QJsonObject ¬e,
|
||||||
|
const QString &gameDir);
|
||||||
|
|
||||||
|
QStringList lastPackEntries() const { return m_lastPackEntries; }
|
||||||
|
QJsonObject lastPackNote() const { return m_lastPackNote; }
|
||||||
|
|
||||||
|
void cancel();
|
||||||
|
bool forgetBuild(int buildId);
|
||||||
|
bool hasArchive(int buildId) const { return !archivePathOf(buildId).isEmpty(); }
|
||||||
|
QString interruptedSwitchWarning() const;
|
||||||
|
void resumeInterrupted(const QString &gameDir);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progressChanged();
|
||||||
|
void finished(int toBuildId);
|
||||||
|
void failed(int toBuildId, const QString &message, bool gameDirIntact);
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum State { Idle, Archiving, Clearing, Restoring, Applying };
|
||||||
|
void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude);
|
||||||
|
void requestClear(const QString &gameDir, const QStringList &keep);
|
||||||
|
void requestRestore(const QString &zipPath, const QString &gameDir);
|
||||||
|
void requestApplyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &remove);
|
||||||
|
|
||||||
|
void onWorkerFinished(bool ok, const QString &error);
|
||||||
|
void onWorkerProgress(int done, int total, const QString ¤tPath);
|
||||||
|
|
||||||
|
void commitArchive();
|
||||||
|
void beginApplying();
|
||||||
|
void finishApplying();
|
||||||
|
void resumeApplying(const QJsonObject &index, const QString &gameDir);
|
||||||
|
void beginClearing();
|
||||||
|
void beginRestoring();
|
||||||
|
void finish();
|
||||||
|
void fail(const QString &message, bool gameDirIntact);
|
||||||
|
|
||||||
|
QString buildDir(int buildId) const;
|
||||||
|
QString archivePathOf(int buildId) const;
|
||||||
|
void setStage(const QString &stage);
|
||||||
|
|
||||||
|
QJsonObject readIndex() const;
|
||||||
|
void writeIndex(const QJsonObject &index) const;
|
||||||
|
void sweepTempArchives() const;
|
||||||
|
|
||||||
|
QThread *m_thread;
|
||||||
|
BuildArchiveWorker *m_worker;
|
||||||
|
|
||||||
|
State m_state = Idle;
|
||||||
|
QString m_stage;
|
||||||
|
QString m_status;
|
||||||
|
int m_done = 0;
|
||||||
|
int m_total = 0;
|
||||||
|
|
||||||
|
int m_fromId = -1;
|
||||||
|
int m_toId = -1;
|
||||||
|
QString m_fromName;
|
||||||
|
QString m_toName;
|
||||||
|
QString m_gameDir;
|
||||||
|
QString m_tempZip;
|
||||||
|
QString m_packPath;
|
||||||
|
QStringList m_packRemove;
|
||||||
|
QJsonObject m_packNote;
|
||||||
|
QStringList m_lastPackEntries;
|
||||||
|
QJsonObject m_lastPackNote;
|
||||||
|
};
|
||||||
@@ -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,218 @@
|
|||||||
|
# Сборка, упаковка и релизы
|
||||||
|
|
||||||
|
## Что нужно
|
||||||
|
|
||||||
|
- 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] — дата`. Его текст станет описанием релиза.
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# AuthService
|
||||||
|
|
||||||
|
`authservice.h`, `authservice.cpp`
|
||||||
|
|
||||||
|
Вход через Ely.by, офлайн-профиль и загрузка `authlib-injector`. Вход через Microsoft живёт
|
||||||
|
отдельно, в [MsaAuthService](MsaAuthService.md), но отдаёт результат в той же структуре
|
||||||
|
`AuthResult`. Поэтому запуск игры дальше идёт одинаково, как бы игрок ни вошёл.
|
||||||
|
|
||||||
|
Экземпляр создаёт и держит [LauncherBackend](LauncherBackend.md). Сервис ничего не пишет на диск,
|
||||||
|
кроме скачанного jar: токены между запусками сохраняет бэкенд в `profiles.json`.
|
||||||
|
|
||||||
|
## AuthResult
|
||||||
|
|
||||||
|
Каждое поле соответствует подстановке в аргументах запуска.
|
||||||
|
|
||||||
|
| Поле | Подстановка / смысл |
|
||||||
|
|------|---------------------|
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
## Офлайн
|
||||||
|
|
||||||
|
`static AuthResult offline(nickname)` работает без сети. UUID считается так же, как в самом
|
||||||
|
Minecraft: MD5 от `OfflinePlayer:<ник>`, после чего выставляются биты версии 3 и варианта RFC 4122.
|
||||||
|
Значит, у одного ника всегда один UUID, и на офлайн-сервере игрок не теряет инвентарь. В
|
||||||
|
`accessToken` и `clientToken` записывается `"0"`.
|
||||||
|
|
||||||
|
Пустой ник — ошибка.
|
||||||
|
|
||||||
|
## Ely.by
|
||||||
|
|
||||||
|
Сервер — `https://authserver.ely.by`, протокол Yggdrasil, JSON по HTTPS.
|
||||||
|
|
||||||
|
`loginElyBy(login, password, clientToken, accessToken, callback)` старается не спрашивать пароль:
|
||||||
|
|
||||||
|
1. Если сохранённого `accessToken` нет, сразу `/auth/authenticate` с паролем.
|
||||||
|
2. Если есть, `/auth/validate`. Ответ 200 — токен рабочий, вход закончен.
|
||||||
|
3. Иначе `/auth/refresh`. Если продлить удалось, приходят новый токен, ник и UUID.
|
||||||
|
4. Если и это не вышло, `/auth/authenticate` с паролем.
|
||||||
|
|
||||||
|
Если сервер недоступен (статус 0 и сетевая ошибка), цепочка обрывается с ошибкой, без попытки войти
|
||||||
|
по паролю.
|
||||||
|
|
||||||
|
Второй фактор Ely.by не сообщает отдельным кодом. Сервис ищет в тексте ошибки подстроку
|
||||||
|
`two factor` и выставляет `twoFactorRequired`. Тогда бэкенд спрашивает код у игрока и вызывает
|
||||||
|
`loginElyByWithTotp(login, password, totp, clientToken, callback)`, который отправляет пароль в виде
|
||||||
|
`пароль:код`, как требует Ely.by.
|
||||||
|
|
||||||
|
`clientToken` генерируется один раз на профиль (`generateClientToken()`, случайный UUID) и хранится
|
||||||
|
вместе с ним: Yggdrasil привязывает выданный `accessToken` к этому значению.
|
||||||
|
|
||||||
|
По ходу работы испускается `progress(message)`. Бэкенд пересылает его в `launchProgress`.
|
||||||
|
|
||||||
|
## authlib-injector
|
||||||
|
|
||||||
|
Без него игра пойдёт проверять сессию на серверы Mojang, и токен Ely.by окажется недействительным.
|
||||||
|
Jar подключается к JVM через `-javaagent` (см. [GameLauncher](GameLauncher.md)).
|
||||||
|
|
||||||
|
`ensureAuthlibInjector(targetDir, callback)`:
|
||||||
|
|
||||||
|
- если `<targetDir>/authlib-injector.jar` уже есть, сразу отдаёт путь. Версия не проверяется, и
|
||||||
|
обновить файл можно, только удалив его;
|
||||||
|
- иначе читает `https://authlib-injector.yushi.moe/artifact/latest.json`, скачивает jar по
|
||||||
|
`download_url` и, если в манифесте есть `checksums.sha256`, сверяет хеш.
|
||||||
|
|
||||||
|
Колбэк получает либо путь, либо текст ошибки. `targetDir` — это `<root>/runtime`.
|
||||||
|
|
||||||
|
## Как вызываются колбэки
|
||||||
|
|
||||||
|
Все сетевые методы асинхронные, колбэк вызывается один раз в GUI-потоке. Если сервис удалить во
|
||||||
|
время запроса, менеджер сети уйдёт вместе с ним, и колбэк не вызовется вообще. Бэкенд к тому же
|
||||||
|
проверяет в каждом колбэке, не отменили ли запуск, пока шёл запрос.
|
||||||
|
|
||||||
|
Повторных попыток при сетевых ошибках нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# BuildArchiveWorker
|
||||||
|
|
||||||
|
`buildarchiveworker.h`, `buildarchiveworker.cpp`
|
||||||
|
|
||||||
|
Файловые операции над `.minecraft`: упаковать, очистить, распаковать, раскатать пак. В
|
||||||
|
`.minecraft` могут лежать гигабайты модов и миров, поэтому это единственная часть проекта,
|
||||||
|
которая работает не в GUI-потоке.
|
||||||
|
|
||||||
|
Объект создаёт [BuildSwitcher](BuildSwitcher.md) без родителя, переносит в свой `QThread` и вызывает
|
||||||
|
слоты через `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`. Работник не знает ни про
|
||||||
|
сборки, ни про журнал. Он получает пути и отвечает сигналами.
|
||||||
|
|
||||||
|
ZIP читается и пишется через `QZipReader` и `QZipWriter` из `Qt6::CorePrivate`.
|
||||||
|
|
||||||
|
## Слоты
|
||||||
|
|
||||||
|
Каждый слот заканчивается одним `finished(ok, error)`. По ходу работы испускается
|
||||||
|
`progress(done, total, currentPath)` на каждый файл.
|
||||||
|
|
||||||
|
### archive(gameDir, tempZipPath, excludeTop)
|
||||||
|
|
||||||
|
Упаковывает всё содержимое `gameDir`, кроме папок и файлов верхнего уровня из `excludeTop`.
|
||||||
|
Символьные ссылки пропускаются. Сжатие `AutoCompress`: Qt сам не сжимает то, что уже сжато.
|
||||||
|
При ошибке или отмене недописанный архив удаляется.
|
||||||
|
|
||||||
|
### clear(gameDir, keepTop)
|
||||||
|
|
||||||
|
Удаляет всё на верхнем уровне `gameDir`, кроме `keepTop`. Символьная ссылка удаляется как файл,
|
||||||
|
то, на что она указывает, не трогается. Если `gameDir` нет, это успех. Отменить нельзя.
|
||||||
|
|
||||||
|
### restore(zipPath, gameDir)
|
||||||
|
|
||||||
|
Распаковывает архив в `gameDir`. Пути с `..`, абсолютные, с `\` или `:` останавливают распаковку.
|
||||||
|
|
||||||
|
### applyPack(zipPath, gameDir, removeRelative, forbiddenTop)
|
||||||
|
|
||||||
|
1. Сначала проверяет весь архив. Небезопасный путь или файл внутри общих папок (`forbiddenTop`:
|
||||||
|
`versions`, `libraries` и т. д.) — отказ до любых изменений. Пак не может подменить jar игры
|
||||||
|
или библиотеки.
|
||||||
|
2. Удаляет файлы из `removeRelative`. Опасные пути и пути в общих папках молча пропускаются. После
|
||||||
|
этого удаляются папки, которые стали пустыми.
|
||||||
|
3. Распаковывает пак, перезаписывая существующие файлы.
|
||||||
|
4. Испускает `packEntries(список файлов пака)`, потом `finished(true)`.
|
||||||
|
|
||||||
|
## Отмена
|
||||||
|
|
||||||
|
`requestCancel()` и `clearCancel()` выставляют атомарный флаг, его можно трогать из любого потока.
|
||||||
|
Флаг проверяется перед каждым файлом в `archive`, `restore` и `applyPack`.
|
||||||
|
|
||||||
|
Файлы распаковываются через `QFile`, а не `QSaveFile`. Если операцию прервать, последний файл
|
||||||
|
может остаться недописанным. Поэтому `BuildSwitcher` и не даёт отменить шаги после архивации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# BuildSwitcher
|
||||||
|
|
||||||
|
`buildswitcher.h`, `buildswitcher.cpp`
|
||||||
|
|
||||||
|
Смена активной сборки и раскатка пака сезонной сборки. Файлы двигает
|
||||||
|
[BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке, а этот класс решает, в каком порядке
|
||||||
|
это делать, и ведёт журнал, по которому можно продолжить после падения.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md).
|
||||||
|
|
||||||
|
## Как устроены сборки на диске
|
||||||
|
|
||||||
|
В `.minecraft` в каждый момент разложена одна сборка, активная. Содержимое остальных лежит в
|
||||||
|
архивах `<root>/builds/<id>/<дата>_<имя>.zip`, по одному на сборку.
|
||||||
|
|
||||||
|
Общие для всех сборок папки и файлы никогда не архивируются и не удаляются (`sharedTopLevel()`):
|
||||||
|
|
||||||
|
- `assets`
|
||||||
|
- `libraries`
|
||||||
|
- `versions`
|
||||||
|
- `launcher_profiles.json`
|
||||||
|
- `runtime`
|
||||||
|
|
||||||
|
Всё остальное (моды, конфиги, миры, скриншоты, `options.txt`) принадлежит сборке.
|
||||||
|
|
||||||
|
В папке сборки могут лежать и другие zip, но файлы с точкой в начале имени не считаются архивом
|
||||||
|
сборки. Это временные архивы (`.tmp-*.zip`) и скачанные паки сезонных сборок (`.pack-r*.zip`).
|
||||||
|
|
||||||
|
## 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)`.
|
||||||
|
|
||||||
|
При ошибке приходит `failed(toId, message, gameDirIntact)`. `gameDirIntact` равен `true`, если
|
||||||
|
ошибка случилась до очистки. Бэкенд по нему выбирает текст: «ничего не изменилось» или «папка
|
||||||
|
игры разобрана».
|
||||||
|
|
||||||
|
`cancel()` работает только на шаге архивации. После него отступать уже некуда.
|
||||||
|
|
||||||
|
## applyPack(buildId, buildName, packZip, removeRelative, note, gameDir)
|
||||||
|
|
||||||
|
Раскатка пака поверх активной сборки. Сначала в `index.json` пишется `state: "applying"` со всем,
|
||||||
|
что нужно для повтора: имя файла пака, имя сборки, список на удаление и `note`. Потом работник
|
||||||
|
удаляет файлы прошлой ревизии и распаковывает пак.
|
||||||
|
|
||||||
|
После успеха пак удаляется, `index.json` возвращается в `idle`, а `lastPackEntries()` и
|
||||||
|
`lastPackNote()` содержат список разложенных файлов и `note`. Бэкенд забирает их в обработчике
|
||||||
|
`finished` и пишет `season.json`.
|
||||||
|
|
||||||
|
`gameDirIntact` при ошибке раскатки равен `true`, только если работник не успел обработать ни одного
|
||||||
|
файла.
|
||||||
|
|
||||||
|
## После падения
|
||||||
|
|
||||||
|
При старте бэкенд вызывает `interruptedSwitchWarning()`. Если в `index.json` осталось
|
||||||
|
`switching` или `applying`, метод возвращает текст для пользователя, а `resumeInterrupted(gameDir)`
|
||||||
|
продолжает операцию:
|
||||||
|
|
||||||
|
- `switching` — архив прежней сборки уже записан целиком, поэтому начинаем с очистки и
|
||||||
|
распаковываем сборку `to`. Начинать с архивации нельзя: заархивировалась бы полуразобранная
|
||||||
|
`.minecraft`;
|
||||||
|
- `applying` — раскатка повторяется с тем же паком и тем же списком на удаление. Если файла пака
|
||||||
|
уже нет, журнал просто сбрасывается в `idle`.
|
||||||
|
|
||||||
|
Если программа упала во время самой архивации, в журнале ещё ничего нет, и продолжать нечего:
|
||||||
|
`.minecraft` цела, а временный архив удалится при следующем старте (`sweepTempArchives()` в
|
||||||
|
конструкторе).
|
||||||
|
|
||||||
|
## Прочее
|
||||||
|
|
||||||
|
- `forgetBuild(id)` — удаляет запись о сборке из `index.json` и папку `builds/<id>` целиком.
|
||||||
|
- `hasArchive(id)` — есть ли у сборки архив.
|
||||||
|
- `isRunning()`, `stage()`, `status()` (текущий файл), `fraction()` — для панели прогресса.
|
||||||
|
Сигнал `progressChanged()` не ограничен по частоте и приходит на каждый обработанный файл.
|
||||||
|
|
||||||
|
### index.json
|
||||||
|
|
||||||
|
`<root>/builds/index.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"state": "idle",
|
||||||
|
"active": 3,
|
||||||
|
"builds": {
|
||||||
|
"2": { "archive": "2026-10-09_Vanilla.zip", "name": "Vanilla", "savedAt": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Во время работы к нему добавляются `from`, `to`, а для раскатки ещё `pack`, `packName`,
|
||||||
|
`packRemove`, `packNote`. Пишется через `QSaveFile`.
|
||||||
|
|
||||||
|
Деструктор просит работника остановиться и ждёт поток до 5 секунд.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# GameLauncher
|
||||||
|
|
||||||
|
`gamelauncher.h`, `gamelauncher.cpp`
|
||||||
|
|
||||||
|
Собирает командную строку, распаковывает нативные библиотеки и запускает JVM с игрой. К этому
|
||||||
|
моменту версия уже разобрана ([minecraftversion.h](minecraftversion.md)), файлы скачаны, а игрок
|
||||||
|
вошёл в аккаунт. Всё остальное приходит в структуре `LaunchOptions`.
|
||||||
|
|
||||||
|
Экземпляр один, его держит [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` |
|
||||||
|
|
||||||
|
## launch(options, version, &error)
|
||||||
|
|
||||||
|
1. Если игра уже идёт, отказ.
|
||||||
|
2. `missingFiles()`: если чего-то не хватает, отказ со списком.
|
||||||
|
3. Распаковка нативных библиотек в `<gameDir>/versions/<id>/natives`.
|
||||||
|
4. `JavaLocator::select()` с `version.javaMajor` и `options.javaPath`. На Windows вместо
|
||||||
|
`java.exe` запускается `javaw.exe`, чтобы не было консольного окна.
|
||||||
|
5. `QProcess` с рабочим каталогом `gameDir` и объединёнными stdout и stderr.
|
||||||
|
6. Ожидание старта до 15 секунд. Если процесс не стартовал, отказ.
|
||||||
|
|
||||||
|
Если всё прошло, метод возвращает `true` и испускает `gameStarted(commandLine)`. В этой строке
|
||||||
|
значение после `--accessToken` заменено на `<скрыт>`, чтобы токен не попал в журнал. Других
|
||||||
|
мест, где мог бы оказаться токен (например, в `${auth_session}`), замена не касается.
|
||||||
|
|
||||||
|
Перед шагами 2–4 испускается `progress(message)`.
|
||||||
|
|
||||||
|
Вывод игры читается кусками и испускается построчно в `output(line)`. Когда процесс завершится,
|
||||||
|
придёт `gameFinished(exitCode, crashed)`, где `crashed` значит `QProcess::CrashExit`.
|
||||||
|
|
||||||
|
`terminate()` вызывает `kill()`, а не `terminate()`. Игра не успеет ничего сохранить, это то же
|
||||||
|
самое, что снять процесс в диспетчере задач.
|
||||||
|
|
||||||
|
## missingFiles(options, version, limit = 12)
|
||||||
|
|
||||||
|
Проверяет, что на месте папка игры, клиентский jar, все библиотеки, индекс ресурсов и папка
|
||||||
|
`assets/objects`. Возвращает до `limit` путей. Если папки игры нет, в списке будет только она.
|
||||||
|
|
||||||
|
Размеры и хеши не проверяются, только наличие. Функция статическая, бэкенд вызывает её и для
|
||||||
|
подсказки в интерфейсе, и перед запуском.
|
||||||
|
|
||||||
|
## extractNatives(options, version, nativesDir, &error)
|
||||||
|
|
||||||
|
Для каждой библиотеки с `native == true` открывает jar через `QZipReader` (отсюда зависимость от
|
||||||
|
`Qt6::CorePrivate`) и вынимает файлы `.dll`, `.so`, `.so.N`, `.dylib`, `.jnilib`. Пропускаются
|
||||||
|
`META-INF/` и пути из `extract.exclude` описания версии. Все файлы кладутся в одну плоскую папку.
|
||||||
|
Если файл такого же размера уже лежит, его не переписывают.
|
||||||
|
|
||||||
|
## buildArguments(options, version, nativesDir)
|
||||||
|
|
||||||
|
Порядок аргументов:
|
||||||
|
|
||||||
|
1. `-javaagent:<authlib-injector>=ely.by` и `-Dauthlibinjector.side=client`, если инжектор задан;
|
||||||
|
2. `-Xms`, `-Xmx`, потом `extraJvmArgs`;
|
||||||
|
3. JVM-аргументы из описания версии;
|
||||||
|
4. аргумент логирования, если файл конфигурации логов скачан;
|
||||||
|
5. главный класс;
|
||||||
|
6. аргументы игры из описания версии;
|
||||||
|
7. `--width`/`--height`, если размер задан, а описание версии само его не передаёт;
|
||||||
|
8. `--fullscreen`;
|
||||||
|
9. сервер: `--quickPlayMultiplayer host:port` для версий, которые это понимают, иначе
|
||||||
|
`--server host --port port` (порт по умолчанию 25565).
|
||||||
|
|
||||||
|
Подстановки: `auth_player_name`, `auth_uuid`, `auth_access_token`, `auth_session`
|
||||||
|
(`token:<токен>:<uuid>`), `auth_xuid` (пусто → `0`), `clientid` (пусто → `0`), `user_type`,
|
||||||
|
`user_properties` (`{}`), `version_name`, `version_type`, `game_directory`, `assets_root`,
|
||||||
|
`assets_index_name`, `game_assets`, `natives_directory`, `library_directory`, `classpath`,
|
||||||
|
`classpath_separator`, `primary_jar`, `launcher_name`, `launcher_version`, `resolution_width`,
|
||||||
|
`resolution_height`.
|
||||||
|
|
||||||
|
Аргумент, в котором после подстановки остался `${`, выбрасывается целиком. Так отсекаются
|
||||||
|
переменные, о которых лаунчер не знает (например, связанные с демо-режимом).
|
||||||
|
|
||||||
|
У версий со старыми ресурсами (`legacy`, `pre-1.6`) `game_assets` указывает на
|
||||||
|
`assets/virtual/legacy`.
|
||||||
|
|
||||||
|
Classpath — это все не-нативные библиотеки и в конце клиентский jar.
|
||||||
|
|
||||||
|
### Forge 1.17+ и `-DignoreList`
|
||||||
|
|
||||||
|
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, если его там ещё нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# JavaInstaller
|
||||||
|
|
||||||
|
`javainstaller.h`, `javainstaller.cpp`
|
||||||
|
|
||||||
|
Ставит одну сборку Java из каталога [JavaRuntimeService](JavaRuntimeService.md) в
|
||||||
|
`<root>/java/<id>/`. Сборки Temurin приходят архивом, сборки Mojang — деревом отдельных файлов,
|
||||||
|
поэтому путей установки два.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса такие же, как у других
|
||||||
|
установщиков: `label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`, плюс
|
||||||
|
`runtimeId`. Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс),
|
||||||
|
`finished(runtimeId, javaPath)`, `failed(label, message)`, `canceled(label)`.
|
||||||
|
|
||||||
|
## install(entry)
|
||||||
|
|
||||||
|
Если установка уже идёт, вызов молча игнорируется. Иначе:
|
||||||
|
|
||||||
|
1. проверяется папка лаунчера;
|
||||||
|
2. **папка `<root>/java/<id>` удаляется целиком**, если она была, и создаётся заново. Повторная
|
||||||
|
установка — это всегда установка с нуля;
|
||||||
|
3. дальше одна из двух веток.
|
||||||
|
|
||||||
|
### Temurin (`zip`, `tar.gz`)
|
||||||
|
|
||||||
|
1. Архив качается в `<root>/cache/java/<имя файла>` через `QSaveFile`, sha256 считается на ходу и
|
||||||
|
сверяется с каталогом.
|
||||||
|
2. `zip` распаковывается через `QZipReader` по 24 записи за такт таймера, чтобы окно не
|
||||||
|
подвисало. Права из архива переносятся. Записи, которые ведут за пределы папки сборки,
|
||||||
|
останавливают установку.
|
||||||
|
3. `tar.gz` распаковывается системным `tar -xzf`. Он есть на Linux, macOS и Windows 10 и новее.
|
||||||
|
`QZipReader` tar не умеет, а держать ради этого ещё одну библиотеку незачем.
|
||||||
|
4. Архив удаляется.
|
||||||
|
|
||||||
|
Прогресс при загрузке считается в байтах, при распаковке zip — в записях. Для tar прогресса нет.
|
||||||
|
|
||||||
|
### Mojang
|
||||||
|
|
||||||
|
1. Качается `manifest.json` компонента. Его sha1 из каталога сейчас **не проверяется**.
|
||||||
|
2. Из `files` строится дерево: папки создаются сразу, ссылки откладываются, файлы ставятся в
|
||||||
|
очередь.
|
||||||
|
3. До 4 файлов качаются параллельно, sha1 каждого сверяется. При временной ошибке (таймаут,
|
||||||
|
5xx и т. п.) и при несовпадении хеша файл уходит в конец очереди без паузы. После третьей
|
||||||
|
неудачи установка падает. Файлам с `executable: true` ставится бит исполнения.
|
||||||
|
4. Ссылки создаются через `QFile::link`. Если не вышло (на Windows без прав разработчика), файл
|
||||||
|
копируется.
|
||||||
|
|
||||||
|
### Конец
|
||||||
|
|
||||||
|
`JavaRuntimeStore::locateBinary()` находит `bin/java`. На Unix ему ставится бит исполнения.
|
||||||
|
`runtime.json` записывается последним: пока его нет, `JavaRuntimeStore` сборку не видит. Поэтому
|
||||||
|
недокачанная сборка в списке не появится, даже если лаунчер упал на середине.
|
||||||
|
|
||||||
|
При ошибке и при отмене папка сборки и скачанный архив удаляются.
|
||||||
|
|
||||||
|
## cancel()
|
||||||
|
|
||||||
|
Обрывает загрузки и убивает `tar`. `canceled` приходит, когда вернутся все активные запросы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# JavaRuntimeService
|
||||||
|
|
||||||
|
`javaruntimeservice.h`, `javaruntimeservice.cpp`
|
||||||
|
|
||||||
|
Каталог сборок Java, которые лаунчер может скачать. Устроен как
|
||||||
|
[VersionManifestService](VersionManifestService.md), но кэш хранится сутки и лежит в
|
||||||
|
`<root>/cache/java_runtimes.json`.
|
||||||
|
|
||||||
|
Источников два:
|
||||||
|
|
||||||
|
- **Mojang** (в интерфейсе категория «Java») — те же рантаймы, с которыми игру запускает
|
||||||
|
официальный лаунчер. Их немного, зато с игрой они точно совместимы.
|
||||||
|
- **Eclipse Temurin** (JDK и JRE) — свежие сборки всех мажорных версий, в том числе тех, которых у
|
||||||
|
Mojang ещё нет.
|
||||||
|
|
||||||
|
В каталог попадают только сборки под текущую ОС и архитектуру.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md), ставит сборки
|
||||||
|
[JavaInstaller](JavaInstaller.md).
|
||||||
|
|
||||||
|
## Как обновляется каталог
|
||||||
|
|
||||||
|
Запросы идут параллельно. Каталог заменяется целиком, когда ответят все.
|
||||||
|
|
||||||
|
1. `api.adoptium.net/v3/info/available_releases` — какие мажорные версии есть и какие из них LTS.
|
||||||
|
2. Для каждой мажорной версии `assets/feature_releases/<major>/ga` с фильтрами: ОС, архитектура,
|
||||||
|
HotSpot, обычная куча, vendor `eclipse`, три последних релиза. Из каждого берутся бинарники с
|
||||||
|
`image_type` = `jdk` или `jre` в виде `.zip` или `.tar.gz` по `https`.
|
||||||
|
3. `piston-meta.mojang.com/.../java-runtime/.../all.json` — раздел под текущую платформу
|
||||||
|
(`windows-x64`, `mac-os-arm64`, `linux` и т. п.), по одному варианту на компонент. Компонент
|
||||||
|
`minecraft-java-exe` пропускается: это не Java.
|
||||||
|
|
||||||
|
**Нет сборки под aarch64.** У Temurin под ARM нет Java 8 и 16. Если для aarch64 ничего не нашлось,
|
||||||
|
запрос повторяется для `x64`. На Mac с Apple Silicon такая сборка работает через Rosetta. Сейчас
|
||||||
|
повтор делается на любой ОС с aarch64, хотя комментарий в заголовке говорит только про macOS.
|
||||||
|
|
||||||
|
Id строк: `temurin-<jdk|jre>-<версия>-<арх>` и `mojang-<компонент>`, пропущенные через
|
||||||
|
`JavaRuntimeStore::sanitizeId()`.
|
||||||
|
|
||||||
|
Версия Temurin 8 показывается как `8u<security>`, остальные — как `openjdk_version` без `-LTS`.
|
||||||
|
Мажорная версия у Mojang берётся из имени (`8u…` → 8, иначе первое число).
|
||||||
|
|
||||||
|
У сборок Mojang `size` равен 0: в каталоге указан только размер манифеста, а не рантайма.
|
||||||
|
|
||||||
|
Если какие-то запросы упали, а другие ответили, каталог всё равно обновляется, а предупреждения
|
||||||
|
склеиваются через `; `. Если не пришло ничего, но старые данные есть, колбэк получает `ok = true` и
|
||||||
|
предупреждение.
|
||||||
|
|
||||||
|
Кэш помечается платформой (`mojangPlatform()`). Кэш с другой платформы не читается. Это возможно,
|
||||||
|
например, если папку лаунчера перенесли с другой машины.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
- `ensureLoaded(callback, forceRefresh)` — как у манифеста версий.
|
||||||
|
- `entries(kind)` — строки одного вида, отсортированы по мажорной версии, потом по дате выхода,
|
||||||
|
новые сверху.
|
||||||
|
- `find(id)`.
|
||||||
|
- `bestFor(major)` — что скачать, если игре нужна Java `major`, а подходящей нет. Выбирается
|
||||||
|
наименьшая мажорная версия не ниже `major`. При равенстве порядок такой: Temurin JDK, потом
|
||||||
|
Mojang, потом Temurin JRE. JDK идёт первым, потому что установщикам Forge и NeoForge иногда нужны
|
||||||
|
инструменты из полного комплекта. Если и здесь равенство, берётся более свежая.
|
||||||
|
- `isRefreshing()`, `hasData()`.
|
||||||
|
|
||||||
|
Сигналы: `catalogChanged()`, `refreshingChanged()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# LauncherBackend
|
||||||
|
|
||||||
|
`launcherbackend.h`, `launcherbackend.cpp`
|
||||||
|
|
||||||
|
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает
|
||||||
|
методы и слушает сигналы, а к сервисам напрямую не обращается.
|
||||||
|
|
||||||
|
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки,
|
||||||
|
создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время
|
||||||
|
смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера.
|
||||||
|
Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать.
|
||||||
|
|
||||||
|
Экземпляр один, его объявляет [Main.qml](../qml/Main.md). Во вложенные окна он передаётся через
|
||||||
|
`required property var backend`. Тип зарегистрирован через `QML_ELEMENT` в модуле `BiLauncher`;
|
||||||
|
синглтоном он не сделан.
|
||||||
|
|
||||||
|
## Сервисы
|
||||||
|
|
||||||
|
Все создаются в конструкторе с бэкендом в роли родителя.
|
||||||
|
|
||||||
|
| Поле | Класс | Зачем |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `m_auth` | [AuthService](AuthService.md) | Ely.by, офлайн, загрузка authlib-injector |
|
||||||
|
| `m_msa` | [MsaAuthService](MsaAuthService.md) | вход через Microsoft |
|
||||||
|
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM |
|
||||||
|
| `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang |
|
||||||
|
| `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры |
|
||||||
|
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | версии модлоадеров |
|
||||||
|
| `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера |
|
||||||
|
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки, раскатка пака |
|
||||||
|
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог Java |
|
||||||
|
| `m_javaInstaller` | [JavaInstaller](JavaInstaller.md) | установка Java |
|
||||||
|
| `m_seasonalMeta` | [SeasonalBuildService](SeasonalBuildService.md) | каталог сезонных сборок |
|
||||||
|
| `m_packDownloader` | [SeasonalPackDownloader](SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||||
|
|
||||||
|
`VersionInstaller` получает указатель на сервис манифеста, `ModLoaderInstaller` — на сервис версий
|
||||||
|
модлоадеров и на установщик версий. Владельцем этих объектов остаётся бэкенд.
|
||||||
|
|
||||||
|
## Что происходит при старте
|
||||||
|
|
||||||
|
Конструктор делает следующее, порядок важен:
|
||||||
|
|
||||||
|
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. Подписка на сигналы сервисов.
|
||||||
|
|
||||||
|
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
||||||
|
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
||||||
|
моменту QML уже подписан на сигнал.
|
||||||
|
|
||||||
|
### Сборка из папки игры
|
||||||
|
|
||||||
|
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
|
||||||
|
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
|
||||||
|
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
|
||||||
|
пользователя «сборок нет», и импорта тогда нет.
|
||||||
|
|
||||||
|
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
|
||||||
|
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
|
||||||
|
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
|
||||||
|
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
|
||||||
|
|
||||||
|
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
|
||||||
|
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
|
||||||
|
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
|
||||||
|
|
||||||
|
## Файлы на диске
|
||||||
|
|
||||||
|
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
||||||
|
|
||||||
|
- `settings.json` — объект с настройками;
|
||||||
|
- `profiles.json` — массив профилей;
|
||||||
|
- `customBuilds.json` — массив сборок.
|
||||||
|
|
||||||
|
Если файла нет, это первый запуск. Если он не читается как JSON или в нём не тот тип (объект вместо
|
||||||
|
массива и наоборот), файл переименовывается в `<имя>.bak`, а на его месте создаётся пустой.
|
||||||
|
Данные из `.bak` можно потом достать руками.
|
||||||
|
|
||||||
|
Перед каждой записью снова вызывается `ensureRootExists()`: папку могли удалить, пока лаунчер
|
||||||
|
работал, и без этой проверки сохранения пропадали бы без ошибки. Пишется всё через обычный `QFile`,
|
||||||
|
кроме `builds/<id>/season.json`, для которого используется `QSaveFile`.
|
||||||
|
|
||||||
|
Пароли Ely.by и токены лежат в `profiles.json` открытым текстом.
|
||||||
|
|
||||||
|
### Настройки
|
||||||
|
|
||||||
|
| Ключ | По умолчанию | Что это |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `gameDir` | пусто | папка игры; пусто — `LauncherPaths::defaultMinecraftDir()` |
|
||||||
|
| `javaPath` | пусто | путь к java, заданный руками |
|
||||||
|
| `javaRuntime` | пусто | id сборки Java из `<root>/java`; важнее `javaPath` |
|
||||||
|
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx` |
|
||||||
|
| `jvmArgs` | пусто | дополнительные аргументы JVM одной строкой, делятся по пробелам |
|
||||||
|
| `windowWidth`, `windowHeight` | 0, 0 | размер окна игры; 0 — не передавать |
|
||||||
|
| `fullscreen` | `false` | |
|
||||||
|
| `language` | `system` | язык интерфейса, см. [Localization](Localization.md) |
|
||||||
|
| `activeBuildId` | 0 | `id` активной сборки |
|
||||||
|
| `seasonalBaseUrl` | `https://launcher-builds.galeonworx.com` | сервер сезонных сборок |
|
||||||
|
|
||||||
|
`seasonalBaseUrl` в интерфейсе нет. Он нужен, чтобы проверять установку против локального
|
||||||
|
HTTP-сервера. Хвостовые слэши отрезаются.
|
||||||
|
|
||||||
|
Имя и версия лаунчера для `${launcher_name}` и `${launcher_version}` в настройках не хранятся: они
|
||||||
|
берутся из `QCoreApplication`, то есть из `project(... VERSION)` в CMake. Ключи `launcherName` и
|
||||||
|
`launcherVersion`, оставшиеся в старых `settings.json`, при загрузке выбрасываются и пропадают из
|
||||||
|
файла при следующем сохранении.
|
||||||
|
|
||||||
|
`settings()` возвращает всю карту и добавляет к ней вычисленный `resolvedGameDir`.
|
||||||
|
`updateSettings()` вливает присланные ключи, сохраняет файл и испускает `settingsChanged`. Если
|
||||||
|
сменился язык, после записи вызывается `Localization::setLanguage()`: так выбор сохранится, даже если
|
||||||
|
лаунчер упадёт сразу после переключения. Если сменилась папка игры, перестраивается каталог версий.
|
||||||
|
|
||||||
|
## Профили
|
||||||
|
|
||||||
|
Профиль хранит имя, логин, пароль, тип входа (`offline`, `elyby`, `microsoft`; всё прочее читается
|
||||||
|
как `offline`) и полученные при входе данные: токены, UUID, ник с сервера, `xuid`, срок жизни токена.
|
||||||
|
|
||||||
|
- `addProfile(name, login, password, authType)` — добавить.
|
||||||
|
- `updateProfile(index, ...)` — перезаписать. Если у профиля Ely.by поменялись логин или пароль,
|
||||||
|
сохранённый токен сбрасывается. Если поменялся тип входа, сбрасывается всё, что было получено
|
||||||
|
при входе. Переименование профиля Microsoft сессию не трогает.
|
||||||
|
- `profileAt(index)` — `name`, `login`, `password`, `authType`, `resolvedName` и
|
||||||
|
`hasMicrosoftSession` (есть ли refresh-токен).
|
||||||
|
- `removeProfile(index)` — удалить.
|
||||||
|
|
||||||
|
Все испускают `profilesChanged`. Свойство `profileNames` отдаёт имена в порядке `id`.
|
||||||
|
|
||||||
|
## Сборки
|
||||||
|
|
||||||
|
Сборка — это имя, адрес сервера для автоподключения, версия Minecraft и, если выбран, модлоадер с
|
||||||
|
версией. После установки модлоадера в `resolvedVersionId` записывается id профиля в `versions/`,
|
||||||
|
который на самом деле запускается. У сезонных сборок есть ещё `seasonalId` и `seasonalRevision`.
|
||||||
|
|
||||||
|
Какую папку из `versions/` запускать, решает `launchVersionIdOf()`: сначала `resolvedVersionId`,
|
||||||
|
потом `minecraftVersion`, а если и её нет, то имя сборки. Последний вариант остался от старых
|
||||||
|
записей, у которых версия была записана в имени.
|
||||||
|
|
||||||
|
- `addCustomBuild(name, serverUrl, minecraftVersion)` — создать и сразу начать ставить версию, если
|
||||||
|
она указана и её нет на диске. Если версия пустая (так создаёт сборку кнопка «+ Новая сборка»),
|
||||||
|
ничего не ставится.
|
||||||
|
- `updateCustomBuild(index, fields)` — изменить только переданные ключи. Карточка сборки сохраняет
|
||||||
|
правки по одному полю, поэтому установка здесь не запускается. Если сменилась версия игры, а
|
||||||
|
`loaderVersion` в том же вызове не прислали, `loaderVersion` и `resolvedVersionId` сбрасываются;
|
||||||
|
сам `loader` остаётся.
|
||||||
|
- `customBuildAt(index)` — все поля плюс `launchVersionId`.
|
||||||
|
- `customBuildRemovalInfo(index)` — `name`, `hasArchive`, `active`, `lastOne` для текста
|
||||||
|
предупреждения.
|
||||||
|
- `removeCustomBuild(index)` — удалить запись и архив. Пока лаунчер занят или игра запущена,
|
||||||
|
отказывает. Если удалили активную, активной становится первая из оставшихся, и запускается
|
||||||
|
переключение.
|
||||||
|
- `installCustomBuild(index)` — поставить, чего не хватает. Для ванильной сборки это версия игры,
|
||||||
|
для сборки с модлоадером — модлоадер вместе с версией. Вызывает `installLoaderForBuild()`.
|
||||||
|
- `installLoaderForBuild(index)` — то же самое; по завершении записывает `resolvedVersionId`.
|
||||||
|
- `checkInstallation(index)` — список того, чего не хватает для запуска; пустой список значит, что
|
||||||
|
всё на месте.
|
||||||
|
|
||||||
|
### Активная сборка
|
||||||
|
|
||||||
|
`activeBuildIndex` хранится в настройках как `activeBuildId`, а не как индекс. Если этот `id`
|
||||||
|
пропал, активной считается первая сборка.
|
||||||
|
|
||||||
|
Запись в свойство запускает `BuildSwitcher::switchTo()`: содержимое `.minecraft` уезжает в архив
|
||||||
|
прежней сборки, на его место распаковывается архив новой. Смена отклоняется, если уже идёт другая
|
||||||
|
смена, запущена игра или работает любой установщик. `activeBuildId` меняется только в обработчике
|
||||||
|
`BuildSwitcher::finished`, поэтому после неудачи активной остаётся прежняя сборка.
|
||||||
|
|
||||||
|
Пока идёт смена, выставлено `switching`, а ход виден в `switchProgress` (от 0 до 1, или −1, пока
|
||||||
|
объём неизвестен), `switchStage` и `switchStatus`.
|
||||||
|
|
||||||
|
## Версии игры
|
||||||
|
|
||||||
|
- `versionCatalog` — плоский список строк `{id, type, category, label, installed, search}` в порядке
|
||||||
|
манифеста, новые сверху. `category` — `release`, `snapshot`, `old_beta`, `old_alpha` или `other`.
|
||||||
|
В конец добавлены установленные версии, которых нет в манифесте (профили модлоадеров), по
|
||||||
|
алфавиту, с категорией `other`. Разделы и поиск делает
|
||||||
|
[VersionPickerDialog](../qml/VersionPickerDialog.md).
|
||||||
|
- `catalogLoading` — идёт запрос манифеста.
|
||||||
|
- `refreshVersionCatalog(force)` — сбросить кэш строк и попросить манифест. Свежий кэш манифеста
|
||||||
|
отвечает без сети.
|
||||||
|
- `installedVersions`, `isVersionInstalled(id)` — что лежит в `versions/`.
|
||||||
|
- `installVersion(id)` — поставить; если эта версия уже ставится или стоит в очереди, ничего не
|
||||||
|
делает.
|
||||||
|
- `versionRemovalInfo(id)` — `id`, `installed`, `sizeMb`, `dependents` (профили модлоадеров поверх
|
||||||
|
этой версии), `builds` (имена сборок, которые на неё ссылаются).
|
||||||
|
- `removeVersion(id)` — удалить папку версии. Библиотеки и ресурсы общие, их не трогает. Пока
|
||||||
|
лаунчер занят или запущена игра, отказывает. Сборки, ссылающиеся на версию, не меняются:
|
||||||
|
ссылка на неустановленную версию — нормальное состояние.
|
||||||
|
|
||||||
|
Список строк каталога строится лениво и кэшируется: QML читает свойство много раз за кадр, пока
|
||||||
|
открыт список. То же сделано для каталогов Java и сезонных сборок.
|
||||||
|
|
||||||
|
## Модлоадеры
|
||||||
|
|
||||||
|
- `loaderVersions(loaderKey, gameVersion)` — список `{version, label, recommended, stable}` для этой
|
||||||
|
версии игры, новые сверху. Несовместимых строк в нём не бывает. Пустой список означает, что
|
||||||
|
модлоадер эту версию не поддерживает.
|
||||||
|
- `refreshLoaderVersions(loaderKey, gameVersion, force)` — обновить список. По окончании всегда
|
||||||
|
приходит `loaderVersionsChanged(loaderKey, gameVersion)`, даже если ответил кэш. Обработчик должен
|
||||||
|
сверить пару со своей: сигнал может относиться к другому модлоадеру или к прошлой версии игры.
|
||||||
|
- `loaderVersionsLoading(loaderKey, gameVersion)` — помогает отличить «ещё грузится» от «не
|
||||||
|
поддерживается».
|
||||||
|
|
||||||
|
Ключи: `forge`, `fabric`, `neoforge`, `quilt`.
|
||||||
|
|
||||||
|
Установщику модлоадера передаётся та же Java, что пойдёт в игру: сначала скачанная лаунчером
|
||||||
|
сборка, потом `javaPath`.
|
||||||
|
|
||||||
|
## Java
|
||||||
|
|
||||||
|
- `javaCatalog` — строки `{id, kind, major, version, label, detail, coverage, sizeMb, installed, lts,
|
||||||
|
downloadable, search}`. Сначала то, что уже скачано в `<root>/java`, потом каталог Mojang, Temurin
|
||||||
|
JDK и Temurin JRE.
|
||||||
|
- `javaCatalogLoading`, `refreshJavaCatalog(force)` — как у версий.
|
||||||
|
- `installJavaRuntime(id)` — скачать. Пока работает другой установщик или идёт смена сборки,
|
||||||
|
отказывает. По завершении приходит `javaRuntimeInstalled(id)`.
|
||||||
|
- `removeJavaRuntime(id)` — удалить из папки лаунчера. Если это была выбранная в настройках сборка,
|
||||||
|
`javaRuntime` очищается.
|
||||||
|
- `javaRuntimeInfo(id)` — `id`, `kind`, `major`, `version`, `label`, `javaPath`, `installed`.
|
||||||
|
Если сборка выбрана, но не скачана, `installed` будет `false` и `javaPath` пустым. Для пустого
|
||||||
|
`id` возвращается пустая карта. Метод не пересчитывается сам, поэтому окно настроек перечитывает
|
||||||
|
его по `javaRuntimeInstalled`.
|
||||||
|
- `requiredJavaMajor(buildIndex)` — минимальная Java для версии игры сборки, 0 если версия не
|
||||||
|
выбрана.
|
||||||
|
- `detectedJava()` — найденные в системе java строками вида `Java 17 — /путь`, для справки в
|
||||||
|
настройках.
|
||||||
|
|
||||||
|
## Запуск игры
|
||||||
|
|
||||||
|
`launchGame(profileIndex, buildIndex)` сначала проверяет всё, что можно проверить синхронно:
|
||||||
|
|
||||||
|
- не работает ни один установщик, не идёт другой запуск и игра не запущена;
|
||||||
|
- профиль и сборка выбраны, папка игры существует;
|
||||||
|
- версия сборки есть в `versions/`;
|
||||||
|
- описание версии читается (`VersionLoader::load`), и все нужные файлы на месте
|
||||||
|
(`GameLauncher::missingFiles`).
|
||||||
|
|
||||||
|
После этого выставляется `busy`, а сборка запоминается по `id`, а не по индексу: пока идёт вход,
|
||||||
|
её могут удалить в другом окне.
|
||||||
|
|
||||||
|
Дальше зависит от типа профиля:
|
||||||
|
|
||||||
|
- **офлайн** — ник из логина или имени профиля, UUID считается из ника;
|
||||||
|
- **Ely.by** — сначала докачивается `authlib-injector` в `<root>/runtime`, потом вход. Если Ely.by
|
||||||
|
просит второй фактор, `busy` снимается и приходит `twoFactorRequired(profileName)`. Окно должно
|
||||||
|
вызвать `submitTwoFactorCode(code)` или `cancelPendingLaunch()`, иначе запуск так и останется
|
||||||
|
в ожидании;
|
||||||
|
- **Microsoft** — если refresh-токена нет, запуск прерывается с `microsoftReloginRequired(index)`.
|
||||||
|
Если сохранённый токен Minecraft живёт ещё дольше двух минут, сеть не трогается. Иначе сессия
|
||||||
|
продлевается по refresh-токену. Если продлить не вышло, тоже `microsoftReloginRequired`. Если у
|
||||||
|
аккаунта нет лицензии, просто ошибка.
|
||||||
|
|
||||||
|
Полученные при входе ник, UUID и токены сохраняются в профиль. Java выбирается так: скачанная
|
||||||
|
лаунчером сборка из `javaRuntime`, иначе `javaPath`, иначе её ищет `GameLauncher`. Если
|
||||||
|
`javaRuntime` задан, но такой сборки на диске нет, об этом будет сообщение в `launchProgress`.
|
||||||
|
|
||||||
|
Ход виден по сигналам `launchProgress`, `launched(profileName, buildName, serverUrl)` и
|
||||||
|
`launchError`. `stopGame()` завершает процесс. Пока игра работает, `gameRunning` равно `true`;
|
||||||
|
вывод процесса приходит построчно в `gameOutput`, конец — в `gameFinished(exitCode, crashed)`.
|
||||||
|
|
||||||
|
## Вход через Microsoft из окна профиля
|
||||||
|
|
||||||
|
1. `startMicrosoftLogin(profileIndex)` испускает `microsoftLoginUrlReady(url)`. `-1` значит, что
|
||||||
|
профиля ещё нет и его нужно создать по нику из аккаунта. Без WebEngine сразу приходит
|
||||||
|
`microsoftLoginFailed`.
|
||||||
|
2. [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) открывает страницу и на каждую смену
|
||||||
|
адреса вызывает `inspectMicrosoftRedirect(url)`. Ответ: `{matched, code, error}`. Разбор сделан
|
||||||
|
в C++, чтобы правила совпадали с тем, как `MsaAuthService` строит `redirect_uri`.
|
||||||
|
3. Код уходит в `finishMicrosoftLogin(code)`. Итог приходит в `microsoftLoginSucceeded(playerName)`
|
||||||
|
или `microsoftLoginFailed(message)`.
|
||||||
|
|
||||||
|
`cancelMicrosoftLogin()` забывает начатый вход. Если окно закрыли, пока шёл обмен кода, результат
|
||||||
|
будет выброшен.
|
||||||
|
|
||||||
|
`microsoftAvailable` — константа: собран ли лаунчер с Qt WebEngine.
|
||||||
|
|
||||||
|
## Сезонные сборки
|
||||||
|
|
||||||
|
- `seasonalCatalog` — строки каталога с полями сервера (`id`, `name`, `minecraftVersion`, `loader`,
|
||||||
|
`loaderTitle`, `loaderVersion`, `modCount`, `seasonStart`, `seasonEnd` в виде `дд.мм.гггг`,
|
||||||
|
`sizeBytes`, `revision`, `description`, `serverUrl`) и локальным состоянием (`installed`,
|
||||||
|
`installedRevision`, `updateAvailable`, `status`). Сборка считается установленной, если есть
|
||||||
|
запись с таким `seasonalId`. Обновление доступно, если её ревизия меньше серверной.
|
||||||
|
- `seasonalCatalogLoading`, `seasonalCatalogError`.
|
||||||
|
- `refreshSeasonalCatalog(force)` — перед запросом заново читает `seasonalBaseUrl`.
|
||||||
|
- `installSeasonalBuild(id)` — поставить или обновить.
|
||||||
|
- `cancelSeasonalInstall()` — отменить.
|
||||||
|
- `seasonalInstalling` — идёт установка.
|
||||||
|
|
||||||
|
Установка — цепочка шагов. Каждый шаг либо запускает асинхронную работу и ждёт сигнала, либо
|
||||||
|
решает, что делать нечего, и сразу передаёт ход следующему (`seasonAdvance()` со сквозными `case`).
|
||||||
|
|
||||||
|
1. **Запись.** Если сборки с таким `seasonalId` нет, она создаётся. Если есть, в неё записываются
|
||||||
|
новые версия, модлоадер и адрес сервера. Ревизию пока не трогаем: её запишем, только когда файлы
|
||||||
|
лягут на диск, иначе оборванная установка выглядела бы законченной.
|
||||||
|
2. **Switching.** Сборка становится активной: пак раскатывается поверх текущей `.minecraft`.
|
||||||
|
3. **Java.** Нужная версия берётся из `javaMajor` каталога или из таблицы требований. Если
|
||||||
|
подходящая java уже есть (своя, системная или скачанная раньше), шаг пропускается. Иначе
|
||||||
|
загружается каталог Java и ставится лучшая сборка для этой версии. Java ставится раньше
|
||||||
|
модлоадера, потому что она нужна установщику Forge.
|
||||||
|
4. **Loader.** Для ванильной сборки ставится версия игры, для остальных — модлоадер. Если всё уже
|
||||||
|
установлено и укомплектовано, шаг пропускается.
|
||||||
|
5. **Downloading.** Архив качается в `builds/<id>/.pack-r<ревизия>.zip`. Точка в начале имени нужна,
|
||||||
|
чтобы `BuildSwitcher` не принял файл за архив сборки и не стёр его.
|
||||||
|
6. **Applying.** `BuildSwitcher::applyPack()` удаляет всё, что принесла прошлая ревизия (список из
|
||||||
|
`season.json`), и распаковывает новую. Миры и скриншоты в этот список не попадают, потому что
|
||||||
|
пак их не приносил.
|
||||||
|
|
||||||
|
После раскатки `recordSeasonalPack()` пишет `builds/<id>/season.json` (`seasonalId`, `revision`,
|
||||||
|
`sha256`, `installedAt`, `entries`) и проставляет ревизию в запись сборки. Это делается и тогда,
|
||||||
|
когда раскатка была доиграна после перезапуска лаунчера. Конец цепочки — `seasonalInstallFinished
|
||||||
|
(seasonalId, buildName)`.
|
||||||
|
|
||||||
|
При ошибке на любом шаге скачанный архив удаляется, и приходит `launchError`. На шагах Switching и
|
||||||
|
Applying отменить нельзя: `.minecraft` уже разобрана, операцию нужно довести до конца.
|
||||||
|
|
||||||
|
Строка каталога копируется в `m_season.entry` при старте. Если каталог обновится во время установки,
|
||||||
|
ставиться будет всё равно то, что выбрали.
|
||||||
|
|
||||||
|
## Панель загрузки
|
||||||
|
|
||||||
|
Источников загрузки четыре: установщик версий, установщик модлоадера, установщик Java и загрузчик
|
||||||
|
пака. Наружу они видны через одну группу свойств: `downloading`, `downloadProgress`,
|
||||||
|
`downloadVersion` (что качается), `downloadStatus` (`этап — файл`), `downloadBytesDone`,
|
||||||
|
`downloadBytesTotal`. Все обновляются одним сигналом `downloadChanged`.
|
||||||
|
|
||||||
|
Чьи числа показывать, решается так. Пак качается, только когда остальные уже закончили. Java не
|
||||||
|
качается одновременно с версией или модлоадером. Во время установки модлоадера доля берётся у
|
||||||
|
`VersionInstaller`, если он сейчас качает файлы версии, потому что его оценка точнее.
|
||||||
|
|
||||||
|
`cancelDownload()` останавливает первое, что работает, в порядке: модлоадер (он сам остановит
|
||||||
|
вложенную установку версии), версия, Java, пак.
|
||||||
|
|
||||||
|
`busy` равно `true`, пока идёт вход или запуск, работает любой установщик, идёт смена сборки или
|
||||||
|
качается пак. На это время в интерфейсе гаснет кнопка запуска.
|
||||||
|
|
||||||
|
## Папки
|
||||||
|
|
||||||
|
- `openMinecraftFolder()` открывает `<gameDir>/mods` (создаёт, если её нет).
|
||||||
|
- `openGameFolder()` открывает саму папку игры.
|
||||||
|
|
||||||
|
## Потоки
|
||||||
|
|
||||||
|
Весь класс работает в GUI-потоке. В отдельном потоке выполняются только файловые операции
|
||||||
|
`BuildSwitcher`, и этот поток скрыт внутри него.
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import BiLauncher
|
||||||
|
|
||||||
|
Window {
|
||||||
|
visible: true
|
||||||
|
|
||||||
|
LauncherBackend {
|
||||||
|
id: backend
|
||||||
|
onLaunchError: (message) => console.warn(message)
|
||||||
|
onTwoFactorRequired: (profileName) => twoFactorDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: backend.activeBuildName
|
||||||
|
enabled: !backend.busy && !backend.gameRunning
|
||||||
|
onClicked: backend.launchGame(profileBox.currentIndex, backend.activeBuildIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Localization
|
||||||
|
|
||||||
|
`localization.h`, `localization.cpp`
|
||||||
|
|
||||||
|
Все тексты интерфейса на всех языках лежат в одном файле, `i18n/translations.json`, и читаются
|
||||||
|
через этот класс. Стандартные `.ts`/`.qm`, `lupdate`, `lrelease` и Linguist в проекте не
|
||||||
|
используются. Язык переключается без перезапуска.
|
||||||
|
|
||||||
|
## Из QML
|
||||||
|
|
||||||
|
Синглтон `Loc` (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) доступен в любом файле модуля без
|
||||||
|
импорта.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Text { text: Loc.t.settings.title }
|
||||||
|
Text { text: Loc.t.java.progress.downloading.arg(label) }
|
||||||
|
DarkCombo { model: Loc.t.profile.authTypes }
|
||||||
|
```
|
||||||
|
|
||||||
|
`Loc.t` — дерево JS-объектов, собранное из плоских ключей каталога разбиением по точке.
|
||||||
|
|
||||||
|
Почему `t` сделано свойством, а не методом `Loc.t("ключ")`. Вызов `Q_INVOKABLE` не регистрирует
|
||||||
|
зависимость привязки, и текст не обновился бы при смене языка. Чтение `Q_PROPERTY` с `NOTIFY`
|
||||||
|
регистрирует, поэтому `languageChanged` перевычисляет все привязки, где читали `Loc.t`. Дальнейшие
|
||||||
|
`.settings.title` — обычные обращения к полям JS-объекта. Следить за ними не нужно, потому что при
|
||||||
|
смене языка дерево заменяется целиком.
|
||||||
|
|
||||||
|
Тип свойства — `QJSValue`. С `QVariantMap` движок собирал бы новый JS-объект при каждом чтении, а
|
||||||
|
привязок в проекте около полутора сотен. `QJSValue` строится один раз на каждую смену языка.
|
||||||
|
|
||||||
|
Кроме `t` есть `language` (что выбрано: `system`, `ru`, `en`), `resolvedLanguage` (во что это
|
||||||
|
превратилось, всегда `ru` или `en`) и `availableLanguages()`. Последний сейчас не используется:
|
||||||
|
список языков в настройках [Main.qml](../qml/Main.md) задан руками (`stLanguage`).
|
||||||
|
|
||||||
|
## Из C++
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
emit launchError(Loc::text("launch.error.noProfile"));
|
||||||
|
emit launchProgress(Loc::text("java.progress.downloading").arg(label));
|
||||||
|
const QStringList kinds = Loc::list("profile.authTypes");
|
||||||
|
```
|
||||||
|
|
||||||
|
Это свободные функции, потому что их вызывают и из пространств имён без `QObject`
|
||||||
|
([launcherpaths.h](launcherpaths.md), [javalocator.h](javalocator.md),
|
||||||
|
[zlibreference.h](zlibreference.md)).
|
||||||
|
|
||||||
|
Если ключа нет, возвращается сам ключ, а в отладочной сборке ещё пишется `qWarning`. В интерфейсе
|
||||||
|
такую строку легко заметить.
|
||||||
|
|
||||||
|
### Потоки
|
||||||
|
|
||||||
|
`Loc::text()` и `Loc::list()` можно вызывать из любого потока. Каталог заполняется один раз в
|
||||||
|
`load()`, до того как [BuildSwitcher](BuildSwitcher.md) запустит свой поток, и дальше только
|
||||||
|
читается. На ходу меняется лишь индекс текущего языка, а он `QAtomicInt`. В худшем случае строка,
|
||||||
|
собранная в момент переключения, окажется на прежнем языке.
|
||||||
|
|
||||||
|
`setLanguage()` вызывается только из GUI-потока, это проверяет `Q_ASSERT`.
|
||||||
|
|
||||||
|
## Каталог
|
||||||
|
|
||||||
|
Файл попадает в ресурсы через `RESOURCES` в `qt_add_qml_module` и читается из
|
||||||
|
`:/qt/qml/BiLauncher/i18n/translations.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"languages": ["ru", "en"],
|
||||||
|
"displayNames": { "ru": "Русский", "en": "English" },
|
||||||
|
"_readme": ["..."],
|
||||||
|
"_glossary": { "сборка": "build", "...": "..." }
|
||||||
|
},
|
||||||
|
"strings": {
|
||||||
|
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
|
||||||
|
"java.progress.downloading": { "ru": "Загрузка Java «%1»…", "en": "Downloading Java \"%1\"…" },
|
||||||
|
"profile.authTypes": {
|
||||||
|
"ru": ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"],
|
||||||
|
"en": ["Offline (no password)", "Ely.by", "Microsoft (licensed)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключи плоские, языки записаны рядом. Так сразу видно, если перевод забыли, а новый язык
|
||||||
|
добавляется ещё одним полем без правок загрузчика. `_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`, `tooltip`; для сообщений
|
||||||
|
`error`, `progress`, `status`, `warning`.
|
||||||
|
- **Имя** описывает ситуацию, а не формулировку: `gameRunning`, `downloadFailed`,
|
||||||
|
`checksumMismatch`. Тогда при правке перевода ключ не придётся переименовывать.
|
||||||
|
|
||||||
|
Строка, которая нужна в двух и более местах, кладётся в `common.*`.
|
||||||
|
|
||||||
|
`%1`, `%2` пишутся как есть: их одинаково понимают `QString::arg()` и `String.arg()` в QML. Набор
|
||||||
|
`%N` в `ru` и `en` должен совпадать, порядок может быть разным. Множественного числа формат не
|
||||||
|
поддерживает, и пока ни одна строка его не требует.
|
||||||
|
|
||||||
|
## Добавить строку
|
||||||
|
|
||||||
|
1. Запись в `strings` сразу с `ru` и `en`.
|
||||||
|
2. `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
|
||||||
|
3. `python3 tools/check_translations.py`.
|
||||||
|
|
||||||
|
## Добавить язык
|
||||||
|
|
||||||
|
1. Код в `_meta.languages`, название в `_meta.displayNames`.
|
||||||
|
2. Значение для этого кода в каждой записи `strings`.
|
||||||
|
3. Ключ `settings.language.<код>` с названием языка на нём самом, одинаковым во всех колонках.
|
||||||
|
4. Пункт в модели `stLanguage` и код в `stLanguage.codes` в [Main.qml](../qml/Main.md).
|
||||||
|
5. Если нужно, поправить `systemLanguage()`: сейчас при русской системной локали выбирается
|
||||||
|
русский, при любой другой — английский.
|
||||||
|
|
||||||
|
## Где хранится выбранный язык
|
||||||
|
|
||||||
|
Ключ `language` в `settings.json`, по умолчанию `system`. Меняется в окне настроек и применяется
|
||||||
|
по кнопке «Сохранить».
|
||||||
|
|
||||||
|
```
|
||||||
|
main.cpp ──► Localization::load() ──► читает settings.json (один раз)
|
||||||
|
LauncherBackend::updateSettings() ──► Localization::setLanguage()
|
||||||
|
```
|
||||||
|
|
||||||
|
`settings.json` записывает только [LauncherBackend](LauncherBackend.md). Localization его только
|
||||||
|
читает, и только при старте: язык нужен раньше первой привязки QML, а бэкенд создаётся вместе с
|
||||||
|
движком. Обратной зависимости от бэкенда у Localization нет.
|
||||||
|
|
||||||
|
Неизвестный язык в файле (если его правили руками) заменяется на `system` с предупреждением.
|
||||||
|
|
||||||
|
Если каталог не найден или не разбирается, это ошибка сборки, потому что файл вкомпилирован в
|
||||||
|
бинарник. `main()` пишет причину в `qCritical` и возвращает `-1`.
|
||||||
|
|
||||||
|
### Что не переводится при смене языка
|
||||||
|
|
||||||
|
Строки, которые уже собраны и лежат в полях C++, остаются на старом языке:
|
||||||
|
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
|
||||||
|
[BuildSwitcher](BuildSwitcher.md) обновятся только на следующем шаге. Всё это короткоживущие
|
||||||
|
диагностические сообщения.
|
||||||
|
|
||||||
|
Отдельно: `GameLauncher` прячет токен в строке запуска за `<скрыт>`, и эта строка не берётся из
|
||||||
|
каталога.
|
||||||
|
|
||||||
|
## Проверка каталога
|
||||||
|
|
||||||
|
`tools/check_translations.py` ничего не меняет и возвращает ненулевой код при любой ошибке.
|
||||||
|
Проверяет:
|
||||||
|
|
||||||
|
- у всех языков одинаковый набор ключей и типы, пустых значений нет, длины списков равны;
|
||||||
|
- набор `%N` совпадает по языкам;
|
||||||
|
- каждый `Loc::text("…")` и `Loc::list("…")` в C++ есть в каталоге с нужным типом;
|
||||||
|
- каждый `Loc.t.a.b.c` в QML ведёт к существующему ключу (последний сегмент может быть методом
|
||||||
|
JS, например `.arg`);
|
||||||
|
- нигде не осталось `tr(`, `qsTr(`, `QCoreApplication::translate`.
|
||||||
|
|
||||||
|
Ключи, на которые никто не ссылается, выводятся предупреждением.
|
||||||
|
|
||||||
|
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
|
||||||
|
будет поправить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ModLoaderInstaller
|
||||||
|
|
||||||
|
`modloaderinstaller.h`, `modloaderinstaller.cpp`
|
||||||
|
|
||||||
|
Ставит модлоадер в `.minecraft`. Fabric и Quilt ставятся иначе, чем Forge и NeoForge, но снаружи у
|
||||||
|
них одинаковый интерфейс.
|
||||||
|
|
||||||
|
- **Fabric и Quilt** отдают готовое описание версии. Его достаточно положить в
|
||||||
|
`versions/<id>/<id>.json`, а остальное докачает [VersionInstaller](VersionInstaller.md).
|
||||||
|
- **Forge и NeoForge** при установке патчат клиентский jar, поэтому запускается их официальный
|
||||||
|
`installer.jar` без окна. После него проверяется, какой профиль появился в `versions/`.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса устроены так же, как у
|
||||||
|
`VersionInstaller` (`label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`), и
|
||||||
|
панель загрузки читает их одинаково.
|
||||||
|
|
||||||
|
## install(gameDir, loader, gameVersion, loaderVersion, javaPreference)
|
||||||
|
|
||||||
|
Если установка уже идёт или не указана версия, сразу приходит `failed`. Иначе:
|
||||||
|
|
||||||
|
1. Список версий модлоадера через [ModLoaderVersionService](ModLoaderVersionService.md).
|
||||||
|
2. **Базовая версия игры.** Если её нет или не хватает файлов, она ставится через
|
||||||
|
`VersionInstaller`, и ход идёт дальше только после её `finished`. Без целой ванильной версии
|
||||||
|
модлоадер ставить некуда.
|
||||||
|
3. Дальше один из двух путей.
|
||||||
|
|
||||||
|
### Fabric и Quilt
|
||||||
|
|
||||||
|
Описание скачивается из `meta.fabricmc.net/v2/versions/loader/<игра>/<лоадер>/profile/json`
|
||||||
|
(у Quilt `meta.quiltmc.org/v3/...`). Проверяется, что в нём есть безопасный `id` и что
|
||||||
|
`inheritsFrom` совпадает с выбранной версией игры. Файл сохраняется, и `VersionInstaller` получает
|
||||||
|
этот `id`.
|
||||||
|
|
||||||
|
### Forge и NeoForge
|
||||||
|
|
||||||
|
1. Ссылка на установщик берётся из записи `ModLoaderVersionService::find()`. Если записи нет,
|
||||||
|
значит, эта пара версий несовместима. Установщик качается в
|
||||||
|
`<root>/cache/loaders/<ключ>-<версия>-installer.jar`.
|
||||||
|
2. Если в `.minecraft` нет `launcher_profiles.json`, создаётся заглушка. Без неё установщик
|
||||||
|
отказывается работать: он считает, что официальный лаунчер не установлен.
|
||||||
|
3. Java берётся через `JavaLocator::select()` по `javaMajor` базовой версии. `javaPreference` — это
|
||||||
|
та же Java, что выбрана для игры.
|
||||||
|
4. Запоминается список профилей в `versions/`.
|
||||||
|
5. Запуск: `java -jar installer.jar --installClient <gameDir>`. Рабочий каталог — папка загрузок
|
||||||
|
модлоадеров. На Linux в окружение процесса подставляется эталонный zlib (см.
|
||||||
|
[zlibreference.h](zlibreference.md)).
|
||||||
|
6. Вывод установщика построчно уходит в `log(line)`, бэкенд показывает его в журнале. Полностью он
|
||||||
|
пишется в `<root>/cache/loaders/<ключ>-<версия>-install.log`, а последние 12 строк хранятся в
|
||||||
|
памяти для текста ошибки.
|
||||||
|
7. Через 15 минут процесс убивается.
|
||||||
|
|
||||||
|
После успешного завершения новый профиль ищется как разница списков до и после. Угадывать имя по
|
||||||
|
строке нельзя: у Forge в разные годы оно составлялось по-разному. Если новых папок нет (установщик
|
||||||
|
переписал существующую), ищется профиль, который наследует базовую версию и содержит в имени версию
|
||||||
|
модлоадера. Если новых папок несколько, берётся та, где есть версия модлоадера.
|
||||||
|
|
||||||
|
Найденный профиль передаётся `VersionInstaller`, который докачивает библиотеки, не положенные
|
||||||
|
установщиком. Когда он отчитается, приходит `finished(loaderKey, gameVersion, loaderVersion,
|
||||||
|
producedVersionId)`. Бэкенд записывает `producedVersionId` в `resolvedVersionId` сборки.
|
||||||
|
|
||||||
|
### Если установщик упал
|
||||||
|
|
||||||
|
Все папки, которые он успел создать в `versions/`, удаляются. В текст ошибки идут код выхода,
|
||||||
|
хвост журнала и путь к полному журналу.
|
||||||
|
|
||||||
|
Если подменить zlib не удалось, а в журнале есть `Processor failed` или `invalid outputs`, к
|
||||||
|
сообщению добавляется прямая подсказка про zlib-ng. Почти наверняка дело в нём.
|
||||||
|
|
||||||
|
## cancel()
|
||||||
|
|
||||||
|
Обрывает загрузку, убивает процесс установщика и удаляет то, что он создал. Если ход уже передан
|
||||||
|
`VersionInstaller`, отменяется и он, а `canceled` придёт после его отчёта.
|
||||||
|
|
||||||
|
Деструктор убивает процесс установщика, если тот ещё работает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# ModLoaderVersionService
|
||||||
|
|
||||||
|
`modloaderversionservice.h`, `modloaderversionservice.cpp`
|
||||||
|
|
||||||
|
Списки версий Forge, Fabric, NeoForge и Quilt. Устроен так же, как
|
||||||
|
[VersionManifestService](VersionManifestService.md): качает, хранит копию в
|
||||||
|
`<root>/cache/loader_<ключ>.json` и отвечает из неё, пока она моложе 6 часов.
|
||||||
|
|
||||||
|
Совместимость с версией игры отдельно не проверяется: данные с самого начала разложены по версиям
|
||||||
|
игры. Под версию, для которой сборок нет, лежит пустой список, и выбрать несовместимый модлоадер
|
||||||
|
нечем.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). [ModLoaderInstaller](ModLoaderInstaller.md)
|
||||||
|
берёт отсюда ссылку на `installer.jar` через `find()`.
|
||||||
|
|
||||||
|
## Откуда берутся данные
|
||||||
|
|
||||||
|
**Fabric и Quilt** спрашиваются отдельно под каждую версию игры:
|
||||||
|
`meta.fabricmc.net/v2/versions/loader/<игра>` и `meta.quiltmc.org/v3/versions/loader/<игра>`.
|
||||||
|
Признак стабильности берётся из ответа, а если его нет, угадывается по имени.
|
||||||
|
|
||||||
|
**Forge** — один `maven-metadata.xml` на все версии игры. Строка `1.20.1-47.4.0` делится по первому
|
||||||
|
дефису. Параллельно запрашивается `promotions_slim.json`, оттуда берутся отметки recommended. Если
|
||||||
|
он не ответил, список всё равно сохраняется, только без отметок.
|
||||||
|
|
||||||
|
**NeoForge** тоже из `maven-metadata.xml`, но версия игры в номере не записана. Её приходится
|
||||||
|
выводить: `21.1.248` → `1.21.1`, `20.4.80` → `1.20.4`, `21.0.x` → `1.21`. Для четырёхчастных
|
||||||
|
номеров вроде `25.1.0.1` правило другое: `25.1.0.1` → `25.1`. Вторым запросом читается старый
|
||||||
|
репозиторий `net/neoforged/forge` с первыми сборками NeoForge под 1.20.1, которые выходили под
|
||||||
|
именем артефакта `forge` и номерами в стиле Forge.
|
||||||
|
|
||||||
|
Ссылка на установщик собирается по схеме maven:
|
||||||
|
`<репозиторий>/<версия>/<артефакт>-<версия>-installer.jar`.
|
||||||
|
|
||||||
|
Версии внутри каждой версии игры отсортированы от новых к старым. Числа сравниваются как числа,
|
||||||
|
поэтому `47.10` новее `47.9`.
|
||||||
|
|
||||||
|
## ensureLoaded(loader, gameVersion, callback, forceRefresh)
|
||||||
|
|
||||||
|
Логика та же, что у манифеста версий. Отличие в том, что свежесть у Fabric и Quilt считается для
|
||||||
|
каждой версии игры отдельно, а у Forge и NeoForge для модлоадера целиком. По этому же ключу
|
||||||
|
объединяются одновременные запросы.
|
||||||
|
|
||||||
|
- Пустая `gameVersion` — сразу `ok` и пустой список.
|
||||||
|
- При первом обращении к модлоадеру читается кэш, и если он есть, испускается
|
||||||
|
`versionsChanged(key, gameVersion)`.
|
||||||
|
- Если сеть не ответила, но какие-то данные по модлоадеру есть, колбэк получит `ok = true` и
|
||||||
|
`warning`.
|
||||||
|
- Если ответ не разобрался, хороший кэш не затирается.
|
||||||
|
|
||||||
|
`versionsChanged(loaderKey, gameVersion)` испускается после каждого успешного обновления.
|
||||||
|
`refreshingChanged()` — в начале и в конце запроса. `isRefreshing(loader, gameVersion)` отвечает,
|
||||||
|
идёт ли запрос по этому ключу.
|
||||||
|
|
||||||
|
## Формат кэша
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fetchedAt": "...",
|
||||||
|
"games": {
|
||||||
|
"1.20.1": { "fetchedAt": "...", "entries": [ { "loaderVersion": "47.4.0", ... } ] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Верхний `fetchedAt` используется для Forge и NeoForge, `fetchedAt` внутри версии игры — для Fabric и
|
||||||
|
Quilt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# MsaAuthService
|
||||||
|
|
||||||
|
`msaauthservice.h`, `msaauthservice.cpp`
|
||||||
|
|
||||||
|
Вход через аккаунт Microsoft, то есть с лицензионной копией игры. Цепочка такая же, как у
|
||||||
|
официального лаунчера: OAuth2 → Xbox Live → XSTS → Minecraft Services → проверка лицензии →
|
||||||
|
профиль. Результат приходит в `AuthResult` из [AuthService](AuthService.md).
|
||||||
|
|
||||||
|
Окно входа сервис не показывает. Страницу Microsoft открывает
|
||||||
|
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), а сервис даёт адрес этой страницы и умеет
|
||||||
|
узнать адрес возврата. Класс компилируется всегда, но без Qt WebEngine показать страницу нечем, и
|
||||||
|
[LauncherBackend](LauncherBackend.md) сразу отвечает ошибкой.
|
||||||
|
|
||||||
|
## Чьё это приложение
|
||||||
|
|
||||||
|
Сейчас используется `client_id` официального лаунчера Mojang (`00000000402b5328`). Это старое
|
||||||
|
приложение Live Connect, поэтому и адреса от `login.live.com`:
|
||||||
|
|
||||||
|
| Константа | Значение |
|
||||||
|
|-----------|----------|
|
||||||
|
| `kAuthorizeUrl` | `https://login.live.com/oauth20_authorize.srf` |
|
||||||
|
| `kTokenUrl` | `https://login.live.com/oauth20_token.srf` |
|
||||||
|
| `kRedirectUri` | `https://login.live.com/oauth20_desktop.srf` |
|
||||||
|
| `kScope` | `service::user.auth.xboxlive.com::MBI_SSL` |
|
||||||
|
| `kRpsTicketPrefix` | пусто |
|
||||||
|
|
||||||
|
Это временное решение. Чтобы перейти на своё приложение в Azure (с включённым «Allow public client
|
||||||
|
flows»), меняются эти шесть констант. Что именно подставить, написано в комментарии в начале
|
||||||
|
`msaauthservice.cpp`. Главное, что легко пропустить: у нового приложения в `RpsTicket` нужна
|
||||||
|
приставка `d=`.
|
||||||
|
|
||||||
|
`clientId()` отдаёт этот id наружу. Бэкенд подставляет его в `${clientid}`, потому что официальный
|
||||||
|
лаунчер передаёт туда id приложения, а не случайный токен.
|
||||||
|
|
||||||
|
## Окно входа
|
||||||
|
|
||||||
|
`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`.
|
||||||
|
|
||||||
|
## Цепочка
|
||||||
|
|
||||||
|
`loginWithCode(code, callback)` меняет код на токен (`grant_type=authorization_code`).
|
||||||
|
`loginWithRefreshToken(refreshToken, callback)` делает то же по refresh-токену. Дальше путь общий:
|
||||||
|
|
||||||
|
1. **Токен Microsoft.** Форма на `kTokenUrl`. Из ответа берутся `access_token` и `refresh_token`.
|
||||||
|
2. **Xbox Live.** `user.auth.xboxlive.com/user/authenticate`, `RpsTicket` = приставка + токен
|
||||||
|
Microsoft.
|
||||||
|
3. **XSTS.** `xsts.auth.xboxlive.com/xsts/authorize` для `rp://api.minecraftservices.com/`. При 401
|
||||||
|
код `XErr` переводится в понятный текст: нет профиля Xbox, страна не поддерживается, нужна
|
||||||
|
проверка возраста, детский аккаунт без разрешения взрослого. Отсюда же берутся `uhs` и `xid`
|
||||||
|
(он станет `xuid`).
|
||||||
|
4. **Minecraft Services.** `login_with_xbox` с `XBL3.0 x=<uhs>;<xsts>`. В ответе токен Minecraft и
|
||||||
|
`expires_in`, обычно сутки; от него считается `expiresAt`.
|
||||||
|
5. **Лицензия.** `entitlements/mcstore`. Результат только запоминается и сам вход не останавливает.
|
||||||
|
6. **Профиль.** `minecraft/profile`. Ответы 404 и 401 означают, что профиля нет: выставляется
|
||||||
|
`licenseMissing`. Текст ошибки зависит от шага 5. Если игра куплена, значит, игрок ещё не выбрал
|
||||||
|
ник. Если не куплена, значит, лицензии нет.
|
||||||
|
|
||||||
|
`userType` = `msa`, `clientToken` = id приложения.
|
||||||
|
|
||||||
|
Каждый шаг испускает `progress(message)` до отправки запроса. Таймаут на каждый запрос 20 секунд,
|
||||||
|
повторных попыток нет. Сетевая ошибка и отказ сервера дают разные тексты.
|
||||||
|
|
||||||
|
Refresh-токен Microsoft живёт намного дольше суточного токена Minecraft, поэтому при обычном
|
||||||
|
запуске окно входа не нужно: бэкенд вызывает `loginWithRefreshToken()`. Если Microsoft не вернул
|
||||||
|
новый refresh-токен, бэкенд оставляет старый.
|
||||||
|
|
||||||
|
## Колбэки
|
||||||
|
|
||||||
|
Колбэк вызывается один раз в GUI-потоке. Шаги вызываются из колбэков предыдущих. Если удалить
|
||||||
|
сервис посреди цепочки, она оборвётся вместе с менеджером сети, и колбэк не вызовется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# SeasonalBuildService
|
||||||
|
|
||||||
|
`seasonalbuildservice.h`, `seasonalbuildservice.cpp`
|
||||||
|
|
||||||
|
Каталог сезонных сборок, то есть готовых паков, которые команда публикует на своём файловом
|
||||||
|
сервере. Сервис читает `<baseUrl>/index.json`, хранит копию в
|
||||||
|
`<root>/cache/seasonal_builds.json` и отвечает из неё 15 минут.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Он же сводит строки каталога с
|
||||||
|
установленными сборками и ставит выбранную.
|
||||||
|
|
||||||
|
## Адрес сервера
|
||||||
|
|
||||||
|
По умолчанию `https://launcher-builds.galeonworx.com`. Переопределяется ключом `seasonalBaseUrl` в
|
||||||
|
`settings.json`, например чтобы проверить установку на локальном HTTP-сервере. Бэкенд передаёт
|
||||||
|
адрес через `setBaseUrl()` при старте и перед каждым `refreshSeasonalCatalog()`.
|
||||||
|
|
||||||
|
Если адрес сменился, накопленные данные сбрасываются, а кэш с другим `baseUrl` не читается: ссылки
|
||||||
|
в нём ведут на старый сервер. Пустой адрес или адрес без хоста — сразу ошибка.
|
||||||
|
|
||||||
|
## Формат index.json
|
||||||
|
|
||||||
|
Это контракт с теми, кто публикует сборки.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"id": "season-5",
|
||||||
|
"name": "Сезон 5: Пустоши",
|
||||||
|
"revision": 3,
|
||||||
|
"minecraftVersion": "1.20.1",
|
||||||
|
"loader": "forge",
|
||||||
|
"loaderVersion": "47.4.0",
|
||||||
|
"modCount": 142,
|
||||||
|
"seasonStart": "2026-09-01",
|
||||||
|
"seasonEnd": "",
|
||||||
|
"serverUrl": "play.example.com:25565",
|
||||||
|
"javaMajor": 17,
|
||||||
|
"description": "…",
|
||||||
|
"archive": {
|
||||||
|
"url": "https://launcher-builds.galeonworx.com/season-5/r3.zip",
|
||||||
|
"size": 734003200,
|
||||||
|
"sha256": "…64 hex…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | Обязательно | Правила |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `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()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# SeasonalPackDownloader
|
||||||
|
|
||||||
|
`seasonalpackdownloader.h`, `seasonalpackdownloader.cpp`
|
||||||
|
|
||||||
|
Качает один архив сезонной сборки в файл и проверяет sha256. Пак весит сотни мегабайт, поэтому
|
||||||
|
пишется потоком через `QSaveFile`, а не собирается в памяти. Битый архив распаковывать поверх
|
||||||
|
рабочей `.minecraft` нельзя, поэтому файл появляется на диске только после совпадения хеша.
|
||||||
|
|
||||||
|
Распаковывает пак [BuildSwitcher](BuildSwitcher.md). Экземпляр держит
|
||||||
|
[LauncherBackend](LauncherBackend.md), который и вызывает загрузку на шаге Downloading установки
|
||||||
|
сезонной сборки.
|
||||||
|
|
||||||
|
## download(url, targetPath, sha256, expectedSize, label)
|
||||||
|
|
||||||
|
- `targetPath` — `<root>/builds/<id>/.pack-r<ревизия>.zip`. Если файл уже есть, он будет
|
||||||
|
перезаписан: недокачанный пак с прошлой попытки не должен дожить до новой.
|
||||||
|
- `expectedSize` из каталога используется для прогресса, пока сервер не сообщил настоящий размер.
|
||||||
|
- Таймаут — 60 секунд без данных. Повторных попыток нет: если загрузка оборвалась, установку
|
||||||
|
нужно начать заново.
|
||||||
|
|
||||||
|
Если загрузка уже идёт, сразу приходит `failed`.
|
||||||
|
|
||||||
|
Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс), `finished(path)`,
|
||||||
|
`failed(label, message)`, `canceled(label)`. Геттеры прогресса такие же, как у других
|
||||||
|
установщиков.
|
||||||
|
|
||||||
|
При ошибке и несовпадении хеша файл удаляется. `cancel()` обрывает запрос, и `canceled` приходит
|
||||||
|
после его завершения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# VersionInstaller
|
||||||
|
|
||||||
|
`versioninstaller.h`, `versioninstaller.cpp`
|
||||||
|
|
||||||
|
Ставит версию игры в `.minecraft`: описание версии, клиентский jar, библиотеки, индекс ресурсов,
|
||||||
|
сами ресурсы и конфиг логирования. Одновременно ставится одна версия, остальные ждут в очереди.
|
||||||
|
Всё работает в GUI-потоке на асинхронной сети.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md). Ещё им пользуется
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md): перед установкой модлоадера нужно поставить базовую
|
||||||
|
версию, а после установки докачать библиотеки нового профиля.
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
- `install(gameDir, versionId)` — поставить в очередь. Если эта версия уже ставится или ждёт,
|
||||||
|
ничего не происходит. Если id содержит слэши, `.` или `..`, сразу приходит `failed`.
|
||||||
|
- `cancel()` — отменить текущую установку и очистить очередь.
|
||||||
|
- `isRunning()`, `isQueued(id)`, `versionId()`.
|
||||||
|
- Для панели прогресса: `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()`, `fraction()`
|
||||||
|
(от 0 до 1; −1, если общий объём неизвестен).
|
||||||
|
|
||||||
|
Сигналы: `started(id)`, `progressChanged()` (не чаще раза в 100 мс), `finished(id)`,
|
||||||
|
`failed(id, message)`, `canceled(id)`.
|
||||||
|
|
||||||
|
`started` испускается один раз на всю серию установок из очереди. `finished` испускается для
|
||||||
|
каждой версии.
|
||||||
|
|
||||||
|
## Как идёт установка
|
||||||
|
|
||||||
|
1. **Описание версии.** У [VersionManifestService](VersionManifestService.md) берётся ссылка на
|
||||||
|
`<id>.json`, файл скачивается, sha1 сверяется с манифестом, и файл сохраняется в
|
||||||
|
`versions/<id>/`. Если версии в манифесте нет (профиль модлоадера) или сеть недоступна, берётся
|
||||||
|
файл, который уже лежит на диске. Если нет и его, установка падает.
|
||||||
|
2. **Родитель.** Если в описании есть `inheritsFrom`, а родителя на диске нет, текущая версия
|
||||||
|
уступает место: в начало очереди встают сначала родитель, потом она сама. Глубина цепочки — до
|
||||||
|
10 звеньев.
|
||||||
|
3. **Список файлов.** `VersionLoader::load()` сводит цепочку, и из результата составляется список:
|
||||||
|
клиентский jar, все библиотеки, индекс ресурсов и конфиг логов. Ссылки не по `https` и
|
||||||
|
библиотеки без адреса пропускаются с предупреждением.
|
||||||
|
4. **Загрузка.** До 8 файлов одновременно. Каждый пишется потоком в `QSaveFile`, sha1 считается на
|
||||||
|
ходу. При временной ошибке (таймаут, обрыв, HTTP 429 или 5xx) и при несовпадении хеша файл
|
||||||
|
ставится обратно в начало очереди с паузой 400 мс, потом 800 мс. После третьей неудачи
|
||||||
|
установка падает. Другие ошибки сразу её прерывают. Таймаут на файл 30 секунд.
|
||||||
|
5. **Ресурсы.** Как только индекс ресурсов на диске, он разворачивается в задачи на загрузку из
|
||||||
|
`resources.download.minecraft.net/<xx>/<hash>`. До этого момента в общем объёме учитывается
|
||||||
|
`totalSize` из описания версии, а потом он заменяется точной суммой.
|
||||||
|
6. **Старые ресурсы.** У версий до 1.6 (`virtual`, `map_to_resources`, `assets` = `legacy` или
|
||||||
|
`pre-1.6`) игра ищет ресурсы не в `assets/objects`, а в обычной папке с именами файлов. Они
|
||||||
|
копируются в `assets/virtual/legacy/` или в `resources/` по 64 файла за такт таймера, чтобы
|
||||||
|
окно не подвисало.
|
||||||
|
7. **Проверка.** `GameLauncher::missingFiles()`. Если чего-то нет, это ошибка.
|
||||||
|
|
||||||
|
## Уже скачанные файлы
|
||||||
|
|
||||||
|
Существующий файл считается целым, если его размер совпадает с ожидаемым (или размер неизвестен).
|
||||||
|
Хеш не пересчитывается: перехеширование сотен мегабайт ресурсов при каждой установке обходится
|
||||||
|
дороже, чем редкий битый файл. Для будущей кнопки «Проверить файлы» есть флаг `m_verifyExisting`,
|
||||||
|
сейчас он всегда `false`.
|
||||||
|
|
||||||
|
Пропуск файлов идёт пачками по 256 за такт. Иначе установка версии, у которой уже есть все
|
||||||
|
ресурсы, повесила бы окно на перебор десятков тысяч записей.
|
||||||
|
|
||||||
|
## Ошибки и отмена
|
||||||
|
|
||||||
|
При ошибке все активные загрузки обрываются, недописанные файлы откатываются (`QSaveFile`), а
|
||||||
|
**очередь очищается**: остальные версии из неё тоже не поставятся. К тексту ошибки добавляются до
|
||||||
|
трёх накопленных предупреждений.
|
||||||
|
|
||||||
|
Отмена обрывает активные запросы и ждёт, пока все они вернутся. Только после этого приходит
|
||||||
|
`canceled`. Пока отмена не закончилась, `install()` отвечает отказом.
|
||||||
|
|
||||||
|
Деструктор блокирует сигналы и отменяет установку.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# VersionManifestService
|
||||||
|
|
||||||
|
`versionmanifestservice.h`, `versionmanifestservice.cpp`
|
||||||
|
|
||||||
|
Каталог версий Mojang. Скачивает `version_manifest_v2.json`, хранит его копию в папке лаунчера и
|
||||||
|
отвечает из неё, пока копия свежая.
|
||||||
|
|
||||||
|
Экземпляр держит [LauncherBackend](LauncherBackend.md): из каталога он строит список для окна
|
||||||
|
выбора версии. [VersionInstaller](VersionInstaller.md) берёт из каталога ссылку на описание версии
|
||||||
|
через `find()`.
|
||||||
|
|
||||||
|
## RemoteVersionEntry
|
||||||
|
|
||||||
|
| Поле | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| `id` | `1.21.8`, `25w33a`, `b1.7.3` |
|
||||||
|
| `type` | `release`, `snapshot`, `old_beta`, `old_alpha` |
|
||||||
|
| `url` | адрес `<id>.json` |
|
||||||
|
| `sha1` | хеш этого json |
|
||||||
|
| `releaseTime` | |
|
||||||
|
|
||||||
|
Записи без `id` или с адресом не по `https` отбрасываются. Список отсортирован по `releaseTime`,
|
||||||
|
новые сверху. При одинаковом времени сортировка идёт по `id`.
|
||||||
|
|
||||||
|
## ensureLoaded(callback, forceRefresh = false)
|
||||||
|
|
||||||
|
Через этот метод сервис получает данные; других способов нет.
|
||||||
|
|
||||||
|
1. При первом вызове читается кэш `<root>/cache/version_manifest.json`. Если он разобрался,
|
||||||
|
сразу испускается `versionsChanged`, и окно показывает список ещё до ответа сети.
|
||||||
|
2. Если данные моложе 6 часов и `forceRefresh` не задан, колбэк вызывается через
|
||||||
|
`QTimer::singleShot(0)` с `ok = true`. Сеть не трогается.
|
||||||
|
3. Иначе колбэк встаёт в очередь. Если запрос уже идёт, второй не отправляется.
|
||||||
|
|
||||||
|
Ответ сети разбирается, кэш перезаписывается через `QSaveFile`, испускается `versionsChanged`, и
|
||||||
|
все колбэки из очереди получают результат.
|
||||||
|
|
||||||
|
Если сеть не ответила, но старые данные есть, колбэк получит `ok = true` и непустой `warning`:
|
||||||
|
работать можно, просто список, возможно, неполный. Если данных нет совсем, придёт `ok = false`.
|
||||||
|
Бэкенд показывает `warning` пользователю только во втором случае.
|
||||||
|
|
||||||
|
Таймаут запроса 20 секунд.
|
||||||
|
|
||||||
|
## Остальное
|
||||||
|
|
||||||
|
- `versions()` — весь список.
|
||||||
|
- `find(id)` — запись по id (через хеш-таблицу, а не перебором).
|
||||||
|
- `hasData()`, `isRefreshing()`, `fetchedAt()`.
|
||||||
|
- `refreshingChanged` испускается в начале и в конце запроса. Бэкенд превращает его в
|
||||||
|
`catalogLoading`.
|
||||||
|
|
||||||
|
## Формат кэша
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fetchedAt": "2026-10-11T08:00:00Z",
|
||||||
|
"versions": [
|
||||||
|
{ "id": "1.21.8", "type": "release", "url": "https://...", "sha1": "...", "releaseTime": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Хранится только то, что нужно лаунчеру, а не весь манифест 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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# JavaLocator
|
||||||
|
|
||||||
|
`javalocator.h`, `javalocator.cpp`
|
||||||
|
|
||||||
|
Поиск java, уже установленной на машине, и выбор подходящей под версию игры. Скачиванием
|
||||||
|
занимается [JavaInstaller](JavaInstaller.md), а здесь только смотрим, что есть.
|
||||||
|
|
||||||
|
Это пространство имён со свободными функциями. Его используют [GameLauncher](GameLauncher.md) при
|
||||||
|
запуске, [LauncherBackend](LauncherBackend.md) для списка в настройках и для шага Java в установке
|
||||||
|
сезонной сборки.
|
||||||
|
|
||||||
|
## findAll(gameDir)
|
||||||
|
|
||||||
|
Список путей к исполняемому `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`.
|
||||||
|
|
||||||
|
В каждом каталоге установки проверяются `bin`, `Contents/Home/bin` (так устроены JDK на macOS) и
|
||||||
|
`jre/bin`.
|
||||||
|
|
||||||
|
## majorVersion(javaPath)
|
||||||
|
|
||||||
|
Запускает `java -version` и достаёт мажорную версию из строки `version "..."`. Старая схема
|
||||||
|
`1.8.0_x` даёт 8. Если процесс не ответил за 5 секунд или вывод не разобрался, возвращается 0.
|
||||||
|
|
||||||
|
Результат кэшируется в статической таблице на всё время работы лаунчера. Если java по тому же пути
|
||||||
|
обновить, лаунчер узнает об этом только после перезапуска. Кэш не защищён от гонок, поэтому
|
||||||
|
функцию можно вызывать только из одного потока, и в проекте это GUI-поток.
|
||||||
|
|
||||||
|
## select(gameDir, requiredMajor, preferred, &error)
|
||||||
|
|
||||||
|
Выбирает java для запуска.
|
||||||
|
|
||||||
|
- Если `preferred` задан, он идёт первым кандидатом. Это может быть путь к файлу или к домашней
|
||||||
|
папке JDK (тогда берётся `bin/java`). Если по этому пути ничего нет, функция сразу возвращает
|
||||||
|
ошибку и других кандидатов не смотрит: пользователь явно указал путь, и молча подменять его
|
||||||
|
было бы неправильно.
|
||||||
|
- Дальше добавляется всё из `findAll()`.
|
||||||
|
- Возвращается первый кандидат с версией не ниже `requiredMajor`. Если таких нет, в `error`
|
||||||
|
пишется, какая самая новая java нашлась и где.
|
||||||
|
|
||||||
|
Функция возвращает первую подходящую, а не самую новую. Поэтому скачанная лаунчером Java 17
|
||||||
|
выиграет у системной Java 21, если игре нужна 17.
|
||||||
|
|
||||||
|
## windowlessVariant(javaPath)
|
||||||
|
|
||||||
|
На Windows заменяет `java.exe` на `javaw.exe` из той же папки, если он там есть. Тогда при запуске
|
||||||
|
игры не появляется консольное окно. На других системах возвращает путь без изменений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# javaruntime.h
|
||||||
|
|
||||||
|
`javaruntime.h`, `javaruntime.cpp`
|
||||||
|
|
||||||
|
Общие типы для Java: виды сборок, строка каталога, запись об установленной сборке, работа с папкой
|
||||||
|
`<root>/java` и таблица «какая Java нужна какой версии игры».
|
||||||
|
|
||||||
|
## Виды сборок
|
||||||
|
|
||||||
|
| `JavaRuntimeKind` | Ключ | Что это |
|
||||||
|
|-------------------|------|---------|
|
||||||
|
| `Mojang` | `java` | сборки, которые Mojang раздаёт для своего лаунчера |
|
||||||
|
| `Jdk` | `jdk` | Eclipse Temurin JDK |
|
||||||
|
| `Jre` | `jre` | Eclipse Temurin JRE |
|
||||||
|
|
||||||
|
`javaKindKey()`, `javaKindFromKey()`, `javaKindTitle()` переводят между перечислением, ключом и
|
||||||
|
подписью для интерфейса. Ключ хранится в `runtime.json` установленной сборки и используется в QML.
|
||||||
|
|
||||||
|
## JavaRuntimeEntry
|
||||||
|
|
||||||
|
Строка каталога [JavaRuntimeService](JavaRuntimeService.md), то есть то, что можно скачать.
|
||||||
|
|
||||||
|
| Поле | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| `id` | `temurin-jdk-21.0.12.1_1`, `mojang-java-runtime-delta` |
|
||||||
|
| `kind` | |
|
||||||
|
| `major` | 8, 17, 21… |
|
||||||
|
| `version` | `21.0.12.1+1`, `21.0.7` |
|
||||||
|
| `component` | только у Mojang: `java-runtime-delta` и т. п. |
|
||||||
|
| `released` | |
|
||||||
|
| `url` | архив Temurin или `manifest.json` компонента Mojang |
|
||||||
|
| `checksum` | sha256 архива Temurin или sha1 манифеста Mojang |
|
||||||
|
| `size` | размер загрузки, 0 если неизвестен |
|
||||||
|
| `archive` | `zip`, `tar.gz` или `mojang` |
|
||||||
|
| `architecture` | `x64`, `aarch64`; на macOS может быть не родной (см. JavaRuntimeService) |
|
||||||
|
| `lts` | |
|
||||||
|
|
||||||
|
## InstalledJavaRuntime
|
||||||
|
|
||||||
|
Скачанная сборка: `id`, `kind` (ключ), `major`, `version`, `javaPath` (абсолютный путь к
|
||||||
|
`java`/`java.exe`), `size` (сколько заняла на диске).
|
||||||
|
|
||||||
|
## JavaRuntimeStore
|
||||||
|
|
||||||
|
Каждая сборка лежит в `<root>/java/<id>/`, а рядом с ней `runtime.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "...", "kind": "jdk", "major": 21, "version": "21.0.7", "javaPath": "bin/java", "size": 197132288 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`javaPath` хранится относительно папки сборки, так что папку лаунчера можно переносить.
|
||||||
|
|
||||||
|
- `sanitizeId(id)` — всё, кроме букв, цифр, `.`, `-`, `_`, заменяется на `_`. Ведущие точки
|
||||||
|
убираются.
|
||||||
|
- `dirFor(id)` — `<root>/java/<sanitizeId(id)>`.
|
||||||
|
- `find(id)` — читает `runtime.json`. Возвращает пусто, если файла нет, `id` в нём другой или
|
||||||
|
`java` по указанному пути не существует. Поэтому недокачанная сборка без `runtime.json` в
|
||||||
|
списке не появится.
|
||||||
|
- `installed()` — всё, что нашлось, от новых мажорных версий к старым.
|
||||||
|
- `locateBinary(rootDir)` — ищет `bin/java` внутри распакованного дерева и берёт самый короткий
|
||||||
|
путь. Нужно потому, что у архивов разная вложенность: `jdk-21/bin`, `jdk-21/Contents/Home/bin`
|
||||||
|
и т. п.
|
||||||
|
- `writeMeta(runtime, &error)` — пишет `runtime.json` через `QSaveFile`.
|
||||||
|
- `remove(id, &error)` — удаляет папку сборки. Перед этим проверяет, что путь лежит внутри
|
||||||
|
`<root>/java/`.
|
||||||
|
|
||||||
|
## JavaRequirement
|
||||||
|
|
||||||
|
`minimumFor(minecraftVersionId)` — минимальная Java для версии игры:
|
||||||
|
|
||||||
|
| Версия игры | Java |
|
||||||
|
|-------------|------|
|
||||||
|
| до 1.16 включительно, беты, альфы, всё нераспознанное | 8 |
|
||||||
|
| 1.17 | 16 |
|
||||||
|
| 1.18 — 1.20.4 | 17 |
|
||||||
|
| 1.20.5 и новее | 21 |
|
||||||
|
| версии с новой нумерацией (первое число не 1, например `26.1`) | 21 |
|
||||||
|
| снапшоты `21wNNx` | 16 |
|
||||||
|
| снапшоты `22w`, `23w` | 17 |
|
||||||
|
| снапшоты `24w` и новее | 21 |
|
||||||
|
| снапшоты до `21w` | 8 |
|
||||||
|
|
||||||
|
Пустая строка даёт 0. Таблица нужна там, где описания версии ещё нет на диске: в окне выбора Java
|
||||||
|
и при установке сезонной сборки. При запуске игры используется `javaVersion.majorVersion` из
|
||||||
|
описания версии.
|
||||||
|
|
||||||
|
Профили модлоадеров (`fabric-loader-...`) по этой таблице дают 8, поэтому бэкенд передаёт сюда
|
||||||
|
`minecraftVersion` сборки, а не `resolvedVersionId`.
|
||||||
|
|
||||||
|
`coverage(javaMajor)` — подпись для окна выбора: какие версии игры эта Java потянет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# launcherpaths.h
|
||||||
|
|
||||||
|
`launcherpaths.h`, `launcherpaths.cpp`
|
||||||
|
|
||||||
|
Все пути к данным лаунчера. Пространство имён `LauncherPaths` из свободных функций, состояния нет:
|
||||||
|
каждая функция просто собирает строку.
|
||||||
|
|
||||||
|
Данные лаунчера лежат в папке `BiLauncher` рядом со стандартной папкой игры, а не в системном
|
||||||
|
каталоге данных приложения. `QStandardPaths` не используется, поэтому имя приложения и организации
|
||||||
|
из `main.cpp` на эти пути не влияют.
|
||||||
|
|
||||||
|
## Где что лежит
|
||||||
|
|
||||||
|
`containerDir()` — папка, в которой живут и игра, и лаунчер:
|
||||||
|
|
||||||
|
| ОС | `containerDir()` | папка игры по умолчанию | `rootDir()` |
|
||||||
|
|----|------------------|-------------------------|-------------|
|
||||||
|
| Windows | `%USERPROFILE%/AppData/Roaming` | `.minecraft` | `BiLauncher` |
|
||||||
|
| macOS | `~/Library/Application Support` | `minecraft` (без точки, как у официального лаунчера) | `BiLauncher` |
|
||||||
|
| Linux | `~` | `.minecraft` | `BiLauncher` |
|
||||||
|
|
||||||
|
На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая.
|
||||||
|
|
||||||
|
## BILAUNCHER_HOME
|
||||||
|
|
||||||
|
`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он
|
||||||
|
заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая
|
||||||
|
папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt
|
||||||
|
(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит.
|
||||||
|
|
||||||
|
Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль
|
||||||
|
у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего
|
||||||
|
`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на
|
||||||
|
Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp`
|
||||||
|
при старте пишет в лог предупреждение с итоговой папкой лаунчера.
|
||||||
|
|
||||||
|
Внутри `rootDir()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# main.cpp
|
||||||
|
|
||||||
|
Точка входа. Объектов предметной области здесь нет: `LauncherBackend` создаёт сам
|
||||||
|
[Main.qml](../qml/Main.md), а `main()` только готовит приложение и загружает QML.
|
||||||
|
|
||||||
|
## По порядку
|
||||||
|
|
||||||
|
1. **WebEngine.** Если лаунчер собран с Qt WebEngine (`LAUNCHER_HAS_WEBENGINE`),
|
||||||
|
`QtWebEngineQuick::initialize()` вызывается до создания `QGuiApplication`. После создания
|
||||||
|
приложения общий контекст OpenGL уже не выставить.
|
||||||
|
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
||||||
|
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
|
||||||
|
(в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её
|
||||||
|
через `Qt.application.version`), организация `GaleonDev`, домен `galeonworx.com`. На
|
||||||
|
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
|
||||||
|
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
||||||
|
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
||||||
|
5. **`setDesktopFileName("bilauncher")`.** По этому имени окружения рабочего стола в Linux
|
||||||
|
связывают окно с `bilauncher.desktop` и показывают правильную иконку в панели.
|
||||||
|
6. **Тексты.** `Localization::instance().load()` читает `i18n/translations.json` и язык из
|
||||||
|
`settings.json`. Это нужно сделать до создания движка: язык должен быть известен до первой
|
||||||
|
привязки. Если каталог не загрузился, `main()` возвращает `-1` (см.
|
||||||
|
[Localization](Localization.md)).
|
||||||
|
7. **Стиль `Basic`.** Окна оформлены вручную, а нативный стиль Windows игнорирует свои
|
||||||
|
`contentItem` и `background` и засыпает журнал предупреждениями.
|
||||||
|
8. **Движок.** `QQmlApplicationEngine`. Если корневой объект не создался, приложение
|
||||||
|
завершается с `-1`. Соединение отложенное (`Qt::QueuedConnection`), чтобы выход произошёл уже в
|
||||||
|
цикле событий, а не посреди загрузки.
|
||||||
|
9. `engine.loadFromModule("BiLauncher", "Main")` — загрузка по URI модуля и имени типа.
|
||||||
|
10. `app.exec()`.
|
||||||
|
|
||||||
|
Аргументы командной строки не разбираются. Контекстных свойств нет: QML общается с C++ только
|
||||||
|
через зарегистрированные типы `LauncherBackend` и `Loc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# minecraftversion.h
|
||||||
|
|
||||||
|
`minecraftversion.h`, `minecraftversion.cpp`
|
||||||
|
|
||||||
|
Чтение описаний версий из `<gameDir>/versions/<id>/<id>.json` и всё, что с ними связано: правила
|
||||||
|
Mojang, нативные библиотеки под текущую платформу, список установленного, удаление.
|
||||||
|
|
||||||
|
Профиль модлоадера обычно содержит только отличия от базовой версии и ссылается на неё через
|
||||||
|
`inheritsFrom`. `VersionLoader::load()` проходит эту цепочку и сводит её в одну структуру
|
||||||
|
`MinecraftVersion`, с которой уже работают [GameLauncher](GameLauncher.md) и
|
||||||
|
[VersionInstaller](VersionInstaller.md).
|
||||||
|
|
||||||
|
## MinecraftLibrary
|
||||||
|
|
||||||
|
Одна библиотека, к которой уже применены правила.
|
||||||
|
|
||||||
|
| Поле | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| `name` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
|
||||||
|
| `path` | путь относительно `libraries/` |
|
||||||
|
| `url` | откуда качать; может быть пустым |
|
||||||
|
| `sha1`, `size` | для проверки; могут отсутствовать |
|
||||||
|
| `native` | распаковывается в `natives`, в classpath не идёт |
|
||||||
|
| `extractExclude` | префиксы путей внутри jar, которые не распаковываются |
|
||||||
|
|
||||||
|
## MinecraftVersion
|
||||||
|
|
||||||
|
| Поле | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| `id` | имя папки в `versions/` |
|
||||||
|
| `mainClass` | |
|
||||||
|
| `type` | `release`, `snapshot` и т. д.; по умолчанию `release` |
|
||||||
|
| `assetIndexId` | id индекса ресурсов |
|
||||||
|
| `assetsKind` | поле `assets`: `legacy`, `pre-1.6` или тот же id |
|
||||||
|
| `clientJarPath` | абсолютный путь к клиентскому jar; часто это jar родителя |
|
||||||
|
| `javaMajor` | из `javaVersion.majorVersion`; по умолчанию 8 |
|
||||||
|
| `jvmArgs`, `gameArgs` | с ещё не подставленными `${...}` |
|
||||||
|
| `libraries` | |
|
||||||
|
| `loggingArgument`, `loggingConfigPath` | аргумент log4j и путь к его xml в `assets/log_configs` |
|
||||||
|
| `supportsQuickPlay` | среди аргументов игры есть `quickPlayMultiplayer` (1.20+) |
|
||||||
|
| `hasCustomResolutionArgs` | описание само передаёт `resolution_width` |
|
||||||
|
|
||||||
|
`isValid()` возвращает `true`, если заданы `id` и `mainClass`.
|
||||||
|
|
||||||
|
## Как сводится цепочка
|
||||||
|
|
||||||
|
`load(gameDir, versionId, features, &error)` идёт от запрошенной версии к родителям по
|
||||||
|
`inheritsFrom`. Ограничения: не больше 10 звеньев, без циклов. Если файл отсутствует или не
|
||||||
|
разбирается, это ошибка, и возвращается невалидный объект.
|
||||||
|
|
||||||
|
Дальше поля применяются от корня к потомку:
|
||||||
|
|
||||||
|
- `mainClass`, `type`, `assets`, `assetIndex`, `javaVersion`, `logging` — потомок перекрывает
|
||||||
|
родителя;
|
||||||
|
- `minecraftArguments` (старый формат, одна строка) **заменяет** `gameArgs` целиком;
|
||||||
|
- `arguments.jvm` и `arguments.game` (новый формат) **дописываются** к тому, что дал родитель.
|
||||||
|
|
||||||
|
Библиотеки собираются в обратном порядке, от потомка к корню. Повторы убираются по ключу
|
||||||
|
`группа:артефакт[:классификатор]`, без версии. Поэтому если Forge приносит свою версию библиотеки,
|
||||||
|
она вытесняет ванильную.
|
||||||
|
|
||||||
|
Если среди JVM-аргументов нет `-cp`, значит это старая версия без блока `arguments.jvm`. Тогда
|
||||||
|
добавляются аргументы, которые официальный лаунчер подставляет сам: `-Djava.library.path`, бренд и
|
||||||
|
версия лаунчера, `-cp ${classpath}`.
|
||||||
|
|
||||||
|
Клиентский jar берётся первый найденный по цепочке, от потомка к корню. Если его нет нигде,
|
||||||
|
указывается путь у корня. `GameLauncher::missingFiles()` потом скажет, что его не хватает.
|
||||||
|
|
||||||
|
`features` нужен для правил вида `"features": {"has_custom_resolution": true}`. Бэкенд передаёт
|
||||||
|
`has_custom_resolution`, если в настройках задан размер окна.
|
||||||
|
|
||||||
|
## Правила и натив
|
||||||
|
|
||||||
|
`rulesAllow(rules, features)` реализует алгоритм Mojang: правила проверяются по порядку,
|
||||||
|
действует последнее совпавшее; пустой список означает «разрешено». Правило совпадает, если подошли
|
||||||
|
`features`, `os.name`, `os.arch` и регулярное выражение `os.version` (сверяется с
|
||||||
|
`QSysInfo::productVersion()`).
|
||||||
|
|
||||||
|
Нативные библиотеки встречаются в двух форматах:
|
||||||
|
|
||||||
|
- старый: блок `natives` с классификатором под ОС, `${arch}` заменяется на `32` или `64`;
|
||||||
|
- новый (LWJGL 3.3+): отдельные библиотеки с классификатором `natives-<ос>[-<арх>]`.
|
||||||
|
|
||||||
|
`pickNativesForCurrentArch()` оставляет для каждого артефакта один классификатор. Лучше всего
|
||||||
|
точный (`nativeClassifier()`, например `natives-macos-arm64`), потом общий для ОС
|
||||||
|
(`natives-macos`), потом какой попадётся.
|
||||||
|
|
||||||
|
`osName()` возвращает `windows`, `osx` или `linux`. `osArch()` возвращает архитектуру из
|
||||||
|
`QSysInfo`, причём `i386` и `i686` сводятся к `x86`.
|
||||||
|
|
||||||
|
## Установленные версии
|
||||||
|
|
||||||
|
- `installedVersions(gameDir)` — подпапки `versions/`, в которых есть `<id>.json`, по алфавиту.
|
||||||
|
- `dependentsOf(gameDir, id)` — установленные профили с `inheritsFrom == id`. Без базовой версии они
|
||||||
|
не запустятся, поэтому их перечисляют в предупреждении об удалении.
|
||||||
|
- `installedSize(gameDir, id)` — размер папки в байтах.
|
||||||
|
- `remove(gameDir, id, &error)` — удаляет `versions/<id>`. Сначала проверяет, что id не пустой,
|
||||||
|
не начинается с точки и не содержит слэшей, а итоговый путь лежит внутри `versions/`. Если папки
|
||||||
|
нет, это не ошибка.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# modloader.h
|
||||||
|
|
||||||
|
Перечисление модлоадеров, запись одной версии модлоадера и перевод между перечислением и
|
||||||
|
строковым ключом. Только заголовок, `.cpp` нет.
|
||||||
|
|
||||||
|
## ModLoader
|
||||||
|
|
||||||
|
`Forge`, `Fabric`, `NeoForge`, `Quilt`.
|
||||||
|
|
||||||
|
Модлоадеры взаимоисключающие: игра запускается с одним профилем из `versions/`, поэтому у сборки
|
||||||
|
может быть не больше одного модлоадера. [LoaderRow](../qml/LoaderRow.md) снимает отметку с
|
||||||
|
остальных, когда пользователь отмечает один.
|
||||||
|
|
||||||
|
## Ключи и названия
|
||||||
|
|
||||||
|
| Значение | `loaderKey()` | `loaderTitle()` |
|
||||||
|
|----------|---------------|-----------------|
|
||||||
|
| `Forge` | `forge` | Minecraft Forge |
|
||||||
|
| `Fabric` | `fabric` | Fabric Loader |
|
||||||
|
| `NeoForge` | `neoforge` | NeoForge |
|
||||||
|
| `Quilt` | `quilt` | Quilt Loader |
|
||||||
|
|
||||||
|
Ключ используется в QML, в имени файла кэша (`cache/loader_<ключ>.json`) и в поле `loader` записи
|
||||||
|
сборки в `customBuilds.json`. Если его поменять, старые сборки и кэши перестанут читаться.
|
||||||
|
|
||||||
|
`loaderFromKey(key)` возвращает `std::nullopt` для любой другой строки, в том числе пустой. В
|
||||||
|
бэкенде это значит «ванильная сборка».
|
||||||
|
|
||||||
|
Названия не переводятся: это имена собственные.
|
||||||
|
|
||||||
|
## LoaderVersionEntry
|
||||||
|
|
||||||
|
| Поле | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| `loaderVersion` | `47.4.0`, `0.19.3`, `21.1.248` |
|
||||||
|
| `gameVersion` | `1.20.1` |
|
||||||
|
| `versionId` | ожидаемое имя профиля в `versions/`. У Fabric и Quilt оно известно заранее (`fabric-loader-<версия>-<игра>`), у Forge и NeoForge пусто: имя становится известно только после работы установщика |
|
||||||
|
| `installerUrl` | ссылка на `installer.jar`, только у Forge и NeoForge |
|
||||||
|
| `recommended` | у Forge помечено как recommended в `promotions_slim.json` |
|
||||||
|
| `stable` | не альфа, не бета, не rc/pre |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# zlibreference.h
|
||||||
|
|
||||||
|
`zlibreference.h`, `zlibreference.cpp`
|
||||||
|
|
||||||
|
Обход одной конкретной проблемы на Linux: Forge и NeoForge не ставятся на системах, где вместо
|
||||||
|
zlib стоит zlib-ng.
|
||||||
|
|
||||||
|
## В чём проблема
|
||||||
|
|
||||||
|
Установщики Forge и NeoForge собирают часть jar прямо на машине пользователя и сверяют sha1
|
||||||
|
каждого собранного файла с эталоном из `install_profile.json`. Эталон посчитан на обычном zlib.
|
||||||
|
CachyOS, Fedora и некоторые другие дистрибутивы ставят вместо него zlib-ng в режиме совместимости.
|
||||||
|
Он сжимает корректно, но результат побайтово другой. Поэтому установка падает на любой версии игры
|
||||||
|
с сообщением `Processor failed, invalid outputs`.
|
||||||
|
|
||||||
|
Другая Java не помогает: сборки OpenJDK под Linux берут `libz.so.1` из системы.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
В репозитории лежат исходники zlib 1.3.1 (каталог `zlib/`). На Linux CMake собирает из них цель
|
||||||
|
`launcher_zlib_reference` (`libz.so.1`). В дереве сборки она лежит в `<папка бинарника>/zlib/`, при
|
||||||
|
установке — в `<prefix>/lib/BiLauncher/`. Сам лаунчер с ней не линкуется. Библиотека
|
||||||
|
подкладывается через `LD_PRELOAD` только процессу установщика, системный zlib-ng не трогается.
|
||||||
|
|
||||||
|
Исходники лежат в репозитории, а не скачиваются при сборке, по двум причинам: сборка не зависит от
|
||||||
|
сети, и версия зафиксирована. От версии зависит побайтовый результат сжатия.
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
На Windows и macOS все три функции ничего не делают: `systemIsZlibNg()` возвращает `false`,
|
||||||
|
`bundledPath()` пустую строку, `applyTo()` — `true`.
|
||||||
|
|
||||||
|
### systemIsZlibNg()
|
||||||
|
|
||||||
|
Открывает `libz.so.1` через `dlopen`, то есть ту же библиотеку, которую загрузчик выдаст процессу
|
||||||
|
java, и смотрит, есть ли `zlib-ng` в строке `zlibVersion()`. Результат вычисляется один раз за
|
||||||
|
запуск.
|
||||||
|
|
||||||
|
### bundledPath()
|
||||||
|
|
||||||
|
Ищет эталонную библиотеку по порядку:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Второй вариант рассчитан на `CMAKE_INSTALL_LIBDIR=lib`. Поэтому CI на Ubuntu передаёт его явно,
|
||||||
|
иначе библиотека легла бы в `lib/x86_64-linux-gnu`.
|
||||||
|
|
||||||
|
### applyTo(env, ¬e)
|
||||||
|
|
||||||
|
- zlib-ng в системе нет — подменять нечего, возвращает `true`, `note` пуст.
|
||||||
|
- zlib-ng есть, эталон найден — дописывает его путь в начало `LD_PRELOAD` (чужое содержимое
|
||||||
|
переменной сохраняется), возвращает `true`, в `note` пишет, что подставлено.
|
||||||
|
- zlib-ng есть, эталона нет — возвращает `false`, в `note` пишет предупреждение.
|
||||||
|
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()`
|
||||||
|
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая
|
||||||
|
подсказка про zlib-ng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# BiLauncher: устройство проекта
|
||||||
|
|
||||||
|
Лаунчер Minecraft на Qt 6. Интерфейс на QML. Всё остальное, то есть вход в аккаунт, загрузка
|
||||||
|
версий, установка модлоадеров и Java, смена сборок и запуск игры, написано на 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](qml/Main.md) — единственное окно приложения. Остальные окна — `Dialog` внутри
|
||||||
|
него. Main создаёт один `LauncherBackend` и передаёт его диалогам через `required property var
|
||||||
|
backend`. К сервисам QML не обращается.
|
||||||
|
2. **[LauncherBackend](cpp/LauncherBackend.md)** хранит профили, сборки и настройки, владеет
|
||||||
|
двенадцатью сервисами и следит, чтобы их операции не пересекались. Каталоги он отдаёт в QML
|
||||||
|
уже сведёнными с тем, что лежит на диске, поэтому окна сами ничего не считают.
|
||||||
|
3. **Сервисы.** Каждый занят одним делом и про интерфейс не знает.
|
||||||
|
4. **Внешний мир.** Серверы Mojang, Ely.by, Microsoft/Xbox, Adoptium, Fabric/Quilt/Forge/NeoForge и
|
||||||
|
свой сервер сезонных сборок. Файлы: `.minecraft` и папка лаунчера. Процессы: java с игрой,
|
||||||
|
установщики Forge/NeoForge, `tar`.
|
||||||
|
|
||||||
|
Отдельно стоит [Localization](cpp/Localization.md), синглтон `Loc`. Через него идут все тексты
|
||||||
|
интерфейса: `Loc.t.домен.вид.имя` в QML и `Loc::text("домен.вид.имя")` в C++. Он ни от чего не
|
||||||
|
зависит, и обращаться к нему можно из любого слоя.
|
||||||
|
|
||||||
|
Почти всё работает в GUI-потоке. Параллельность даёт асинхронная сеть, а не потоки. Исключение —
|
||||||
|
файловые операции над `.minecraft` (это могут быть гигабайты модов и миров). Они вынесены в
|
||||||
|
отдельный поток в [BuildArchiveWorker](cpp/BuildArchiveWorker.md).
|
||||||
|
|
||||||
|
## Основные понятия
|
||||||
|
|
||||||
|
**Профиль** — игрок и способ входа: офлайн, Ely.by или Microsoft.
|
||||||
|
|
||||||
|
**Сборка** — версия игры, модлоадер (или без него), адрес сервера и собственное содержимое
|
||||||
|
`.minecraft`: моды, конфиги, миры. Активная сборка одна, её файлы разложены в `.minecraft`, у
|
||||||
|
остальных файлы лежат в архивах в папке лаунчера. Папки `versions`, `libraries`, `assets`,
|
||||||
|
`runtime` общие для всех сборок. См. [BuildSwitcher](cpp/BuildSwitcher.md).
|
||||||
|
|
||||||
|
**Сезонная сборка** — готовая сборка, которую публикует команда на своём сервере. Ставится одной
|
||||||
|
кнопкой вместе с версией, модлоадером и Java, обновляется по номеру ревизии. Формат каталога
|
||||||
|
описан в [SeasonalBuildService](cpp/SeasonalBuildService.md).
|
||||||
|
|
||||||
|
**Папка лаунчера** — `BiLauncher` рядом с `.minecraft`. Что в ней лежит, расписано в
|
||||||
|
[launcherpaths.h](cpp/launcherpaths.md).
|
||||||
|
|
||||||
|
## QML
|
||||||
|
|
||||||
|
| Файл | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| [Main](qml/Main.md) | главное окно: экран запуска, профили, настройки, плашка сообщений, панели прогресса |
|
||||||
|
| [BuildsDialog](qml/BuildsDialog.md) | пользовательские сборки: список и карточка |
|
||||||
|
| [VersionPickerDialog](qml/VersionPickerDialog.md) | выбор версии Minecraft, удаление скачанных |
|
||||||
|
| [JavaPickerDialog](qml/JavaPickerDialog.md) | выбор и загрузка Java |
|
||||||
|
| [SeasonalBuildsDialog](qml/SeasonalBuildsDialog.md) | каталог сезонных сборок |
|
||||||
|
| [MicrosoftLoginDialog](qml/MicrosoftLoginDialog.md) | вход Microsoft во встроенном браузере; только с WebEngine |
|
||||||
|
| [LoaderRow](qml/LoaderRow.md) | строка модлоадера в карточке сборки |
|
||||||
|
| [ProgressPanel](qml/ProgressPanel.md) | плашка прогресса |
|
||||||
|
| [DarkCombo](qml/DarkCombo.md) | выпадающий список в стиле окна |
|
||||||
|
| [LabelledField](qml/LabelledField.md) | подпись и поле ввода |
|
||||||
|
|
||||||
|
## C++
|
||||||
|
|
||||||
|
### Вход и фасад
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [main.cpp](cpp/main.md) | запуск приложения |
|
||||||
|
| [LauncherBackend](cpp/LauncherBackend.md) | всё, что видит QML |
|
||||||
|
| [Localization](cpp/Localization.md) | тексты интерфейса, `i18n/translations.json` |
|
||||||
|
|
||||||
|
### Вход в аккаунт и запуск
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [AuthService](cpp/AuthService.md) | Ely.by, офлайн, authlib-injector; структура `AuthResult` |
|
||||||
|
| [MsaAuthService](cpp/MsaAuthService.md) | Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
|
||||||
|
| [GameLauncher](cpp/GameLauncher.md) | командная строка, нативные библиотеки, процесс игры |
|
||||||
|
|
||||||
|
### Версии игры
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [VersionManifestService](cpp/VersionManifestService.md) | каталог версий Mojang |
|
||||||
|
| [VersionInstaller](cpp/VersionInstaller.md) | установка версии: jar, библиотеки, ресурсы |
|
||||||
|
| [minecraftversion.h](cpp/minecraftversion.md) | чтение описаний версий, цепочка `inheritsFrom`, правила Mojang |
|
||||||
|
|
||||||
|
### Модлоадеры
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [modloader.h](cpp/modloader.md) | перечисление модлоадеров и ключи |
|
||||||
|
| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | версии Forge, Fabric, NeoForge, Quilt |
|
||||||
|
| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | установка: готовый профиль или `installer.jar` |
|
||||||
|
| [zlibreference.h](cpp/zlibreference.md) | обход zlib-ng для установщиков Forge/NeoForge на Linux |
|
||||||
|
|
||||||
|
### Java
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [javaruntime.h](cpp/javaruntime.md) | типы, папка `<root>/java`, какая Java нужна какой версии игры |
|
||||||
|
| [JavaRuntimeService](cpp/JavaRuntimeService.md) | каталог Mojang и Temurin |
|
||||||
|
| [JavaInstaller](cpp/JavaInstaller.md) | установка: архив Temurin или дерево файлов Mojang |
|
||||||
|
| [javalocator.h](cpp/javalocator.md) | поиск java в системе |
|
||||||
|
|
||||||
|
### Сборки
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
|
||||||
|
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
|
||||||
|
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
|
||||||
|
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
|
||||||
|
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||||
|
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
|
||||||
|
|
||||||
|
## Прочее в репозитории
|
||||||
|
|
||||||
|
| Путь | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| `i18n/translations.json` | все тексты интерфейса |
|
||||||
|
| `tools/check_translations.py` | проверка каталога переводов |
|
||||||
|
| `tests/` | автотесты, см. [testing.md](testing.md) |
|
||||||
|
| `icons/` | иконки и скрипт, который их генерирует |
|
||||||
|
| `images/` | картинки интерфейса |
|
||||||
|
| `zlib/` | исходники zlib 1.3.1 (чужой код) |
|
||||||
|
| `packaging/` | установщик Windows, лицензии сторонних компонентов |
|
||||||
|
| `ci/` | скрипты подготовки раннеров |
|
||||||
|
| `.gitea/workflows/release.yml` | сборка релизов |
|
||||||
|
| `.gitea/workflows/tests.yml` | автотесты на тех же раннерах |
|
||||||
|
| `Screenshots/` | картинки для README |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# BuildsDialog.qml
|
||||||
|
|
||||||
|
Окно пользовательских сборок. Слева список, справа карточка выбранной сборки: имя, сервер, версия
|
||||||
|
Minecraft, модлоадер. Отсюда сборку ставят, делают активной и удаляют.
|
||||||
|
|
||||||
|
Сборка — это версия игры, модлоадер и адрес сервера плюс собственное содержимое `.minecraft`
|
||||||
|
(моды, конфиги, миры). Активная сборка одна, её файлы разложены в `.minecraft`, остальные лежат в
|
||||||
|
архивах. Подробнее в [BuildSwitcher](../cpp/BuildSwitcher.md).
|
||||||
|
|
||||||
|
Открывается из [Main](Main.md) по кнопке активной сборки.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Что это |
|
||||||
|
|----------|---------|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `editIndex` | сборка, открытая в карточке; −1 — ничего не открыто |
|
||||||
|
| `loading` | карточка сейчас заполняется данными сборки, обработчики полей ничего не записывают |
|
||||||
|
|
||||||
|
`editIndex` не связан с активной сборкой. Щелчок по строке списка только открывает карточку, а
|
||||||
|
активной сборку делает отдельная кнопка. Иначе случайный щелчок запускал бы архивацию
|
||||||
|
`.minecraft`.
|
||||||
|
|
||||||
|
## Как устроено окно
|
||||||
|
|
||||||
|
Модальный `Dialog` 880×600. Пока идёт смена сборки (`backend.switching`), окно закрывается только
|
||||||
|
кнопкой, а список и карточка выключены.
|
||||||
|
|
||||||
|
**Слева** (260 px) список сборок. У активной пометка «активна», при наведении появляется корзина.
|
||||||
|
Ниже строка «+ Новая сборка»: создаёт сборку с первым свободным именем вида «Сборка N» и сразу
|
||||||
|
открывает её.
|
||||||
|
|
||||||
|
**Справа** карточка в `Flickable`, потому что может не поместиться по высоте:
|
||||||
|
|
||||||
|
- название и адрес сервера ([LabelledField](LabelledField.md));
|
||||||
|
- версия Minecraft. Это не выпадающий список, а поле, которое открывает
|
||||||
|
[VersionPickerDialog](VersionPickerDialog.md): версий около тысячи, и выбирать их удобнее в окне
|
||||||
|
с категориями и поиском;
|
||||||
|
- четыре строки [LoaderRow](LoaderRow.md): Forge, Fabric, NeoForge, Quilt;
|
||||||
|
- строка комплектности: «можно запускать» или «не хватает N файлов» и первый из них
|
||||||
|
(`checkInstallation()`).
|
||||||
|
|
||||||
|
**Внизу** кнопки «Установить» (`installCustomBuild()`), «Сделать активной» (запись в
|
||||||
|
`activeBuildIndex`) и «Закрыть». Первые две выключены, пока `busy`. «Сделать активной» выключена
|
||||||
|
ещё и для уже активной сборки. Прогресс установки и смены сборки показывают панели главного окна.
|
||||||
|
|
||||||
|
## Сохранение по ходу
|
||||||
|
|
||||||
|
Отдельной кнопки «Сохранить» нет. Каждое поле записывается сразу после правки через
|
||||||
|
`commit(fields)` → `updateCustomBuild()`. Иначе пришлось бы как-то показывать несохранённые правки
|
||||||
|
при переключении между сборками в списке.
|
||||||
|
|
||||||
|
Чтобы открытие сборки не записывало её саму в себя, на время `selectBuild()` выставляется
|
||||||
|
`loading`, и `commit()` в это время ничего не делает.
|
||||||
|
|
||||||
|
## Модлоадеры
|
||||||
|
|
||||||
|
`loaderPanel` держит четыре строки и следит, чтобы был отмечен не больше чем один модлоадер: игра
|
||||||
|
запускается с одним профилем из `versions/`. Ни одной отметки — ванильная сборка.
|
||||||
|
|
||||||
|
- `applyBuild(loader, loaderVersion)` — раздать данные сборки строкам;
|
||||||
|
- `selectedRow()` — отмеченная строка или `null`;
|
||||||
|
- `keepOnly(row)` — снять отметки с остальных (на `userChecked` строки);
|
||||||
|
- `commitSelection()` — записать `loader`, `loaderVersion` и пустой `resolvedVersionId` (на
|
||||||
|
`changed` строки). Профиль модлоадера появится только после установки, а до этого сборка
|
||||||
|
запускается как ванильная.
|
||||||
|
|
||||||
|
Строки получают `gameVersion` привязкой к выбранной версии, поэтому смена версии игры сама
|
||||||
|
перезапрашивает их списки.
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
`askRemove(index)` берёт `customBuildRemovalInfo()` и открывает подтверждение. Текст зависит от
|
||||||
|
трёх признаков:
|
||||||
|
|
||||||
|
- `hasArchive` — вместе со сборкой удалится её архив, моды, конфиги и миры будет не вернуть;
|
||||||
|
- `active` — содержимое `.minecraft` принадлежит этой сборке, оно будет очищено, и на его место
|
||||||
|
развернётся следующая;
|
||||||
|
- `lastOne` — для последней сборки содержимое `.minecraft` остаётся как есть.
|
||||||
|
|
||||||
|
Индекс запоминается в самом окне подтверждения, потому что к моменту ответа под курсором может
|
||||||
|
оказаться другая строка.
|
||||||
|
|
||||||
|
`performRemove(index)` удаляет сборку и открывает соседнюю (ту же позицию или последнюю), а если
|
||||||
|
сборок не осталось, очищает карточку.
|
||||||
|
|
||||||
|
## Когда окно открывается
|
||||||
|
|
||||||
|
`onAboutToShow` просит обновить каталог версий и открывает активную сборку (или первую). Через
|
||||||
|
`Connections` окно слушает `installedVersionsChanged` и обновляет строку комплектности: установка
|
||||||
|
могла закончиться, пока окно открыто.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# DarkCombo.qml
|
||||||
|
|
||||||
|
`ComboBox` в тёмном оформлении окна: фон `#2a2a2a`, зелёная рамка при фокусе, выпадающая панель
|
||||||
|
высотой до 220 px с полосой прокрутки.
|
||||||
|
|
||||||
|
Стрелка взята та же, что у списков профиля и версии на главном окне
|
||||||
|
(`images/Profile_Box/Asset_23/24.svg`), вместо двойного шеврона стиля `Basic`.
|
||||||
|
|
||||||
|
Новых свойств нет, используется как обычный `ComboBox`. Есть одно ограничение: делегат выводит
|
||||||
|
`modelData` как текст, поэтому модель должна быть списком строк. `textRole` не поддерживается.
|
||||||
|
|
||||||
|
Используется в [LoaderRow](LoaderRow.md) и в диалогах [Main](Main.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# JavaPickerDialog.qml
|
||||||
|
|
||||||
|
Окно выбора сборки Java. Устроено как [VersionPickerDialog](VersionPickerDialog.md): слева виды,
|
||||||
|
справа список с поиском. Отличие в том, что выбранная здесь сборка сразу и скачивается. Версии игры
|
||||||
|
качает сборка, а Java, которой нет на диске, игре ничем не поможет.
|
||||||
|
|
||||||
|
Открывается из диалога настроек в [Main](Main.md).
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `selectedId` | выбранная сборка |
|
||||||
|
| `category` | `java` (Mojang), `jdk`, `jre` (Temurin) |
|
||||||
|
| `filterText`, `installedOnly` | поиск и «только скачанные» |
|
||||||
|
| `requiredMajor` | минимальная Java для версии игры активной сборки; 0 — версия не выбрана |
|
||||||
|
| `openFor(runtimeId, requiredMajor)` | открыть |
|
||||||
|
| `runtimeChosen(runtimeId)` | выбор подтверждён |
|
||||||
|
|
||||||
|
## Как работает
|
||||||
|
|
||||||
|
`openFor()` сбрасывает поиск, открывает категорию переданной сборки и просит бэкенд обновить
|
||||||
|
каталог Java. По «Отмене» ничего не меняется.
|
||||||
|
|
||||||
|
Под категориями слева написано, какая Java нужна выбранной версии игры, чтобы не держать это в
|
||||||
|
голове.
|
||||||
|
|
||||||
|
`javaCatalog` приходит из C++ уже отсортированным: скачанное первым, потом новые версии сверху.
|
||||||
|
Окно только отбирает строки.
|
||||||
|
|
||||||
|
В строке: версия, пометка LTS, какие версии игры она потянет (`coverage`), размер и галочка у
|
||||||
|
скачанных. Сборка ниже `requiredMajor` не прячется, а помечается красноватым и подписью «слишком
|
||||||
|
старая для этой версии». Она может понадобиться другой сборке.
|
||||||
|
|
||||||
|
Кнопка внизу называется «Скачать», если выбранной сборки нет на диске, и «Выбрать», если есть.
|
||||||
|
`acceptSelection()` испускает `runtimeChosen` и, если сборки нет на диске, вызывает
|
||||||
|
`installJavaRuntime()`. Ход загрузки показывает панель главного окна.
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
У скачанных сборок есть корзина, и удаление происходит **без подтверждения**:
|
||||||
|
`removeJavaRuntime()` вызывается сразу. Сборка весит около двухсот мегабайт, и без корзины здесь
|
||||||
|
папка лаунчера росла бы незаметно для пользователя. Если удалили сборку, выбранную в настройках,
|
||||||
|
бэкенд очистит `javaRuntime`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# LabelledField.qml
|
||||||
|
|
||||||
|
Подпись и под ней поле ввода. Используется в настройках и в карточке сборки.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `label` | подпись |
|
||||||
|
| `placeholder` | текст-подсказка в пустом поле |
|
||||||
|
| `text` | алиас текста поля |
|
||||||
|
| `validator` | алиас валидатора поля |
|
||||||
|
| `editingFinished()` | правка закончена: поле потеряло фокус или нажат Enter |
|
||||||
|
|
||||||
|
Ширина поля равна ширине компонента, высота 32 px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# LoaderRow.qml
|
||||||
|
|
||||||
|
Одна строка модлоадера в карточке [BuildsDialog](BuildsDialog.md): чекбокс, а если он отмечен,
|
||||||
|
выпадающий список версий под выбранную версию Minecraft.
|
||||||
|
|
||||||
|
Совместимость здесь не проверяется. `backend.loaderVersions()` отдаёт список уже под конкретную
|
||||||
|
версию игры, и несовместимых строк в нём нет. Если список пустой, значит, модлоадер эту версию не
|
||||||
|
поддерживает, и вместо пустого выпадающего списка показывается текст.
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `loaderKey` (required) | `forge`, `fabric`, `neoforge`, `quilt` |
|
||||||
|
| `title` (required) | подпись у чекбокса |
|
||||||
|
| `gameVersion` | версия Minecraft из карточки; её смена сбрасывает выбор |
|
||||||
|
| `checked` | алиас чекбокса |
|
||||||
|
| `selectedVersion` | выбранная версия модлоадера |
|
||||||
|
| `entries` | строки `{version, label, recommended, stable}` |
|
||||||
|
| `applying` | строка заполняется из сборки; изменения в это время не считаются пользовательскими |
|
||||||
|
| `userChecked()` | пользователь отметил чекбокс. Карточка снимает отметки с остальных строк |
|
||||||
|
| `changed()` | отметка или версия изменились, карточке пора сохранить сборку |
|
||||||
|
| `applyBuild(loader, loaderVersion)` | заполнить из сохранённой сборки без `changed()` |
|
||||||
|
|
||||||
|
Пока версия игры не выбрана, чекбокс выключен.
|
||||||
|
|
||||||
|
## applyEntries(list)
|
||||||
|
|
||||||
|
Единственное место, где меняются `entries` и `selectedVersion`. Через него проходят все три пути:
|
||||||
|
ответ из кэша, ответ сети и заполнение из сборки. Если выбранной версии в новом списке нет, она
|
||||||
|
заменяется первой строкой, а это самая новая версия для этой версии игры. Если при этом значение
|
||||||
|
изменилось и строка не в режиме `applying`, испускается `changed()`.
|
||||||
|
|
||||||
|
Раньше проверку делал только путь из сети, и версия модлоадера, выбранная под прошлую версию игры,
|
||||||
|
доживала до сохранения сборки.
|
||||||
|
|
||||||
|
## Откуда берётся список
|
||||||
|
|
||||||
|
`reload()` сначала берёт то, что уже есть в кэше (`loaderVersions()`), чтобы список появился сразу,
|
||||||
|
а потом просит обновление (`refreshLoaderVersions()`). Ответ приходит в
|
||||||
|
`backend.loaderVersionsChanged(key, game)`. Строка сравнивает пару со своей и игнорирует чужие
|
||||||
|
ответы: от другого модлоадера или от прошлой версии игры.
|
||||||
|
|
||||||
|
Под пустым списком написано «загрузка версий…», пока идёт запрос
|
||||||
|
(`loaderVersionsLoading()`), и «не поддерживает версию N» после.
|
||||||
|
|
||||||
|
`applyBuild()` берёт версию из сборки как пожелание. Если под текущую версию игры её нет,
|
||||||
|
`applyEntries()` заменит её на самую новую.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Main.qml
|
||||||
|
|
||||||
|
Главное окно, оно же точка входа: `main.cpp` загружает его через
|
||||||
|
`engine.loadFromModule("BiLauncher", "Main")`. Корень — `Window` 1280×720.
|
||||||
|
|
||||||
|
Что в нём есть:
|
||||||
|
|
||||||
|
- единственный экземпляр `LauncherBackend` и все обработчики его сигналов;
|
||||||
|
- экран запуска: фон, кнопка игры по центру, список профилей, кнопка активной сборки, кнопки папки
|
||||||
|
модов, настроек и сезонных сборок;
|
||||||
|
- плашка сообщений и две панели прогресса ([ProgressPanel](ProgressPanel.md));
|
||||||
|
- диалоги, которые не вынесены в отдельные файлы: новый профиль, редактирование профиля, код
|
||||||
|
двухфакторной аутентификации, настройки запуска.
|
||||||
|
|
||||||
|
Отдельными файлами сделаны [BuildsDialog](BuildsDialog.md),
|
||||||
|
[SeasonalBuildsDialog](SeasonalBuildsDialog.md), [JavaPickerDialog](JavaPickerDialog.md) и
|
||||||
|
[MicrosoftLoginDialog](MicrosoftLoginDialog.md). Окно передаёт им бэкенд через свойство `backend`.
|
||||||
|
|
||||||
|
Стиль Qt Quick Controls — `Basic` (выставлен в `main.cpp`), и почти у каждого контрола здесь своё
|
||||||
|
оформление.
|
||||||
|
|
||||||
|
## Раскладка
|
||||||
|
|
||||||
|
Всё стоит на якорях вокруг кнопки запуска: список профилей слева сверху от неё, кнопка активной
|
||||||
|
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
|
||||||
|
правом нижнем углу, потому что остальные углы заняты.
|
||||||
|
|
||||||
|
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
|
||||||
|
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
|
||||||
|
(см. [main.cpp](../cpp/main.md)).
|
||||||
|
|
||||||
|
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
|
||||||
|
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
|
||||||
|
наоборот. Панель смены сборки отменить нельзя.
|
||||||
|
|
||||||
|
У заголовков и подвалов всех диалогов корнем служит `Item` с явным `implicitHeight`. У
|
||||||
|
`Rectangle` `implicitHeight` всегда 0, и `Dialog` не смог бы посчитать свою высоту.
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
**`showToast(text, color, timeout)`** — показать сообщение в плашке. `timeout` в миллисекундах,
|
||||||
|
по умолчанию 4000. `0` значит «держать до следующего сообщения». Так показываются шаги запуска,
|
||||||
|
чтобы сообщение не исчезало посреди долгой операции.
|
||||||
|
|
||||||
|
**`openMicrosoftLogin(url)`** — открыть окно входа Microsoft. Окно создаётся при первом вызове
|
||||||
|
через `Qt.createComponent()` и хранится в `microsoftLoginDialog`. Объявить его прямо в файле
|
||||||
|
нельзя: `MicrosoftLoginDialog.qml` есть в модуле только в сборках с WebEngine, и в остальных
|
||||||
|
сломалось бы всё главное окно. Если компонент не создался, вход отменяется у бэкенда и показывается
|
||||||
|
сообщение.
|
||||||
|
|
||||||
|
**`formatMb(bytes)`** — байты в мегабайты с одним знаком после запятой, для панели загрузки.
|
||||||
|
|
||||||
|
## Сигналы бэкенда
|
||||||
|
|
||||||
|
| Сигнал | Что делает окно |
|
||||||
|
|--------|-----------------|
|
||||||
|
| `launched` | зелёное сообщение «запущено» |
|
||||||
|
| `launchProgress` | сообщение без таймаута |
|
||||||
|
| `launchError` | красное сообщение на 8 секунд |
|
||||||
|
| `twoFactorRequired` | открывает диалог кода |
|
||||||
|
| `gameFinished` | сообщение о закрытии игры; если код не 0 или процесс упал, красное и с кодом |
|
||||||
|
| `microsoftLoginUrlReady` | `openMicrosoftLogin()` |
|
||||||
|
| `microsoftLoginSucceeded` | сообщение. Выбор в списке профилей не трогает: новый профиль уже выбран тем, кто его создал, а повторный вход мог быть не для последнего профиля |
|
||||||
|
| `microsoftLoginFailed` | красное сообщение на 8 секунд |
|
||||||
|
| `microsoftReloginRequired` | сразу `startMicrosoftLogin()` для этого профиля |
|
||||||
|
| `gameOutput` | `console.log` |
|
||||||
|
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
|
||||||
|
| `javaRuntimeInstalled` | обновляет строку Java в настройках |
|
||||||
|
|
||||||
|
Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`;
|
||||||
|
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
|
||||||
|
числе сезонной сборки) доступна только отмена на панели загрузки.
|
||||||
|
|
||||||
|
## Профили
|
||||||
|
|
||||||
|
Выпадающий список профилей переделан целиком. Наверху всплывающей панели закреплена кнопка
|
||||||
|
«+ Добавить профиль», под ней список. У каждой строки при наведении появляются карандаш (открывает
|
||||||
|
редактирование через `openFor()`) и корзина (`removeProfile()`).
|
||||||
|
|
||||||
|
Типы входа во всех списках идут в одном порядке: 0 — `offline`, 1 — `elyby`, 2 — `microsoft`.
|
||||||
|
Модель берётся из `Loc.t.profile.authTypes`, и порядок в каталоге важен, потому что код сравнивает
|
||||||
|
`currentIndex` с 1 и 2. Без WebEngine третий пункт отрезается через `.slice(0, 2)`. В окне
|
||||||
|
редактирования он остаётся, если профиль уже лицензионный: иначе в сборке без WebEngine такой
|
||||||
|
профиль молча стал бы офлайновым.
|
||||||
|
|
||||||
|
**Новый профиль.** `addProfile()`, новый профиль выбирается в списке. Если тип «Microsoft», сразу
|
||||||
|
начинается вход, потому что без него такой профиль бесполезен. Скрытые поля при сохранении не
|
||||||
|
читаются: в них мог остаться текст, набранный до смены типа.
|
||||||
|
|
||||||
|
**Редактирование.** Дополнительные свойства диалога: `editIndex`, `msProfile`, `msLinked` (есть ли
|
||||||
|
сессия Microsoft), `msName` (ник из аккаунта). Ник показывается отдельным полем только для чтения,
|
||||||
|
а не подставляется в поле логина. Иначе привязка оборвалась бы при первом же вводе в это поле у
|
||||||
|
обычного профиля. Кнопка входа сначала сохраняет профиль с типом `microsoft` через
|
||||||
|
`updateProfile()` и только потом вызывает `startMicrosoftLogin()`. Тип могли переключить только
|
||||||
|
что, и без этого токены достались бы профилю другого типа.
|
||||||
|
|
||||||
|
## Двухфакторная аутентификация
|
||||||
|
|
||||||
|
Диалог показывает имя профиля (`profileName`) и поле для кода. «Подтвердить» вызывает
|
||||||
|
`submitTwoFactorCode()`, «Отмена» — `cancelPendingLaunch()`. Если отмену не вызвать, запуск так и
|
||||||
|
повиснет.
|
||||||
|
|
||||||
|
## Настройки
|
||||||
|
|
||||||
|
`settingsDialog.load()` читает `settings()` и `detectedJava()`, раскладывает значения по полям и
|
||||||
|
открывает диалог. «Сохранить» собирает всё в одну карту и отдаёт в `updateSettings()`.
|
||||||
|
|
||||||
|
- **Язык** стоит первым, потому что относится ко всему приложению, а не к запуску игры. В модели
|
||||||
|
переведённые подписи, рядом лежит непереводимый список `codes` (`system`, `ru`, `en`). Язык
|
||||||
|
применяется по «Сохранить» и сразу, без перезапуска.
|
||||||
|
- **Папка игры**, а под полем — путь, который получится на самом деле (`resolvedGameDir`). Путь
|
||||||
|
хранится в свойстве, а не записывается в текст напрямую, иначе подпись не обновилась бы при смене
|
||||||
|
языка.
|
||||||
|
- **Java.** Поле открывает [JavaPickerDialog](JavaPickerDialog.md) с текущим выбором и требованием
|
||||||
|
активной сборки (`requiredJavaMajor()`). Крестик сбрасывает выбор, и тогда Java ищется в системе.
|
||||||
|
Выбор хранится в `javaRuntimeId` и записывается только по «Сохранить». `javaRuntimeInfo`
|
||||||
|
обновляется при смене `javaRuntimeId` и по `javaRuntimeInstalled`, потому что
|
||||||
|
`javaRuntimeInfo()` — обычный вызов, а не привязка. Если сборка выбрана, запуск идёт ею, а путь
|
||||||
|
из соседнего поля остаётся запасным вариантом.
|
||||||
|
- Память, размер окна, аргументы JVM, полноэкранный режим.
|
||||||
|
- Ниже для справки список найденных в системе java.
|
||||||
|
|
||||||
|
Окно выбора Java объявлено рядом с диалогом настроек, а не внутри него. Оно шире настроек и
|
||||||
|
центрируется по окну лаунчера.
|
||||||
|
|
||||||
|
## Кнопки
|
||||||
|
|
||||||
|
- папка — `openMinecraftFolder()` (открывает `mods`);
|
||||||
|
- настройки — `settingsDialog.load()`;
|
||||||
|
- сезонные сборки — `seasonalDialog.openCatalog()`;
|
||||||
|
- активная сборка — `buildsDialog.open()`.
|
||||||
|
|
||||||
|
Все тексты окна берутся из `Loc.t`, литералов в разметке нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# MicrosoftLoginDialog.qml
|
||||||
|
|
||||||
|
Окно входа в аккаунт Microsoft. Работает так же, как официальный лаунчер: страница входа
|
||||||
|
открывается внутри приложения в `WebEngineView`, а лаунчер ждёт, когда браузер перейдёт на
|
||||||
|
`redirect_uri` с кодом в адресе.
|
||||||
|
|
||||||
|
Файл попадает в QML-модуль, только если CMake нашёл `Qt6::WebEngineQuick`. Поэтому
|
||||||
|
[Main](Main.md) не объявляет окно в разметке, а создаёт его через `Qt.createComponent()` при первом
|
||||||
|
входе (`openMicrosoftLogin()`).
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `openAt(url)` | открыть страницу входа |
|
||||||
|
| `failed(message)` | вход не удался. Окно ничего не знает о плашке сообщений главного окна и сообщает о неудаче сигналом |
|
||||||
|
|
||||||
|
## Как работает
|
||||||
|
|
||||||
|
На каждое `urlChanged` вызывается `handleUrl()`. Он передаёт адрес в
|
||||||
|
`backend.inspectMicrosoftRedirect()`. Разбор сделан в C++, чтобы правила совпадали с тем, как
|
||||||
|
[MsaAuthService](../cpp/MsaAuthService.md) строит `redirect_uri`.
|
||||||
|
|
||||||
|
Когда адрес совпал:
|
||||||
|
|
||||||
|
- окно закрывается, `codeTaken` выставляется в `true`;
|
||||||
|
- если есть код, вызывается `backend.finishMicrosoftLogin(code)`;
|
||||||
|
- если кода нет, вызывается `backend.cancelMicrosoftLogin()` и испускается `failed` с текстом от
|
||||||
|
Microsoft или общим «вход не завершён».
|
||||||
|
|
||||||
|
`codeTaken` нужен потому, что `WebEngineView` может сообщить об одном и том же адресе несколько
|
||||||
|
раз, а отдавать код дважды нельзя.
|
||||||
|
|
||||||
|
Кнопка «Отмена» выставляет `codeTaken`, закрывает окно и вызывает `cancelMicrosoftLogin()`. Щелчок
|
||||||
|
мимо окна и Esc его не закрывают.
|
||||||
|
|
||||||
|
## Куки
|
||||||
|
|
||||||
|
Профиль браузера создаётся через `WebEngineProfilePrototype` без `storageName`, то есть только в
|
||||||
|
памяти. Куки живут, пока работает лаунчер, и в общий браузер не попадают. Выбрать другой аккаунт
|
||||||
|
в пределах одного запуска позволяет `prompt=select_account` в адресе входа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ProgressPanel.qml
|
||||||
|
|
||||||
|
Плашка прогресса долгой операции, 320×72, в левом нижнем углу главного окна. Там она не закрывает
|
||||||
|
кнопку запуска, сообщения по центру и кнопки папки и настроек.
|
||||||
|
|
||||||
|
В [Main](Main.md) их две: загрузка (версия, модлоадер, Java, пак) и смена сборки.
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `title` | заголовок, жирным |
|
||||||
|
| `status` | вторая строка, если нет `detail` |
|
||||||
|
| `fraction` | от 0 до 1. −1 — общий объём неизвестен, вместо процентов «…» |
|
||||||
|
| `detail` | вторая строка вместо `status`: мегабайты у загрузки, путь у архивации. Длинный текст обрезается посередине |
|
||||||
|
| `cancellable` | показывать ли крестик; по умолчанию `true` |
|
||||||
|
| `cancelRequested()` | нажат крестик |
|
||||||
|
|
||||||
|
Панель только показывает данные. Видимость, значения и реакцию на отмену задаёт главное окно: у
|
||||||
|
загрузки крестик вызывает `backend.cancelDownload()`, у смены сборки крестика нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# SeasonalBuildsDialog.qml
|
||||||
|
|
||||||
|
Каталог сезонных сборок с сервера лаунчера. Таблица показывает всё, что нужно, чтобы решить,
|
||||||
|
ставить сборку или нет. Остальное делает одна кнопка: создаёт запись сборки, ставит версию игры,
|
||||||
|
модлоадер и Java и раскладывает файлы. Как это устроено, описано в
|
||||||
|
[LauncherBackend](../cpp/LauncherBackend.md#сезонные-сборки).
|
||||||
|
|
||||||
|
Открывается из [Main](Main.md) кнопкой в правом нижнем углу (`openCatalog()`).
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `selectedId` | выбранная строка, по `id` сборки |
|
||||||
|
| `columns` | описание колонок: ключ, заголовок, ширина, выравнивание |
|
||||||
|
|
||||||
|
Выбранная строка хранится по `id`, а не по индексу: список может обновиться, пока окно открыто,
|
||||||
|
и индекс начал бы указывать на другую сборку.
|
||||||
|
|
||||||
|
## Таблица
|
||||||
|
|
||||||
|
Колонки: название, версия, модлоадер с версией, число модов, начало и конец сезона, статус
|
||||||
|
(«не установлена», «установлена», «доступно обновление»). Ширины заданы в одном месте, `columns`,
|
||||||
|
и их используют и шапка, и строки.
|
||||||
|
|
||||||
|
Таблица собрана из `Row` с колонками фиксированной ширины, а не из `TableView`. В проекте нет ни
|
||||||
|
одной модели на `QAbstractItemModel`, строки приходят из C++ готовыми `QVariantMap`, и заводить
|
||||||
|
модель ради семи колонок не стоит.
|
||||||
|
|
||||||
|
Строки приходят уже отсортированными и сведёнными с локальными записями: окно ничего не считает.
|
||||||
|
|
||||||
|
Пустой список и ошибка сервера выглядят одинаково пусто, поэтому причина пишется прямо на месте
|
||||||
|
строк: «загрузка…», «сборок пока нет» или текст `seasonalCatalogError` красным.
|
||||||
|
|
||||||
|
Под таблицей описание выбранной сборки, ревизия, размер архива и установленная ревизия, если она
|
||||||
|
есть. Описания длинные и в таблицу не помещаются, а решение принимают как раз по ним.
|
||||||
|
|
||||||
|
## Кнопки
|
||||||
|
|
||||||
|
- **Обновить список** — `refreshSeasonalCatalog(true)`, мимо кэша. Выключена, пока идёт загрузка.
|
||||||
|
- **Установить / Обновить** — `installSeasonalBuild(id)`. Подпись зависит от `updateAvailable`.
|
||||||
|
Выключена, пока лаунчер чем-то занят (`busy`): установка занимает и панель загрузки, и
|
||||||
|
`.minecraft` целиком.
|
||||||
|
|
||||||
|
Двойной щелчок по строке делает то же, что кнопка установки.
|
||||||
|
|
||||||
|
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
|
||||||
|
без сети.
|
||||||
|
|
||||||
|
Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только
|
||||||
|
на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна
|
||||||
|
продолжается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# VersionPickerDialog.qml
|
||||||
|
|
||||||
|
Окно выбора версии Minecraft. Слева категории, справа список версий выбранной категории с поиском.
|
||||||
|
|
||||||
|
Раньше на этом месте был выпадающий список на тысячу версий, и найти в нём, например, бету 1.7
|
||||||
|
можно было только набрав точный номер. Категории делят каталог на части разумного размера, а поиск
|
||||||
|
работает внутри выбранной.
|
||||||
|
|
||||||
|
Используется в [BuildsDialog](BuildsDialog.md) для поля версии игры.
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `backend` (required) | `LauncherBackend` |
|
||||||
|
| `selectedId` | выбранная версия |
|
||||||
|
| `category` | `release`, `snapshot`, `old_beta`, `old_alpha` или `other` |
|
||||||
|
| `filterText` | строка поиска |
|
||||||
|
| `installedOnly` | показывать только скачанные |
|
||||||
|
| `openFor(versionId)` | открыть с этой версией |
|
||||||
|
| `versionChosen(versionId)` | выбор подтверждён |
|
||||||
|
|
||||||
|
Сигнал называется не `accepted`, потому что такой сигнал у `Dialog` уже есть, и переопределить его
|
||||||
|
нельзя.
|
||||||
|
|
||||||
|
## Как работает
|
||||||
|
|
||||||
|
`openFor()` сбрасывает поиск, переключается на категорию переданной версии (а не всегда на
|
||||||
|
релизы), просит бэкенд обновить каталог (`refreshVersionCatalog()`) и прокручивает список к
|
||||||
|
выбранной строке. Если версии нет в каталоге, окно открывается на релизах. По «Отмене» ничего не
|
||||||
|
меняется: результат уходит наружу только через `versionChosen`.
|
||||||
|
|
||||||
|
`versionCatalog` приходит из C++ уже отсортированным, новые сверху. `visibleEntries` только
|
||||||
|
отбирает строки по категории, флажку «только скачанные» и подстроке в поле `search`, а порядок не
|
||||||
|
меняет. В «Прочие» попадают профили модлоадеров и всё, чего нет в манифесте Mojang.
|
||||||
|
|
||||||
|
С клавиатуры: фокус сразу в поиске, стрелки вверх и вниз двигают выбор, Enter подтверждает, Esc
|
||||||
|
закрывает. Мышью выбор подтверждается двойным щелчком или кнопкой.
|
||||||
|
|
||||||
|
Если список пуст, на его месте написано почему: каталог ещё грузится, каталог не загрузился или по
|
||||||
|
запросу ничего нет.
|
||||||
|
|
||||||
|
## Удаление версии
|
||||||
|
|
||||||
|
У скачанных версий есть корзина. Версия занимает от десятков мегабайт, а это окно — единственное
|
||||||
|
место, где видно, что именно скачано.
|
||||||
|
|
||||||
|
`askRemove(versionId)` берёт `versionRemovalInfo()` и открывает подтверждение. В нём указаны
|
||||||
|
размер, профили модлоадеров, которые без этой версии не запустятся (`dependents`), и сборки,
|
||||||
|
которые на неё ссылаются (`builds`). id запоминается в окне подтверждения: к моменту ответа под
|
||||||
|
курсором может оказаться другая строка.
|
||||||
|
|
||||||
|
`performRemove(versionId)` вызывает `removeVersion()`. Если удалили выбранный профиль модлоадера,
|
||||||
|
он пропадает из каталога совсем (он был в нём только потому, что установлен), и выбор
|
||||||
|
сбрасывается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
#include "gamelauncher.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include "javalocator.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QtCore/private/qzipreader_p.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
constexpr QChar kClasspathSeparator = u';';
|
||||||
|
#else
|
||||||
|
constexpr QChar kClasspathSeparator = u':';
|
||||||
|
#endif
|
||||||
|
|
||||||
|
QString libraryPath(const QString &gameDir, const MinecraftLibrary &library)
|
||||||
|
{
|
||||||
|
return gameDir + QStringLiteral("/libraries/") + library.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString nativeToken(const QString &path)
|
||||||
|
{
|
||||||
|
return QDir::toNativeSeparators(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подставляет ${...}. Аргументы, в которых после подстановки остались
|
||||||
|
// неизвестные плейсхолдеры, вызывающий выбрасывает.
|
||||||
|
QString substitute(QString argument, const QHash<QString, QString> &variables)
|
||||||
|
{
|
||||||
|
for (auto it = variables.cbegin(); it != variables.cend(); ++it)
|
||||||
|
argument.replace(QStringLiteral("${") + it.key() + u'}', it.value());
|
||||||
|
return argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isNativeLibraryFile(const QString &fileName)
|
||||||
|
{
|
||||||
|
static const QStringList suffixes = {QStringLiteral(".dll"),
|
||||||
|
QStringLiteral(".so"),
|
||||||
|
QStringLiteral(".dylib"),
|
||||||
|
QStringLiteral(".jnilib")};
|
||||||
|
for (const QString &suffix : suffixes)
|
||||||
|
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
|
||||||
|
return true;
|
||||||
|
// Версионированные so (libfoo.so.1, libfoo.so.1.2.3). Хвост — строго цифры,
|
||||||
|
// иначе в натив попадают служебные файлы LWJGL вроде liblwjgl.so.sha1 / .so.git.
|
||||||
|
static const QRegularExpression versionedSo(QStringLiteral("\\.so(\\.\\d+)+$"),
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
return versionedSo.match(fileName).hasMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
GameLauncher::GameLauncher(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::isRunning() const
|
||||||
|
{
|
||||||
|
return m_process && m_process->state() != QProcess::NotRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList GameLauncher::missingFiles(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
int limit)
|
||||||
|
{
|
||||||
|
QStringList missing;
|
||||||
|
const auto add = [&missing, limit](const QString &path) {
|
||||||
|
if (missing.size() < limit)
|
||||||
|
missing << QDir::toNativeSeparators(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!QDir(options.gameDir).exists())
|
||||||
|
return {QDir::toNativeSeparators(options.gameDir)};
|
||||||
|
|
||||||
|
if (!QFile::exists(version.clientJarPath))
|
||||||
|
add(version.clientJarPath);
|
||||||
|
|
||||||
|
for (const MinecraftLibrary &library : version.libraries) {
|
||||||
|
const QString path = libraryPath(options.gameDir, library);
|
||||||
|
if (!QFile::exists(path))
|
||||||
|
add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!version.assetIndexId.isEmpty()) {
|
||||||
|
const QString index = options.gameDir + QStringLiteral("/assets/indexes/")
|
||||||
|
+ version.assetIndexId + QStringLiteral(".json");
|
||||||
|
if (!QFile::exists(index))
|
||||||
|
add(index);
|
||||||
|
else if (!QDir(options.gameDir + QStringLiteral("/assets/objects")).exists())
|
||||||
|
add(options.gameDir + QStringLiteral("/assets/objects"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool ignoreListCovers(const QString &ignoreListArgument, const QString &fileName)
|
||||||
|
{
|
||||||
|
const QString value = ignoreListArgument.mid(QStringLiteral("-DignoreList=").size());
|
||||||
|
const QStringList prefixes = value.split(u',', Qt::SkipEmptyParts);
|
||||||
|
for (const QString &prefix : prefixes) {
|
||||||
|
if (!prefix.isEmpty() && fileName.startsWith(prefix))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QStringList GameLauncher::buildArguments(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir)
|
||||||
|
{
|
||||||
|
QStringList classpath;
|
||||||
|
for (const MinecraftLibrary &library : version.libraries) {
|
||||||
|
if (library.native)
|
||||||
|
continue;
|
||||||
|
classpath << nativeToken(libraryPath(options.gameDir, library));
|
||||||
|
}
|
||||||
|
classpath << nativeToken(version.clientJarPath);
|
||||||
|
|
||||||
|
const QString assetsRoot = options.gameDir + QStringLiteral("/assets");
|
||||||
|
const bool legacyAssets = version.assetsKind == QLatin1String("legacy")
|
||||||
|
|| version.assetsKind == QLatin1String("pre-1.6");
|
||||||
|
|
||||||
|
QHash<QString, QString> variables;
|
||||||
|
variables[QStringLiteral("auth_player_name")] = options.playerName;
|
||||||
|
variables[QStringLiteral("auth_uuid")] = options.uuid;
|
||||||
|
variables[QStringLiteral("auth_access_token")] = options.accessToken;
|
||||||
|
variables[QStringLiteral("auth_session")] =
|
||||||
|
QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid);
|
||||||
|
variables[QStringLiteral("auth_xuid")] =
|
||||||
|
options.xuid.isEmpty() ? QStringLiteral("0") : options.xuid;
|
||||||
|
variables[QStringLiteral("clientid")] =
|
||||||
|
options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken;
|
||||||
|
variables[QStringLiteral("user_type")] = options.userType;
|
||||||
|
variables[QStringLiteral("user_properties")] = QStringLiteral("{}");
|
||||||
|
variables[QStringLiteral("version_name")] = version.id;
|
||||||
|
variables[QStringLiteral("version_type")] = version.type;
|
||||||
|
variables[QStringLiteral("game_directory")] = nativeToken(options.gameDir);
|
||||||
|
variables[QStringLiteral("assets_root")] = nativeToken(assetsRoot);
|
||||||
|
variables[QStringLiteral("assets_index_name")] = version.assetIndexId;
|
||||||
|
variables[QStringLiteral("game_assets")] =
|
||||||
|
nativeToken(legacyAssets ? assetsRoot + QStringLiteral("/virtual/legacy") : assetsRoot);
|
||||||
|
variables[QStringLiteral("natives_directory")] = nativeToken(nativesDir);
|
||||||
|
variables[QStringLiteral("library_directory")] =
|
||||||
|
nativeToken(options.gameDir + QStringLiteral("/libraries"));
|
||||||
|
variables[QStringLiteral("classpath")] = classpath.join(kClasspathSeparator);
|
||||||
|
variables[QStringLiteral("classpath_separator")] = QString(kClasspathSeparator);
|
||||||
|
variables[QStringLiteral("primary_jar")] = nativeToken(version.clientJarPath);
|
||||||
|
variables[QStringLiteral("launcher_name")] = options.launcherName;
|
||||||
|
variables[QStringLiteral("launcher_version")] = options.launcherVersion;
|
||||||
|
variables[QStringLiteral("resolution_width")] = QString::number(options.windowWidth);
|
||||||
|
variables[QStringLiteral("resolution_height")] = QString::number(options.windowHeight);
|
||||||
|
|
||||||
|
QStringList arguments;
|
||||||
|
|
||||||
|
// Свои jvm-аргументы идут первыми — как в строке запуска из примера.
|
||||||
|
if (!options.authlibInjectorPath.isEmpty()) {
|
||||||
|
arguments << QStringLiteral("-javaagent:%1=%2")
|
||||||
|
.arg(nativeToken(options.authlibInjectorPath), options.authlibInjectorApi)
|
||||||
|
<< QStringLiteral("-Dauthlibinjector.side=client");
|
||||||
|
}
|
||||||
|
if (options.minMemoryMb > 0)
|
||||||
|
arguments << QStringLiteral("-Xms%1M").arg(options.minMemoryMb);
|
||||||
|
if (options.maxMemoryMb > 0)
|
||||||
|
arguments << QStringLiteral("-Xmx%1M").arg(options.maxMemoryMb);
|
||||||
|
arguments += options.extraJvmArgs;
|
||||||
|
|
||||||
|
// Forge 1.17+ собирает слой модулей из classpath, пропуская всё, чьё имя
|
||||||
|
// начинается с записи в -DignoreList. Ванильный клиентский jar записан там
|
||||||
|
// как ${version_name}.jar — с расчётом на лаунчер Mojang, который кладёт в
|
||||||
|
// classpath jar самого профиля. Своего jar у профиля Forge нет, мы кладём
|
||||||
|
// родительский, и без его имени в списке он становится вторым модулем с
|
||||||
|
// классами Minecraft: «Modules minecraft and _1._20._1 export package ...».
|
||||||
|
const QString clientJarName = QFileInfo(version.clientJarPath).fileName();
|
||||||
|
|
||||||
|
for (const QString &argument : version.jvmArgs) {
|
||||||
|
QString resolved = substitute(argument, variables);
|
||||||
|
if (resolved.contains(QStringLiteral("${")))
|
||||||
|
continue;
|
||||||
|
if (!clientJarName.isEmpty() && resolved.startsWith(QLatin1String("-DignoreList="))
|
||||||
|
&& !ignoreListCovers(resolved, clientJarName))
|
||||||
|
resolved += u',' + clientJarName;
|
||||||
|
arguments << resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!version.loggingArgument.isEmpty() && QFile::exists(version.loggingConfigPath)) {
|
||||||
|
QHash<QString, QString> loggingVariable;
|
||||||
|
loggingVariable[QStringLiteral("path")] = nativeToken(version.loggingConfigPath);
|
||||||
|
arguments << substitute(version.loggingArgument, loggingVariable);
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments << version.mainClass;
|
||||||
|
|
||||||
|
for (const QString &argument : version.gameArgs) {
|
||||||
|
const QString resolved = substitute(argument, variables);
|
||||||
|
if (!resolved.contains(QStringLiteral("${")))
|
||||||
|
arguments << resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.windowWidth > 0 && options.windowHeight > 0
|
||||||
|
&& !version.hasCustomResolutionArgs && !arguments.contains(QStringLiteral("--width"))) {
|
||||||
|
arguments << QStringLiteral("--width") << QString::number(options.windowWidth)
|
||||||
|
<< QStringLiteral("--height") << QString::number(options.windowHeight);
|
||||||
|
}
|
||||||
|
if (options.fullscreen && !arguments.contains(QStringLiteral("--fullscreen")))
|
||||||
|
arguments << QStringLiteral("--fullscreen");
|
||||||
|
|
||||||
|
if (!options.serverAddress.isEmpty()) {
|
||||||
|
if (version.supportsQuickPlay) {
|
||||||
|
arguments << QStringLiteral("--quickPlayMultiplayer") << options.serverAddress;
|
||||||
|
} else {
|
||||||
|
const int colon = options.serverAddress.lastIndexOf(u':');
|
||||||
|
const QString host = colon > 0 ? options.serverAddress.left(colon) : options.serverAddress;
|
||||||
|
const QString port = colon > 0 ? options.serverAddress.mid(colon + 1)
|
||||||
|
: QStringLiteral("25565");
|
||||||
|
arguments << QStringLiteral("--server") << host << QStringLiteral("--port") << port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::extractNatives(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir,
|
||||||
|
QString *error)
|
||||||
|
{
|
||||||
|
if (!QDir().mkpath(nativesDir)) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.createFolderFailed").arg(QDir::toNativeSeparators(nativesDir));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const MinecraftLibrary &library : version.libraries) {
|
||||||
|
if (!library.native)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QString jar = libraryPath(options.gameDir, library);
|
||||||
|
QZipReader reader(jar);
|
||||||
|
if (!reader.isReadable()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.nativeReadFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(jar));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||||
|
for (const QZipReader::FileInfo &entry : entries) {
|
||||||
|
if (!entry.isFile)
|
||||||
|
continue;
|
||||||
|
if (entry.filePath.startsWith(QStringLiteral("META-INF/")))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool excluded = false;
|
||||||
|
for (const QString &prefix : library.extractExclude) {
|
||||||
|
if (entry.filePath.startsWith(prefix)) {
|
||||||
|
excluded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (excluded)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QString fileName = QFileInfo(entry.filePath).fileName();
|
||||||
|
if (!isNativeLibraryFile(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QString target = nativesDir + u'/' + fileName;
|
||||||
|
const QByteArray data = reader.fileData(entry.filePath);
|
||||||
|
if (QFileInfo(target).size() == data.size() && QFile::exists(target))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
QFile file(target);
|
||||||
|
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.extractFailed")
|
||||||
|
.arg(fileName, QDir::toNativeSeparators(nativesDir));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::launch(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
QString *error)
|
||||||
|
{
|
||||||
|
if (isRunning()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("common.error.gameRunning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("launch.progress.checkingFiles"));
|
||||||
|
const QStringList missing = missingFiles(options, version);
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.filesMissing")
|
||||||
|
.arg(missing.size())
|
||||||
|
.arg(missing.join(u'\n'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("launch.progress.extractingNatives"));
|
||||||
|
const QString nativesDir = options.gameDir + QStringLiteral("/versions/") + version.id
|
||||||
|
+ QStringLiteral("/natives");
|
||||||
|
if (!extractNatives(options, version, nativesDir, error))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
emit progress(Loc::text("launch.progress.lookingForJava").arg(version.javaMajor));
|
||||||
|
const QString java = JavaLocator::select(options.gameDir, version.javaMajor, options.javaPath, error);
|
||||||
|
if (java.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const QStringList arguments = buildArguments(options, version, nativesDir);
|
||||||
|
const QString executable = JavaLocator::windowlessVariant(java);
|
||||||
|
|
||||||
|
m_process = new QProcess(this);
|
||||||
|
m_process->setProgram(executable);
|
||||||
|
m_process->setArguments(arguments);
|
||||||
|
m_process->setWorkingDirectory(options.gameDir);
|
||||||
|
m_process->setProcessChannelMode(QProcess::MergedChannels);
|
||||||
|
|
||||||
|
connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() {
|
||||||
|
const QString chunk = QString::fromLocal8Bit(m_process->readAllStandardOutput());
|
||||||
|
const QStringList lines = chunk.split(u'\n', Qt::SkipEmptyParts);
|
||||||
|
for (const QString &line : lines)
|
||||||
|
emit output(line.trimmed());
|
||||||
|
});
|
||||||
|
connect(m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
|
||||||
|
emit gameFinished(exitCode, status == QProcess::CrashExit);
|
||||||
|
m_process->deleteLater();
|
||||||
|
m_process = nullptr;
|
||||||
|
});
|
||||||
|
// Токен в лог не попадает — маскируем его в отображаемой строке запуска.
|
||||||
|
QStringList shown = arguments;
|
||||||
|
const int tokenIndex = shown.indexOf(QStringLiteral("--accessToken"));
|
||||||
|
if (tokenIndex >= 0 && tokenIndex + 1 < shown.size())
|
||||||
|
shown[tokenIndex + 1] = QStringLiteral("<скрыт>");
|
||||||
|
|
||||||
|
m_process->start();
|
||||||
|
if (!m_process->waitForStarted(15000)) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.startFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(executable), m_process->errorString());
|
||||||
|
m_process->deleteLater();
|
||||||
|
m_process = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit gameStarted(QDir::toNativeSeparators(executable) + QStringLiteral(" ")
|
||||||
|
+ shown.join(QStringLiteral(" ")));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncher::terminate()
|
||||||
|
{
|
||||||
|
if (isRunning())
|
||||||
|
m_process->kill();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "launcherversion.h"
|
||||||
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QProcess;
|
||||||
|
|
||||||
|
// Всё, что лаунчер знает к моменту нажатия PLAY.
|
||||||
|
struct LaunchOptions
|
||||||
|
{
|
||||||
|
QString gameDir; // .minecraft
|
||||||
|
QString versionId; // Папка в versions
|
||||||
|
|
||||||
|
QString playerName;
|
||||||
|
QString uuid;
|
||||||
|
QString accessToken;
|
||||||
|
QString userType;
|
||||||
|
QString clientToken;
|
||||||
|
QString xuid; // Пусто — подставится "0", как в офлайне
|
||||||
|
|
||||||
|
QString javaPath; // Пусто — ищем сами
|
||||||
|
int minMemoryMb = 512;
|
||||||
|
int maxMemoryMb = 4096;
|
||||||
|
QStringList extraJvmArgs;
|
||||||
|
|
||||||
|
int windowWidth = 0; // 0 — не передавать --width/--height
|
||||||
|
int windowHeight = 0;
|
||||||
|
bool fullscreen = false;
|
||||||
|
|
||||||
|
QString serverAddress; // host[:port] — автозаход на сервер
|
||||||
|
QString authlibInjectorPath; // Пусто — не подключаем
|
||||||
|
QString authlibInjectorApi = QStringLiteral("ely.by");
|
||||||
|
|
||||||
|
QString launcherName = QStringLiteral("BiLauncher");
|
||||||
|
QString launcherVersion = QStringLiteral(LAUNCHER_VERSION);
|
||||||
|
};
|
||||||
|
|
||||||
|
class GameLauncher : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GameLauncher(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool isRunning() const;
|
||||||
|
static QStringList missingFiles(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
int limit = 12);
|
||||||
|
|
||||||
|
static QStringList buildArguments(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir);
|
||||||
|
|
||||||
|
static bool extractNatives(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir,
|
||||||
|
QString *error);
|
||||||
|
|
||||||
|
bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString *error);
|
||||||
|
|
||||||
|
void terminate();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progress(const QString &message);
|
||||||
|
void output(const QString &line);
|
||||||
|
void gameStarted(const QString &commandLine);
|
||||||
|
void gameFinished(int exitCode, bool crashed);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QProcess *m_process = nullptr;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Version=1.0
|
||||||
|
Name=BiLauncher
|
||||||
|
GenericName=Minecraft Launcher
|
||||||
|
Comment=Launcher for Minecraft
|
||||||
|
Exec=BiLauncher
|
||||||
|
Icon=bilauncher
|
||||||
|
Terminal=false
|
||||||
|
Categories=Game;ActionGame;
|
||||||
|
StartupNotify=true
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Пересборка всех иконок лаунчера из одного исходника master/icon_master.png
|
||||||
|
# (квадрат 1024x1024, арт нарисован «в край» холста).
|
||||||
|
#
|
||||||
|
# Размеры у систем разные не по прихоти: macOS сама не масштабирует иконку в
|
||||||
|
# доке, поэтому поля закладываются в сам файл — по сетке Apple арт занимает
|
||||||
|
# 824x824 внутри 1024x1024, а углы скруглены радиусом 185.4. Windows и Linux,
|
||||||
|
# наоборот, ждут картинку на весь холст и добавляют отступы сами, так что для
|
||||||
|
# них тот же арт идёт без полей.
|
||||||
|
#
|
||||||
|
# Нужны ImageMagick (magick) и iconutil из состава macOS.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
MASTER="master/icon_master.png"
|
||||||
|
[ -f "$MASTER" ] || { echo "нет исходника $MASTER" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v magick >/dev/null || { echo "нужен ImageMagick: brew install imagemagick" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Сетка Apple для иконки-«скруглённого квадрата», доли от холста 1024.
|
||||||
|
readonly APPLE_ART=824
|
||||||
|
readonly APPLE_RADIUS=185.4
|
||||||
|
readonly CANVAS=1024
|
||||||
|
|
||||||
|
echo "==> растры без полей (Windows, Linux, иконка окна Qt)"
|
||||||
|
# 24 и 48 нужны панели задач Windows и темам значков Linux, 64 — HiDPI-вариант 32.
|
||||||
|
for size in 16 24 32 48 64 128 256 512; do
|
||||||
|
magick "$MASTER" -filter Lanczos -resize "${size}x${size}" -strip "PNG32:icon_${size}x${size}.png"
|
||||||
|
done
|
||||||
|
# Те же кадры под именами @2x: Qt по такому имени берёт растр для HiDPI-экрана.
|
||||||
|
for base in 16 32 128 256 512; do
|
||||||
|
magick "$MASTER" -filter Lanczos -resize "$((base * 2))x$((base * 2))" -strip "PNG32:icon_${base}x${base}@2x.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> icon.ico (Windows 10/11)"
|
||||||
|
# Кадры от 16 до 256: проводник, панель задач и Alt+Tab берут свой размер сами,
|
||||||
|
# промежуточные 20, 40 и 96 нужны масштабам 125%, 150% и 200%. Без явного списка
|
||||||
|
# ImageMagick кладёт в .ico только исходные 1024x1024, и система их мылит.
|
||||||
|
magick "$MASTER" -filter Lanczos \
|
||||||
|
-define icon:auto-resize=256,128,96,64,48,40,32,24,20,16 \
|
||||||
|
-strip icon.ico
|
||||||
|
|
||||||
|
echo "==> icon.icns (macOS)"
|
||||||
|
# Маска рисуется в четыре раза крупнее и ужимается — так края скругления
|
||||||
|
# получаются сглаженными, а не ступенькой.
|
||||||
|
SS=4
|
||||||
|
MASK="$(mktemp -t icnsmask).png"
|
||||||
|
PADDED="$(mktemp -t icnspad).png"
|
||||||
|
trap 'rm -f "$MASK" "$PADDED"' EXIT
|
||||||
|
|
||||||
|
mask_px=$((APPLE_ART * SS))
|
||||||
|
mask_radius=$(awk -v r="$APPLE_RADIUS" -v ss="$SS" 'BEGIN { printf "%d", r * ss }')
|
||||||
|
# Префикс PNG32: обязателен: без него ImageMagick сохранит белое-на-прозрачном
|
||||||
|
# как серую картинку без альфы, и маска вырежет вообще всё.
|
||||||
|
magick -size "${mask_px}x${mask_px}" xc:none -fill white -antialias \
|
||||||
|
-draw "roundrectangle 0,0,$((mask_px - 1)),$((mask_px - 1)),${mask_radius},${mask_radius}" \
|
||||||
|
-filter Lanczos -resize "${APPLE_ART}x${APPLE_ART}" "PNG32:$MASK"
|
||||||
|
|
||||||
|
# DstIn оставляет картинку только там, где непрозрачна маска: арт ужимается до
|
||||||
|
# 824 и обрезается по «яблочному» скруглению. Дальше -compose Copy обязателен:
|
||||||
|
# -extent кладёт результат на прозрачный холст 1024 текущим оператором, а с
|
||||||
|
# оставшимся DstIn холст вычистил бы всю картинку.
|
||||||
|
magick "$MASTER" -filter Lanczos -resize "${APPLE_ART}x${APPLE_ART}" \
|
||||||
|
"$MASK" -compose DstIn -composite \
|
||||||
|
-compose Copy -background none -gravity center -extent "${CANVAS}x${CANVAS}" \
|
||||||
|
-strip "PNG32:$PADDED"
|
||||||
|
|
||||||
|
rm -rf icon.iconset
|
||||||
|
mkdir icon.iconset
|
||||||
|
for base in 16 32 128 256 512; do
|
||||||
|
magick "$PADDED" -filter Lanczos -resize "${base}x${base}" -strip "PNG32:icon.iconset/icon_${base}x${base}.png"
|
||||||
|
magick "$PADDED" -filter Lanczos -resize "$((base * 2))x$((base * 2))" -strip "PNG32:icon.iconset/icon_${base}x${base}@2x.png"
|
||||||
|
done
|
||||||
|
iconutil -c icns icon.iconset -o icon.icns
|
||||||
|
# Промежуточный .iconset больше не нужен: всё его содержимое лежит в .icns.
|
||||||
|
rm -rf icon.iconset
|
||||||
|
|
||||||
|
echo "готово"
|
||||||
|
After Width: | Height: | Size: 409 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
#include <winver.h>
|
||||||
|
|
||||||
|
IDI_ICON1 ICON "@LAUNCHER_ICON_DIR@/icon.ico"
|
||||||
|
|
||||||
|
VS_VERSION_INFO VERSIONINFO
|
||||||
|
FILEVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0
|
||||||
|
PRODUCTVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0
|
||||||
|
FILEFLAGSMASK 0x3fL
|
||||||
|
FILEFLAGS 0x0L
|
||||||
|
FILEOS VOS_NT_WINDOWS32
|
||||||
|
FILETYPE VFT_APP
|
||||||
|
FILESUBTYPE 0x0L
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "041904b0"
|
||||||
|
BEGIN
|
||||||
|
VALUE "CompanyName", "GaleonDev\0"
|
||||||
|
VALUE "FileDescription", "BiLauncher\0"
|
||||||
|
VALUE "FileVersion", "@PROJECT_VERSION@\0"
|
||||||
|
VALUE "InternalName", "BiLauncher\0"
|
||||||
|
VALUE "OriginalFilename", "BiLauncher.exe\0"
|
||||||
|
VALUE "ProductName", "BiLauncher\0"
|
||||||
|
VALUE "ProductVersion", "@PROJECT_VERSION@\0"
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x419, 1200
|
||||||
|
END
|
||||||
|
END
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 811 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 515 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 467 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #637a10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path class="cls-2" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||||
|
<path class="cls-1" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #232323;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path class="cls-2" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||||
|
<path class="cls-1" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #232323;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path class="cls-1" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||||
|
<path class="cls-2" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 69.88 39.72">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #232323;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-2" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="m46.69,9.71h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-1" d="m46.69,18.01h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-1" d="m46.69,26.32h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 978 B |