80 Commits
Author SHA1 Message Date
galeon 3ac2156ef2 traslations fix
tests / windows (push) Failing after 6m19s
tests / linux (push) Successful in 2m12s
tests / macos (push) Failing after 1m15s
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
release / create-release (push) Successful in 3s
release / build-linux (push) Successful in 2m17s
release / build-macos (push) Successful in 2m44s
release / build-windows (push) Successful in 6m27s
release / publish (push) Successful in 1s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-14 09:32:17 +03:00
galeon 5d819129d6 Обновить .gitea/workflows/tests.yml
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Failing after 1m20s
tests / linux (push) Successful in 2m13s
tests / windows (push) Failing after 12m6s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-13 06:01:40 +00:00
galeon 0dd6bef715 test fixes
tests / windows (push) Skipped
tests / macos (push) Skipped
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / linux (push) Successful in 1m59s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-13 08:51:07 +03:00
galeon 41599fa82f new unit tests, hehe 67
tests / linux (push) Failing after 2m18s
tests / windows (push) Skipped
tests / macos (push) Skipped
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-12 13:14:08 +03:00
galeon ebf8ff7869 release workflow fix
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-12 09:25:48 +03:00
galeon 443850196d fix bug with launcher version 2026-10-11 22:32:58 +03:00
galeon f10b654953 documentation rewrite
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-11 22:32:13 +03:00
galeon 1943996608 more translation fixes
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-09 13:01:01 +03:00
galeon 0f370167db translations fix
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-09 12:29:21 +03:00
galeon fd14b54d8a ci\cd fixes
release / create-release (push) Successful in 3s
release / build-macos (push) Successful in 1m50s
release / build-linux (push) Successful in 2m15s
release / build-windows (push) Successful in 6m40s
release / publish (push) Successful in 0s
2026-10-09 00:23:12 +03:00
galeon 8a3d0596ef ci\cd 2026-10-08 23:26:01 +03:00
galeon 29b53ce75a Обновить README.md
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-07 10:14:14 +00:00
galeon 5bbca110d7 Обновить CHANGELOG.md
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-07 09:58:37 +00:00
galeon c66d90ece6 Обновить LICENSE
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-07 09:42:44 +00:00
galeon d77d4c69cd Обновить README.md
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-07 06:20:01 +00:00
galeon 7a54f97dcd Загрузить файлы в «Screenshots»
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-07 05:45:16 +00:00
galeon e85f52e886 Загрузить файлы в «Screenshots»
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-07 05:38:54 +00:00
galeon 1bf3617055 Загрузить файлы в «Screenshots»
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-07 05:38:08 +00:00
galeon bc2b8d27ca Удалить Screenshots 2026-10-06 15:14:55 +00:00
galeon bb202af9a7 Добавить Screenshots 2026-10-06 15:14:20 +00:00
galeon e60a4c2333 Merge branch 'refs/heads/beta-2' 2026-10-06 14:31:44 +03:00
galeon 438666c9d5 1 2026-10-06 14:24:27 +03:00
galeon 91483ea468 Удалить .gitignore
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-06 10:43:08 +00:00
galeon 694c7603b2 Удалить README.md
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-06 10:42:50 +00:00
galeon 04b7799fd3 Добавить README.md
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-06 10:41:29 +00:00
galeon 3737346bea Обновить README.md
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-05 06:41:26 +00:00
galeon ae57940c62 icons beta patch remove and cleanups 2026-10-04 23:46:57 +03:00
galeon 1b18cf48bb predrelease changes 2026-10-04 22:19:20 +03:00
galeon c11da87908 more cleanups 2026-09-30 20:46:20 +03:00
galeon 6567e731d1 cleanups 2026-09-29 09:40:37 +03:00
galeon 441021b9a6 path fixes 2026-09-21 01:23:00 +03:00
galeon 5dba4e89cb new icons and translation fix 2026-09-21 00:27:52 +03:00
galeon 01af372ef2 translation of all project, and new translation mechanism 2026-09-03 09:18:48 +03:00
galeon 00e7c957e4 new docs for project 2026-09-03 09:16:56 +03:00
galeon f1a840174b login variants fields fixes 2026-09-01 22:53:16 +03:00
galeon d6986057d1 license login feature 2026-09-01 22:25:23 +03:00
galeon c25e071b29 seasonal builds 2026-08-31 00:59:14 +03:00
galeon 4689e59b5a zlib transfer 2026-08-28 01:51:12 +03:00
galeon 616840fda9 modded start fix 2026-08-28 01:29:39 +03:00
galeon bbc8633257 forge on linux fix 2026-08-28 00:13:59 +03:00
galeon af41017446 verison deletion feature 2026-08-27 21:05:37 +03:00
galeon dddc0261d4 java implementation and downloading 2026-08-27 19:35:11 +03:00
galeon 08e0dc4965 builds fixes 2026-08-27 16:44:42 +03:00
galeon 168884127b new builds menu and modloaders implementation 2026-08-27 09:21:51 +03:00
galeon 5bc59f8018 version tracking and auto installing 2026-08-24 21:09:52 +03:00
galeon 671fbe169d new home folder for launcher 2026-08-23 03:30:31 +03:00
galeon 2c51c4736a revert af4230500e
revert add launcher folder and some checks to it
2026-08-22 23:39:46 +00:00
galeon af4230500e add launcher folder and some checks to it 2026-08-23 01:20:18 +03:00
galeon 2c4b594c90 adding startup progress and style fix 2026-08-22 18:19:13 +03:00
galeon 56e56a370f fix folder button location 2026-07-05 19:35:30 +03:00
galeon 2b67938385 add button for opening mod folder 2026-06-19 21:03:37 +03:00
galeon 966cd46049 added editing for profiles\versions 2026-06-16 12:16:59 +03:00
galeon bbbed6aa89 Удалить .qtcreator/CMakeLists.txt.user 2026-06-14 21:23:38 +00:00
galeon 2d35c613a9 added a new feature to delete existing version\profile 2026-06-15 00:22:27 +03:00
galeon 4bf16a183f del some shit 2026-06-14 20:32:19 +03:00
galeon af2139c671 fixed text width in blocks 2026-06-14 20:04:17 +03:00
galeon ca2a6bdbff Merge branch 'beta-2' of https://gitea.galeonlab.ru/galeon/minecraft-launcher into beta-2 2026-06-14 18:17:17 +03:00
galeon d3bee159bc added profile and version adding through comboBox 2026-06-09 00:43:06 +03:00
galeon 2b8119c64e new UI 2026-05-09 15:41:22 +03:00
galeon 5dd96fc4dc delete old files 2025-12-29 02:21:17 +03:00
galeon 81877df00f new beta interface 2025-12-29 02:18:35 +03:00
UtkaG 49cbded003 Загрузить файлы в «Ui-TEMP» 2025-11-04 15:49:01 +00:00
UtkaG 38d9805150 revert d760bc2f83
revert Загрузить файлы в «Ui-TEMP»
2025-11-04 15:48:24 +00:00
UtkaG d760bc2f83 Загрузить файлы в «Ui-TEMP» 2025-11-04 15:47:41 +00:00
galeon 3b3d9e7af0 Добавлена реализация базовых профилей (Имя, путь до java) 2025-10-26 20:00:25 +03:00
galeon 0d18e48783 Merge branch 'beta' of https://gitea.galeonlab.ru/galeon/minecraft-launcher into beta 2025-10-26 18:22:51 +03:00
UtkaG b5b964713e Загрузить файлы в «assets/version box» 2025-10-26 15:19:29 +00:00
UtkaG 8ea52d0d70 Загрузить файлы в «assets/scroll» 2025-10-26 15:19:02 +00:00
UtkaG c5e16bc295 Загрузить файлы в «assets/profile box» 2025-10-26 15:18:43 +00:00
galeon a22dca1093 Реализация запуска с аргументами из файла 2025-10-26 18:18:23 +03:00
UtkaG a485621830 Загрузить файлы в «assets/play button» 2025-10-26 15:18:15 +00:00
UtkaG 249476a7ff Загрузить файлы в «assets/options» 2025-10-26 15:17:29 +00:00
UtkaG 0832ebc0a7 Загрузить файлы в «assets/Folder» 2025-10-26 15:17:05 +00:00
galeon 3be6cca929 Версия с упрощённым GUI 2025-10-25 20:05:25 +03:00
galeon 7903775959 Переделка на новый фреймворк Qt 2025-10-25 15:36:48 +03:00
galeon 0ebf7e07a7 Обновить README.md 2025-09-04 12:41:52 +00:00
galeon 52ef710cd7 Удалить main.cpp 2025-09-01 08:15:11 +00:00
galeon 4b2026e96b Удалить LauncherWindow.h 2025-09-01 08:15:06 +00:00
galeon 4c0c566ff9 Удалить LauncherWindow.cpp 2025-09-01 08:15:01 +00:00
galeon 6cfadfc4b6 Удалить CppProperties.json 2025-09-01 08:14:34 +00:00
244 changed files with 54054 additions and 0 deletions

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
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 }}
release_id: ${{ steps.release.outputs.id }}
steps:
- uses: actions/checkout@v4
- id: meta
name: Версия из CMakeLists.txt и сверка с тегом
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; }
tag=""
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
tag="${GITHUB_REF_NAME}"
base="${tag#v}"; base="${base%%-*}"
if [ "$base" != "$version" ]; then
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
fi
version="${tag#v}"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Версия: $version, тег: ${tag:-нет}"
- id: release
name: Черновик релиза
if: steps.meta.outputs.tag != ''
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 *-*) prerelease=true;; esac
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
"$TAG" "$notes" "$prerelease")
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 "Создан черновик релиза $TAG (id $id)"
build-linux:
needs: create-release
runs-on: linux
env:
VERSION: ${{ needs.create-release.outputs.version }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs:
sums: ${{ steps.pack.outputs.sums }}
steps:
- uses: actions/checkout@v4
- name: Сборка
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build AppDir dist
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
cmake --build build -j 8
cmake --install build --prefix "$PWD/AppDir/usr"
- id: pack
name: AppImage и tar.gz
run: |
mkdir -p dist
cp AppDir/usr/share/applications/bilauncher.desktop AppDir/
cp AppDir/usr/share/icons/hicolor/256x256/apps/bilauncher.png AppDir/
ln -sf usr/bin/BiLauncher AppDir/AppRun
ARCH=x86_64 appimagetool --no-appstream AppDir "dist/BiLauncher-$VERSION-x86_64.AppImage"
cp -a AppDir/usr "BiLauncher-$VERSION"
tar -czf "dist/BiLauncher-$VERSION-linux-x86_64.tar.gz" "BiLauncher-$VERSION"
rm -rf "BiLauncher-$VERSION"
ls -lh dist
echo "sums=$(cd dist && sha256sum * | base64 -w0)" >> "$GITHUB_OUTPUT"
- name: Загрузка в релиз
if: env.RELEASE_ID != ''
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
for f in dist/*; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$RELEASE_ID/assets?name=$(basename "$f")" > /dev/null
echo "Загружен $(basename "$f")"
done
- name: Артефакты прогона
if: env.RELEASE_ID == ''
uses: actions/upload-artifact@v4
with:
name: linux
path: dist/
build-windows:
needs: create-release
runs-on: windows
env:
VERSION: ${{ needs.create-release.outputs.version }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs:
sums: ${{ steps.build.outputs.sums }}
steps:
- uses: actions/checkout@v4
- id: build
name: Сборка, установщик и portable-архив
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$build = "C:\r\bl-build"; $stage = "C:\r\bl-stage"; $dist = "$PWD\dist"
Remove-Item -Recurse -Force $build, $stage, $dist -ErrorAction SilentlyContinue
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
if ($LASTEXITCODE) { throw "cmake configure" }
cmake --build $build
if ($LASTEXITCODE) { throw "cmake build" }
cmake --install $build --prefix $stage
if ($LASTEXITCODE) { throw "cmake install" }
New-Item -ItemType Directory -Force $dist | Out-Null
& "$env:ISCC_DIR\ISCC.exe" /Q "/DAppVersion=$env:VERSION" "/DSourceDir=$stage" "/DOutputDir=$dist" packaging\windows\BiLauncher.iss
if ($LASTEXITCODE) { throw "iscc" }
Remove-Item "$stage\bin\vc_redist.x64.exe" -ErrorAction SilentlyContinue
$portable = "C:\r\BiLauncher-$env:VERSION"
Remove-Item -Recurse -Force $portable -ErrorAction SilentlyContinue
Move-Item $stage $portable
& "$env:SystemRoot\System32\tar.exe" -a -c -f "$dist\BiLauncher-$env:VERSION-win64-portable.zip" -C C:\r "BiLauncher-$env:VERSION"
if ($LASTEXITCODE) { throw "zip" }
Remove-Item -Recurse -Force $portable
Get-ChildItem $dist | Format-Table Name, Length
$sums = Get-ChildItem $dist | ForEach-Object {
"{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name }
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($sums -join "`n") + "`n"))
[IO.File]::AppendAllText($env:GITHUB_OUTPUT, "sums=$b64`n")
- name: Загрузка в релиз
if: env.RELEASE_ID != ''
shell: powershell
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
foreach ($f in Get-ChildItem dist) {
curl.exe -fsS -X POST -H "Authorization: token $env:TOKEN" `
-F "attachment=@$($f.FullName)" "$env:API/releases/$env:RELEASE_ID/assets?name=$($f.Name)" | Out-Null
if ($LASTEXITCODE) { throw "Не удалось загрузить $($f.Name)" }
"Загружен $($f.Name)"
}
- name: Артефакты прогона
if: env.RELEASE_ID == ''
uses: actions/upload-artifact@v4
with:
name: windows
path: dist/
build-macos:
needs: create-release
runs-on: macos
env:
VERSION: ${{ needs.create-release.outputs.version }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs:
sums: ${{ steps.pack.outputs.sums }}
steps:
- uses: actions/checkout@v4
- name: Сборка (universal, arm64 + x86_64)
run: |
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build stage dist dmgroot
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
cmake --build build
cmake --install build --prefix "$PWD/stage"
- id: pack
name: Ad-hoc подпись и DMG
run: |
codesign --force --deep --sign - stage/BiLauncher.app
codesign --verify --deep --strict stage/BiLauncher.app
mkdir -p dmgroot dist
cp -R stage/BiLauncher.app dmgroot/
ln -s /Applications dmgroot/Applications
hdiutil create -volname "BiLauncher" -srcfolder dmgroot -ov -format UDZO \
"dist/BiLauncher-$VERSION-macOS-universal.dmg"
rm -rf dmgroot
ls -lh dist
echo "sums=$(cd dist && shasum -a 256 * | base64 | tr -d '\n')" >> "$GITHUB_OUTPUT"
- name: Загрузка в релиз
if: env.RELEASE_ID != ''
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
for f in dist/*; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$RELEASE_ID/assets?name=$(basename "$f")" > /dev/null
echo "Загружен $(basename "$f")"
done
- name: Артефакты прогона
if: env.RELEASE_ID == ''
uses: actions/upload-artifact@v4
with:
name: macos
path: dist/
publish:
needs: [create-release, build-linux, build-windows, build-macos]
if: needs.create-release.outputs.release_id != ''
runs-on: linux
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
TOKEN: ${{ secrets.RELEASE_TOKEN }}
steps:
- name: SHA256SUMS.txt и публикация
run: |
{
echo "${{ needs.build-windows.outputs.sums }}" | base64 -d
echo "${{ needs.build-linux.outputs.sums }}" | base64 -d
echo "${{ needs.build-macos.outputs.sums }}" | base64 -d
} | tr -d '\r' | sed '/^$/d' > SHA256SUMS.txt
cat SHA256SUMS.txt
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@SHA256SUMS.txt" "$API/releases/$RELEASE_ID/assets?name=SHA256SUMS.txt" > /dev/null
curl -fsS -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"draft": false}' "$API/releases/$RELEASE_ID" > /dev/null
echo "Релиз опубликован"
+183
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
# ---> 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
CMakeLists.txt.user*
stage/
dist/
build-tests/
build-*/
+638
View File
@@ -0,0 +1,638 @@
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: 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: "#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()
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
# Changelog
## [1.0.0] — 2026-10-07
### Добавлено
- Настройки запуска Minecraft
- Автоматическая загрузка версий java из предложенного списка
- Система хранения сборок, автоматического скачивания версий игры из меню и модлоадеры к каждой версии
- Сохранение сборок в zip формате и отображение в списке
- Система сезонных сборок: полная автоматизация в один клик
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
### Исправлено
- UI фиксы
+253
View File
@@ -0,0 +1,253 @@
cmake_minimum_required(VERSION 3.20)
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
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)
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
# который ставится ниже вместе с темой hicolor.
#
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
if(WIN32)
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}
)
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
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
)
# OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в
# ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым
# файлом (у него нет расширения), и линковка падает с «Is a directory».
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
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}
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
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_compile_definitions(BiLauncher PRIVATE
LAUNCHER_VERSION="${PROJECT_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})
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
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})
# Автотесты: собираются только по запросу и в установку не попадают.
# Код лаунчера при этом не меняется — тесты компилируют те же исходники
# в отдельную статическую библиотеку, см. tests/CMakeLists.txt и doc/testing.md.
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
if(BILAUNCHER_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
+70
View File
@@ -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
}
}
}
+501
View File
@@ -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()
}
+201
View File
@@ -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.
+37
View File
@@ -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
}
}
}
+148
View File
@@ -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)
}
}
+1425
View File
File diff suppressed because it is too large. Load diff
+120
View File
@@ -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()
}
}
}
}
+86
View File
@@ -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()
}
}
}
+34
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+313
View File
@@ -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: seasons.backend.seasonalInstalling
? Popup.NoAutoClose
: (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()
}
}
}
}
+594
View File
@@ -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()
}
+294
View File
@@ -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, {});
});
});
}
+70
View File
@@ -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;
};
+338
View File
@@ -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, {});
}
+38
View File
@@ -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 &currentPath);
void finished(bool ok, const QString &error);
void packEntries(const QStringList &entries);
private:
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
QAtomicInt m_cancel;
};
+590
View File
@@ -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 &currentPath)
{
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 &note,
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));
}
}
+100
View File
@@ -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 &note,
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 &currentPath);
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;
};
+107
View File
@@ -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)"
+143
View File
@@ -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 на месте."
+177
View File
@@ -0,0 +1,177 @@
# Сборка, упаковка и релизы
## Что нужно
- CMake 3.20+, компилятор C++17 и C (C нужен для zlib на Linux).
- Qt 6.8 или новее (`qt_standard_project_setup(REQUIRES 6.8)`). CI собирает на Qt 6.11.1.
- Обязательные модули: `Quick`, `QuickControls2`, `Qml`, `Svg`, `Core`, `CorePrivate`, `Gui`,
`Network`.
- Необязательный: `WebEngineQuick`, для входа через Microsoft.
`CorePrivate` нужен ради `QZipReader` и `QZipWriter`: ими распаковываются нативные библиотеки
LWJGL, архивы Temurin и архивы сборок. Приватные модули Qt не гарантируют совместимость между
версиями, поэтому проект привязан к конкретной версии Qt. Предупреждение об этом отключено
(`QT_NO_PRIVATE_MODULE_WARNING`).
### Qt WebEngine
Модуль ставится в MaintenanceTool отдельной галочкой и тянет за собой Qt WebChannel и Qt
Positioning. Чтобы он работал, нужны все три.
Если сделать WebEngine обязательным, у любого, кто его не поставил, проект вообще не соберётся,
хотя офлайн-вход и Ely.by без него работают. Поэтому:
- модуль найден — определяется `LAUNCHER_HAS_WEBENGINE`, `MicrosoftLoginDialog.qml` попадает в
модуль, `backend.microsoftAvailable` равно `true`;
- не найден — CMake выдаёт предупреждение со списком компонентов, которые надо доставить, а
лаунчер собирается без входа через Microsoft.
Релизные сборки в CI всегда с WebEngine: скрипты подготовки раннеров проверяют, что он есть.
## Локальная сборка
```bash
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos
```
```bash
cmake --build build
```
Из Qt Creator проект открывается как обычный CMake-проект.
Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в
`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза.
## Что собирается
**`BiLauncher`** — исполняемый файл и QML-модуль с URI `BiLauncher`. В модуль входят все `.cpp`,
`.qml`, картинки из `images/`, `i18n/translations.json`, а также `CHANGELOG.md`, `LICENSE` и
`README.md`. Последние три сейчас никто из кода не читает, они просто лежат в ресурсах.
QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIRECTORY`). Если оставить по
умолчанию, на Linux папка `BiLauncher` совпадёт по имени с исполняемым файлом (у него нет
расширения), и линковка упадёт с «Is a directory».
**`launcher_zlib_reference`** — только на Linux. Это обычный zlib 1.3.1 из каталога `zlib/`,
выходной файл `libz.so.1` в `<build>/zlib/`. С ним лаунчер не линкуется. Библиотека нужна только
установщикам Forge и NeoForge на системах с zlib-ng, см. [zlibreference.h](cpp/zlibreference.md).
Исходники zlib — чужой код из upstream, документацией он не покрыт.
## Иконки
Все размеры собираются из одного файла `icons/master/icon_master.png` (1024×1024, рисунок до краёв
холста) скриптом `icons/generate_icons.sh`. Для него нужны ImageMagick и `iconutil`, то есть
запускается он на macOS. Правки вносятся в мастер, после чего скрипт перезапускается. Отдельные
размеры руками не правятся.
Системы по-разному добавляют поля вокруг иконки, поэтому один и тот же рисунок кладётся
по-разному:
- **macOS** сама поля не добавляет. По сетке Apple рисунок занимает 824 из 1024 точек, углы
скруглены радиусом 185.4. Результат — `icon.icns`, кладётся в `Contents/Resources` бандла.
- **Windows** ждёт картинку на весь холст. `icon.ico` содержит кадры 16–256, включая 20, 40 и 96
для масштабов 125/150/200%. Без явного списка ImageMagick положил бы в `.ico` только 1024×1024,
и система размывала бы иконку при уменьшении. Вкомпилирована через `icon.rc.in`, где заодно
ресурс версии.
- **Linux**: PNG 16–512 ставятся в тему `hicolor` под именем `bilauncher.png`, плюс
`bilauncher.desktop`. Окружения рабочего стола ищут иконку по имени из `Icon=`, а не по имени
файла.
Иконка окна задаётся в `main.cpp` из ресурса `:/icons/` (на всех системах, кроме macOS).
## Установка (`cmake --install`)
- исполняемый файл в `bin/` (на macOS — `BiLauncher.app`);
- `qt_generate_deploy_qml_app_script` раскладывает рядом Qt, плагины и QML-модули
(windeployqt/macdeployqt под капотом; на macOS с `-hardened-runtime`);
- `LICENSE`, `CHANGELOG.md`, `packaging/THIRD_PARTY_LICENSES.txt` в `share/doc/BiLauncher/` (кроме
macOS);
- на Linux ещё `libz.so.1` в `lib/BiLauncher/`, иконки и `.desktop`.
На Linux ставьте с `-DCMAKE_INSTALL_LIBDIR=lib`. Лаунчер ищет эталонный zlib в
`<bin>/../lib/BiLauncher`, а на Ubuntu по умолчанию `LIBDIR` был бы `lib/x86_64-linux-gnu`.
## Релизы
Релизы собирает Gitea Actions, `.gitea/workflows/release.yml`.
### Как выпустить версию
0. Запустить workflow `tests` вручную (Run workflow) и дождаться зелёного прогона на всех трёх
системах, включая дымовую проверку установленного дерева. `release.yml` от тестов не зависит,
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает
релиз pre-release).
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
### Что происходит
1. **create-release** (Linux) — сверяет тег с версией, вырезает раздел из `CHANGELOG.md` и
создаёт **черновик** релиза.
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
идёт с `-j 8`: на ВМ 6 ГБ памяти, и без ограничения компиляторы съедают её всю.
- **Windows**: `BiLauncher-<v>-win64-setup.exe` (Inno Setup) и
`BiLauncher-<v>-win64-portable.zip`. В portable нет `vc_redist`, среда выполнения MSVC должна
уже стоять в системе. Сборка идёт в `C:\r\...`, потому что дерево WebEngine легко выходит за
`MAX_PATH`. Zip делается системным `tar.exe` по полному пути: GNU tar из Git, который стоит в
`PATH` раньше, zip не умеет и принимает `C:` за имя удалённого хоста.
- **macOS**: `BiLauncher-<v>-macOS-universal.dmg` (arm64 + x86_64). Подпись ad-hoc: без неё
приложение вообще не запустится на Apple Silicon. Developer ID нет, поэтому Gatekeeper при
первом запуске всё равно спросит подтверждение.
3. **publish** собирает `SHA256SUMS.txt` из хешей всех трёх сборок, прикладывает его и снимает с
релиза флаг черновика.
Если хоть одна сборка упала, релиз остаётся черновиком и пользователи его не видят.
Ручной запуск (`workflow_dispatch`) делает те же сборки, но релиз не создаёт: файлы остаются
артефактами прогона.
Секрет `RELEASE_TOKEN` — персональный токен Gitea с правом `repository: write`.
### Установщик Windows
`packaging/windows/BiLauncher.iss` (Inno Setup 6). Ставит в `Program Files\BiLauncher`, разрешает
выбрать установку только для текущего пользователя, создаёт ярлык в меню «Пуск» и, по желанию, на
рабочем столе, тихо ставит `vc_redist`, если он есть в дереве. Языки установщика — русский и
английский.
`AppId` менять нельзя: по нему новая версия находит и обновляет старую установку.
## Раннеры
Раннеры работают в режиме host, то есть прямо на машине, без Docker. Метки: `linux`, `windows`,
`macos`.
Пути к Qt раннер передаёт сборке через переменные окружения в своём `config.yaml`:
- `QT_ROOT` — kit Qt (например, `~/Qt/6.11.1/gcc_64`);
- `QT_TOOLS` — папка `Tools` из Qt Online Installer, оттуда CMake и Ninja;
- `ISCC_DIR` — только Windows, папка Inno Setup.
Qt с WebEngine, CMake и Ninja ставятся заранее через Qt Online Installer. Скрипты подготовки
докачивают остальное и регистрируют раннер. Повторный запуск безопасен.
**Linux** (Ubuntu 22.04), `ci/setup-runner-linux.sh [токен]`. Запускать от пользователя, которому
принадлежит Qt, нужен sudo без пароля. Ставит компилятор, библиотеки для сборки и деплоя (xcb,
OpenGL, NSS и т. д.), `libfuse2` для appimagetool, Node.js (нужен `actions/checkout`), appimagetool,
gitea-runner и systemd-сервис `gitea-runner`.
**Windows**, `ci/setup-runner-windows.ps1 [-Token ...]` из PowerShell от администратора. Ставит
VS 2022 Build Tools (это 20–40 минут), Git, Node.js, Inno Setup, включает длинные пути в системе и
в git, регистрирует gitea-runner. Раннер запускается задачей планировщика при входе пользователя,
поэтому пароль учётной записи не нужен, но без входа в систему раннер не поднимется. Чтобы он
стартовал при загрузке машины, задачу нужно пересоздать с `/sc onstart /rp *`.
**macOS** — скрипта подготовки нет, раннер настроен вручную.
Те же раннеры гоняют автотесты (`.gitea/workflows/tests.yml`): Linux — на каждый push, все три
системы — ночью и по кнопке. Подробности — [testing.md](testing.md).
---
При создании этого документа использовался ИИ.
+88
View File
@@ -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-потоке. Если сервис удалить во
время запроса, менеджер сети уйдёт вместе с ним, и колбэк не вызовется вообще. Бэкенд к тому же
проверяет в каждом колбэке, не отменили ли запуск, пока шёл запрос.
Повторных попыток при сетевых ошибках нет.
---
При создании этого документа использовался ИИ.
+55
View File
@@ -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` и не даёт отменить шаги после архивации.
---
При создании этого документа использовался ИИ.
+105
View File
@@ -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 секунд.
---
При создании этого документа использовался ИИ.
+107
View File
@@ -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, если его там ещё нет.
---
При создании этого документа использовался ИИ.
+61
View File
@@ -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` приходит, когда вернутся все активные запросы.
---
При создании этого документа использовался ИИ.
+68
View File
@@ -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()`.
---
При создании этого документа использовался ИИ.
+363
View File
@@ -0,0 +1,363 @@
# 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. `ensureDataFiles()` создаёт недостающие файлы.
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
7. Подписка на сигналы сервисов.
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
моменту 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)
}
}
```
---
При создании этого документа использовался ИИ.
+173
View File
@@ -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`.
Ключи, на которые никто не ссылается, выводятся предупреждением.
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
будет поправить.
---
При создании этого документа использовался ИИ.
+78
View File
@@ -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` придёт после его отчёта.
Деструктор убивает процесс установщика, если тот ещё работает.
---
При создании этого документа использовался ИИ.
+71
View File
@@ -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.
---
При создании этого документа использовался ИИ.
+81
View File
@@ -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-потоке. Шаги вызываются из колбэков предыдущих. Если удалить
сервис посреди цепочки, она оборвётся вместе с менеджером сети, и колбэк не вызовется.
---
При создании этого документа использовался ИИ.
+94
View File
@@ -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()`.
---
При создании этого документа использовался ИИ.
+32
View File
@@ -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` приходит
после его завершения.
---
При создании этого документа использовался ИИ.
+76
View File
@@ -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()` отвечает отказом.
Деструктор блокирует сигналы и отменяет установку.
---
При создании этого документа использовался ИИ.
+67
View File
@@ -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.
---
При создании этого документа использовался ИИ.
+60
View File
@@ -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` из той же папки, если он там есть. Тогда при запуске
игры не появляется консольное окно. На других системах возвращает путь без изменений.
---
При создании этого документа использовался ИИ.
+94
View File
@@ -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 потянет.
---
При создании этого документа использовался ИИ.
+78
View File
@@ -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`.
---
При создании этого документа использовался ИИ.
+36
View File
@@ -0,0 +1,36 @@
# 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`
(берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
5. **`setDesktopFileName("bilauncher")`.** По этому имени окружения рабочего стола в Linux
связывают окно с `bilauncher.desktop` и показывают правильную иконку в панели.
6. **Тексты.** `Localization::instance().load()` читает `i18n/translations.json` и язык из
`settings.json`. Это нужно сделать до создания движка: язык должен быть известен до первой
привязки. Если каталог не загрузился, `main()` возвращает `-1` (см.
[Localization](Localization.md)).
7. **Стиль `Basic`.** Окна оформлены вручную, а нативный стиль Windows игнорирует свои
`contentItem` и `background` и засыпает журнал предупреждениями.
8. **Движок.** `QQmlApplicationEngine`. Если корневой объект не создался, приложение
завершается с `-1`. Соединение отложенное (`Qt::QueuedConnection`), чтобы выход произошёл уже в
цикле событий, а не посреди загрузки.
9. `engine.loadFromModule("BiLauncher", "Main")` — загрузка по URI модуля и имени типа.
10. `app.exec()`.
Аргументы командной строки не разбираются. Контекстных свойств нет: QML общается с C++ только
через зарегистрированные типы `LauncherBackend` и `Loc`.
---
При создании этого документа использовался ИИ.
+103
View File
@@ -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/`. Если папки
нет, это не ошибка.
---
При создании этого документа использовался ИИ.
+44
View File
@@ -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 |
---
При создании этого документа использовался ИИ.
+63
View File
@@ -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, &note)
- zlib-ng в системе нет — подменять нечего, возвращает `true`, `note` пуст.
- zlib-ng есть, эталон найден — дописывает его путь в начало `LD_PRELOAD` (чужое содержимое
переменной сохраняется), возвращает `true`, в `note` пишет, что подставлено.
- zlib-ng есть, эталона нет — возвращает `false`, в `note` пишет предупреждение.
[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()`
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая
подсказка про zlib-ng.
---
При создании этого документа использовался ИИ.
+147
View File
@@ -0,0 +1,147 @@
# 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) | файловые операции в отдельном потоке |
| [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 |
---
При создании этого документа использовался ИИ.
+95
View File
@@ -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` и обновляет строку комплектности: установка
могла закончиться, пока окно открыто.
---
При создании этого документа использовался ИИ.
+16
View File
@@ -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).
---
При создании этого документа использовался ИИ.
+49
View File
@@ -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`.
---
При создании этого документа использовался ИИ.
+17
View File
@@ -0,0 +1,17 @@
# LabelledField.qml
Подпись и под ней поле ввода. Используется в настройках и в карточке сборки.
| | |
|---|---|
| `label` | подпись |
| `placeholder` | текст-подсказка в пустом поле |
| `text` | алиас текста поля |
| `validator` | алиас валидатора поля |
| `editingFinished()` | правка закончена: поле потеряло фокус или нажат Enter |
Ширина поля равна ширине компонента, высота 32 px.
---
При создании этого документа использовался ИИ.
+53
View File
@@ -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()` заменит её на самую новую.
---
При создании этого документа использовался ИИ.
+132
View File
@@ -0,0 +1,132 @@
# 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`), и почти у каждого контрола здесь своё
оформление.
## Раскладка
Всё стоит на якорях вокруг кнопки запуска: список профилей слева сверху от неё, кнопка активной
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
правом нижнем углу, потому что остальные углы заняты.
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
наоборот. Панель смены сборки отменить нельзя.
У заголовков и подвалов всех диалогов корнем служит `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`. Кнопка активной сборки выключена во время
игры и смены сборки.
## Профили
Выпадающий список профилей переделан целиком. Наверху всплывающей панели закреплена кнопка
«+ Добавить профиль», под ней список. У каждой строки при наведении появляются карандаш (открывает
редактирование через `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`, литералов в разметке нет.
---
При создании этого документа использовался ИИ.
+46
View File
@@ -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` в адресе входа.
---
При создании этого документа использовался ИИ.
+24
View File
@@ -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()`, у смены сборки крестика нет.
---
При создании этого документа использовался ИИ.
+56
View File
@@ -0,0 +1,56 @@
# 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)`). Свежий кэш отвечает
без сети.
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
главного окна, и случайный щелчок мимо окна не должен её закрыть.
---
При создании этого документа использовался ИИ.
+59
View File
@@ -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()`. Если удалили выбранный профиль модлоадера,
он пропадает из каталога совсем (он был в нём только потому, что установлен), и выбор
сбрасывается.
---
При создании этого документа использовался ИИ.
+170
View File
@@ -0,0 +1,170 @@
# Автотесты
Тесты лежат в `tests/` и собираются только по опции `BILAUNCHER_BUILD_TESTS`. В обычную сборку и в
релизы они не попадают, код лаунчера ради них не менялся.
## Как запустить
```bash
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos
```
```bash
cmake --build build-tests
```
```bash
ctest --test-dir build-tests --output-on-failure -j 6
```
Отдельный слой — по метке: `ctest --test-dir build-tests -L unit` (ещё `net`, `backend`, `qml`).
Отдельный тест — напрямую, с фильтром по функции:
```bash
build-tests/tests/net/tst_versioninstaller installsModernVersionCompletely
```
Статические проверки (переводы, qmllint, раздел CHANGELOG для текущей версии):
```bash
python3 tests/scripts/static_checks.py build-tests
```
В Qt Creator проект с тестами открывается как обычно: включите `BILAUNCHER_BUILD_TESTS` в настройках
CMake, тесты появятся в панели «Тесты».
## Что покрыто
| Метка | Тест | Что проверяет |
|-------|------|---------------|
| unit | `tst_launcherpaths` | раскладка папки лаунчера, `ensureRootExists` и её отказ, если на месте папки файл |
| unit | `tst_javaruntime` | ключи видов Java, `JavaRuntimeStore`, какая Java нужна какой версии игры |
| unit | `tst_modloader` | ключи и названия модлоадеров |
| unit | `tst_localization` | переключение языков, запасной язык, отсутствующий ключ, язык из `settings.json` |
| unit | `tst_minecraftversion` | правила Mojang, загрузка json версии, `inheritsFrom`, нативы, удаление версий |
| unit | `tst_authoffline` | офлайн-UUID как у сервера в offline-mode |
| unit | `tst_msaredirect` | разбор адреса возврата после входа Microsoft |
| unit | `tst_gamelauncher` | командная строка игры, нативы, запуск, падение и остановка процесса |
| unit | `tst_javalocator` | версия java по `-version`, выбор подходящей, поиск в папке лаунчера |
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
| net | `tst_modloaderversionservice` | Fabric, Quilt, Forge (с рекомендованными), NeoForge обе схемы |
| net | `tst_javaruntimeservice` | Adoptium и Mojang, частичные отказы, кеш, запасной x64 на ARM |
| net | `tst_authservice` | Ely.by: пароль, 2FA, продление токена, authlib-injector |
| net | `tst_msaauthservice` | вся цепочка Microsoft → Xbox → XSTS → Minecraft, коды ошибок XSTS |
| net | `tst_versioninstaller` | установка версии целиком, повторы, контрольные суммы, отмена, очередь |
| net | `tst_seasonalpackdownloader` | загрузка пака: sha256, обрыв, отмена |
| net | `tst_javainstaller` | Temurin zip и tar.gz, дерево Mojang, zip-slip, отмена |
| net | `tst_modloaderinstaller` | Fabric по сети, Forge через подставную java, откат после сбоя |
| backend | `tst_launcherbackend` | всё, что зовёт QML: профили, сборки, настройки, миграция, битые файлы, каталог версий, запуск офлайн / Ely.by с 2FA / Microsoft, Java, сезонная сборка от каталога до раскатки обновления |
| qml | `tst_qml` | Qt Quick Test: `DarkCombo`, `LabelledField`, `ProgressPanel`, `LoaderRow`, диалоги с настоящим `LauncherBackend` |
| qml | `tst_qml_smoke` | `Main.qml` поднимается, как в `main.cpp`, без единого предупреждения QML, в том числе после смены языка |
Ночью и перед релизом ещё собирается Release, ставится через `cmake --install` и запускается на
10 секунд (`tests/scripts/smoke_installed.sh` / `.ps1`). Это ловит то, чего не видят тесты:
недоложенные деплоем модули Qt и плагины, ссылки на библиотеки вне дерева, неподписанный бандл.
## Как тесты изолированы
**Файлы.** `LauncherPaths` строит пути от домашней папки. Общий `main` тестов
(`tests/common/testenv.h`) до создания приложения направляет `BILAUNCHER_HOME` (а заодно `HOME`
и `USERPROFILE`) на временную папку, и каждый процесс работает в своей, на всех ОС, параллельно.
`BILAUNCHER_HOME` нужна ради Windows: там `QDir::homePath()` берёт профиль у системы и `HOME`
с `USERPROFILE` не читает. Дочерние процессы наследуют переменную. Если папка лаунчера или папка
игры по умолчанию всё же оказалась вне песочницы, процесс сразу завершается с кодом 2 и ничего не
трогает. Дымовая проверка установленной сборки на Windows (`smoke_installed.ps1`) тоже запускает
лаунчер с `BILAUNCHER_HOME` на временную папку.
**Сеть.** Адреса серверов в сервисах зашиты константами, а свои `QNetworkAccessManager` они
создают сами. Зато прокси и SSL-настройки у Qt общие на процесс, и код лаунчера их не трогает.
`FakeInternet` (`tests/common/fakeinternet.h`) ставит прокси на локальный порт и отключает
проверку сертификата. Каждое https-соединение приходит к нему как `CONNECT host:443`, переводится
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
сообщением, а в CI на Linux (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
сами ресурсы, конфиг логирования.
**Java.** `fake_java` (`tests/fake_java/main.cpp`, без Qt) копируется в нужную папку как
`bin/java` (`java.exe`/`javaw.exe`). На `-version` она отвечает версией из `fake-java.cfg` рядом с
собой, а при обычном запуске пишет аргументы в файл, печатает заданные строки, создаёт файлы
(так изображается установщик Forge) и выходит с нужным кодом либо падает.
**Код лаунчера.** `tests/CMakeLists.txt` собирает все `.cpp` из корня, кроме `main.cpp`, в
статическую библиотеку-QML-модуль `bilauncher_testlib`. Новый файл попадёт в тесты сам. QML-файлы
берутся тоже все, ресурсы — из строк `RESOURCES` корневого `CMakeLists.txt`. Тестовая библиотека
собирается без Qt WebEngine, так что `backend.microsoftAvailable` в тестах всегда `false`.
## Найденные проблемы (QEXPECT_FAIL)
Тест, который описывает правильное поведение, но пока падает, помечен `QEXPECT_FAIL`. В отчёте он
выглядит как `XFAIL` и прогон не валит. Когда ошибку исправят, тест станет `XPASS` и прогон упадёт:
тогда пометку нужно снять.
| Тест | Проблема |
|------|----------|
| `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» |
| `tst_modloaderversionservice::neoForgeLegacyArtifactArrivesFirst` | если ответ старого артефакта `net.neoforged:forge` приходит раньше основного, основной затирает слитые записи (`state->merged = parsed`), и версии NeoForge для 1.20.1 пропадают |
| `tst_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` |
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
Ещё одна особенность, которую тесты обходят: синглтон `Localization` запоминает JS-движок, который
его создал. Второй `QQmlApplicationEngine` после уничтожения первого оставляет этот указатель
висячим, поэтому дымовой тест держит один движок на весь прогон, как и приложение.
## CI: `.gitea/workflows/tests.yml`
Тесты идут на тех же host-раннерах, что собирают релизы. Отдельные машины и Docker не нужны:
Qt, CMake и Ninja там уже есть, пути берутся из `QT_ROOT` и `QT_TOOLS` в `config.yaml` раннера.
| Событие | Что запускается |
|---------|-----------------|
| push в любую ветку, pull request | Linux: сборка с тестами, статические проверки, все тесты |
| ночью (02:00 UTC) и вручную (Run workflow) | Linux, Windows и macOS, плюс дымовая проверка установленного дерева на всех трёх |
Windows и macOS на каждый коммит не запускаются: вместимость у раннеров 1, и часовая сборка с
WebEngine задерживала бы релизы. Перед выпуском версии запустите workflow вручную и дождитесь
зелёного прогона. `release.yml` от тестов не зависит.
Новый push в ту же ветку отменяет незаконченный прогон (`concurrency`). Отчёт JUnit
(`junit.xml`), лог qmllint и полный лог ctest прикладываются к прогону как артефакты.
На Linux-раннере полезен `ccache`: с ним повторная сборка занимает минуты. `ci/setup-runner-linux.sh`
ставит его вместе с `xvfb`, который нужен дымовой проверке, если деплой Qt не положил плагин
`offscreen`.
**qmllint.** В коде уже есть 53 предупреждения, а код ради тестов не правится. Поэтому проверка
работает как храповик: она падает, только если предупреждений стало больше числа в
`tests/qmllint-baseline.txt`. Если часть исправили, опустите число.
## Как добавить тест
1. Создайте `tests/<слой>/tst_<имя>.cpp` с классом на QtTest и в конце файла напишите
`BILAUNCHER_TEST_MAIN(Класс)` (или `BILAUNCHER_GUI_TEST_MAIN`, если нужен `QGuiApplication`).
Затем `#include "tst_<имя>.moc"`.
2. Добавьте имя в список в `tests/<слой>/CMakeLists.txt`.
3. В `init()` вызывайте `TestEnv::resetLauncherRoot()`, папку игры берите из
`TestEnv::freshDir("game")`.
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
`hang`, `drop`, `truncateTo`.
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
`Fixtures::readTemplate()`.
У moc есть ограничение: сырые строки `R"(...)"` и длинные вложенные инициализаторы `{{...}}`,
разбитые на строки, внутри файла с `Q_OBJECT` сбивают его разбор («Not a signal or slot
declaration» или пустой `.moc`). Такие данные выносите в фикстуры или собирайте по шагам.
---
При создании этого документа использовался ИИ.
+372
View File
@@ -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();
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#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;
};
File diff suppressed because it is too large. Load diff
+11
View File
@@ -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
+81
View File
@@ -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 "готово"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

+31
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

+22
View File
@@ -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

+22
View File
@@ -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

+22
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+26
View File
@@ -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

+26
View File
@@ -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: #637a10;
}
</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

+26
View File
@@ -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-1" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
<g>
<path class="cls-2" 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-2" 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-2" 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

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

+27
View File
@@ -0,0 +1,27 @@
<?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 335 170">
<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">
<rect class="cls-2" width="335" height="170" rx="10.7" ry="10.7"/>
<g>
<path class="cls-1" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
<path class="cls-1" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
<path class="cls-1" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
<path class="cls-1" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+27
View File
@@ -0,0 +1,27 @@
<?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 335 170">
<defs>
<style>
.cls-1 {
fill: #e6e6e6;
}
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #91b315;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<rect class="cls-2" width="335" height="170" rx="10.7" ry="10.7"/>
<g>
<path class="cls-1" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
<path class="cls-1" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
<path class="cls-1" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
<path class="cls-1" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more