Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7adcd780b0 | ||
|
|
2cb4249869 | ||
|
|
2528b6d587 | ||
|
|
215ba4d253 | ||
|
|
4b56720e64 | ||
|
|
324896a0ea | ||
|
|
cf83a78b1d | ||
|
|
06bd1b4026 | ||
|
|
866900c606 | ||
|
|
2c16647f65 | ||
|
|
3001813d02 | ||
|
|
4fa796ea27 | ||
|
|
1250af21a1 | ||
|
|
a171e63efb |
No files matched your search
@@ -0,0 +1,63 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
@@ -1,255 +0,0 @@
|
||||
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 "Релиз опубликован"
|
||||
@@ -1,183 +0,0 @@
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: linux
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Сборка лаунчера и тестов
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
launcher=""
|
||||
if command -v ccache >/dev/null; then
|
||||
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
|
||||
fi
|
||||
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
|
||||
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT" $launcher
|
||||
cmake --build build-tests -j 8
|
||||
|
||||
- name: Статические проверки (переводы, qmllint, CHANGELOG)
|
||||
run: CMAKE="$QT_TOOLS/CMake/bin/cmake" python3 tests/scripts/static_checks.py build-tests
|
||||
|
||||
- name: Тесты
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/bin:$PATH"
|
||||
ctest --test-dir build-tests --output-on-failure -j 4 \
|
||||
--output-junit "$PWD/build-tests/junit.xml"
|
||||
|
||||
- name: Отчёты
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tests-linux
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
build-tests/junit.xml
|
||||
build-tests/qmllint.log
|
||||
build-tests/Testing/Temporary/LastTest.log
|
||||
|
||||
windows:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
runs-on: windows
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Сборка и тесты
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# Короткий путь: дерево WebEngine легко вылезает за MAX_PATH (см. release.yml).
|
||||
$build = "C:\r\bl-tests"
|
||||
Remove-Item -Recurse -Force $build -ErrorAction SilentlyContinue
|
||||
|
||||
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
|
||||
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||
|
||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBILAUNCHER_BUILD_TESTS=ON "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||
cmake --build $build
|
||||
if ($LASTEXITCODE) { throw "cmake build" }
|
||||
|
||||
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
||||
$code = $LASTEXITCODE
|
||||
New-Item -ItemType Directory -Force test-results | Out-Null
|
||||
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
|
||||
if ($code) { throw "ctest" }
|
||||
|
||||
- name: Отчёты
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tests-windows
|
||||
if-no-files-found: ignore
|
||||
path: test-results/
|
||||
|
||||
macos:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
runs-on: macos
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Сборка и тесты
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
|
||||
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT"
|
||||
cmake --build build-tests
|
||||
ctest --test-dir build-tests --output-on-failure -j 4 \
|
||||
--output-junit "$PWD/build-tests/junit.xml"
|
||||
|
||||
- name: Отчёты
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tests-macos
|
||||
if-no-files-found: ignore
|
||||
path: build-tests/junit.xml
|
||||
|
||||
package-smoke-linux:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: linux
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Release-сборка, установка, запуск
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build-smoke smoke-stage
|
||||
launcher=""
|
||||
if command -v ccache >/dev/null; then
|
||||
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
|
||||
fi
|
||||
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib $launcher
|
||||
cmake --build build-smoke -j 8
|
||||
cmake --install build-smoke --prefix "$PWD/smoke-stage/usr"
|
||||
bash tests/scripts/smoke_installed.sh smoke-stage/usr
|
||||
|
||||
package-smoke-windows:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Release-сборка, установка, запуск
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$build = "C:\r\bl-smoke"; $stage = "C:\r\bl-smoke-stage"
|
||||
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
|
||||
|
||||
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
|
||||
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||
|
||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||
cmake --build $build
|
||||
if ($LASTEXITCODE) { throw "cmake build" }
|
||||
cmake --install $build --prefix $stage
|
||||
if ($LASTEXITCODE) { throw "cmake install" }
|
||||
|
||||
& powershell -ExecutionPolicy Bypass -File tests\scripts\smoke_installed.ps1 -Prefix $stage
|
||||
if ($LASTEXITCODE) { throw "smoke" }
|
||||
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
|
||||
|
||||
package-smoke-macos:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Release-сборка, установка, запуск
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build-smoke smoke-stage
|
||||
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QT_ROOT"
|
||||
cmake --build build-smoke
|
||||
cmake --install build-smoke --prefix "$PWD/smoke-stage"
|
||||
bash tests/scripts/smoke_installed.sh smoke-stage
|
||||
@@ -1,68 +1,363 @@
|
||||
# ---> C++
|
||||
# Prerequisites
|
||||
*.d
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# Compiled Object files
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.iobj
|
||||
*.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
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
build/
|
||||
.qtcreator/
|
||||
.DS_Store
|
||||
CMakeLists.txt.user*
|
||||
stage/
|
||||
dist/
|
||||
build-tests/
|
||||
build-*/
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
@@ -1,638 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.0] — 2026-10-07
|
||||
### Добавлено
|
||||
- Настройки запуска Minecraft
|
||||
- Автоматическая загрузка версий java из предложенного списка
|
||||
- Система хранения сборок, автоматического скачивания версий игры из меню и модлоадеры к каждой версии
|
||||
- Сохранение сборок в zip формате и отображение в списке
|
||||
- Система сезонных сборок: полная автоматизация в один клик
|
||||
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
||||
### Исправлено
|
||||
- UI фиксы
|
||||
@@ -1,253 +1,21 @@
|
||||
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.
|
||||
# CMakeList.txt: проект CMake для minecraft-launcher; включите исходный код и определения,
|
||||
# укажите здесь логику для конкретного проекта.
|
||||
#
|
||||
# Растры и .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")
|
||||
cmake_minimum_required (VERSION 3.8)
|
||||
|
||||
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 "")
|
||||
# Включение горячей перезагрузки для компиляторов MSVC, если поддерживается.
|
||||
if (POLICY CMP0141)
|
||||
cmake_policy(SET CMP0141 NEW)
|
||||
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<IF:$<AND:$<C_COMPILER_ID:MSVC>,$<CXX_COMPILER_ID:MSVC>>,$<$<CONFIG:Debug,RelWithDebInfo>:EditAndContinue>,$<$<CONFIG:Debug,RelWithDebInfo>:ProgramDatabase>>")
|
||||
endif()
|
||||
|
||||
qt_add_executable(BiLauncher
|
||||
main.cpp
|
||||
${LAUNCHER_APP_ICON}
|
||||
)
|
||||
project ("minecraft-launcher")
|
||||
|
||||
# Иконка окна выставляется в 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
|
||||
)
|
||||
# Добавьте источник в исполняемый файл этого проекта.
|
||||
add_executable (minecraft-launcher "minecraft-launcher.cpp" "minecraft-launcher.h")
|
||||
|
||||
# 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)
|
||||
if (CMAKE_VERSION VERSION_GREATER 3.12)
|
||||
set_property(TARGET minecraft-launcher PROPERTY CXX_STANDARD 20)
|
||||
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()
|
||||
# TODO: Добавьте тесты и целевые объекты, если это необходимо.
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "windows-base",
|
||||
"hidden": true,
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_C_COMPILER": "cl.exe",
|
||||
"CMAKE_CXX_COMPILER": "cl.exe"
|
||||
},
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x64-debug",
|
||||
"displayName": "x64 Debug",
|
||||
"inherits": "windows-base",
|
||||
"architecture": {
|
||||
"value": "x64",
|
||||
"strategy": "external"
|
||||
},
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x64-release",
|
||||
"displayName": "x64 Release",
|
||||
"inherits": "x64-debug",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x86-debug",
|
||||
"displayName": "x86 Debug",
|
||||
"inherits": "windows-base",
|
||||
"architecture": {
|
||||
"value": "x86",
|
||||
"strategy": "external"
|
||||
},
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x86-release",
|
||||
"displayName": "x86 Release",
|
||||
"inherits": "x86-debug",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linux-debug",
|
||||
"displayName": "Linux Debug",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
},
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Linux"
|
||||
},
|
||||
"vendor": {
|
||||
"microsoft.com/VisualStudioRemoteSettings/CMake/1.0": {
|
||||
"sourceDir": "$env{HOME}/.vs/$ms{projectDirName}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "macos-debug",
|
||||
"displayName": "macOS Debug",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
},
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Darwin"
|
||||
},
|
||||
"vendor": {
|
||||
"microsoft.com/VisualStudioRemoteSettings/CMake/1.0": {
|
||||
"sourceDir": "$env{HOME}/.vs/$ms{projectDirName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2026] [GaleonDev dertiellis@gmail.com]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) [year] [fullname]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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>
|
||||
|
Before Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 225 KiB |
@@ -1,313 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
#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, {});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,338 +0,0 @@
|
||||
#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, {});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAtomicInt>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class BuildArchiveWorker : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit BuildArchiveWorker(QObject *parent = nullptr);
|
||||
void requestCancel() { m_cancel.storeRelaxed(1); }
|
||||
void clearCancel() { m_cancel.storeRelaxed(0); }
|
||||
|
||||
public slots:
|
||||
void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop);
|
||||
|
||||
void clear(const QString &gameDir, const QStringList &keepTop);
|
||||
|
||||
void restore(const QString &zipPath, const QString &gameDir);
|
||||
|
||||
void applyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &removeRelative,
|
||||
const QStringList &forbiddenTop);
|
||||
|
||||
signals:
|
||||
void progress(int done, int total, const QString ¤tPath);
|
||||
void finished(bool ok, const QString &error);
|
||||
void packEntries(const QStringList &entries);
|
||||
|
||||
private:
|
||||
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
|
||||
|
||||
QAtomicInt m_cancel;
|
||||
};
|
||||
@@ -1,590 +0,0 @@
|
||||
#include "buildswitcher.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include "buildarchiveworker.h"
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
#include <QThread>
|
||||
#include <QUuid>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kIndexFile = QStringLiteral("index.json");
|
||||
const QString kStateSwitching = QStringLiteral("switching");
|
||||
const QString kStateApplying = QStringLiteral("applying");
|
||||
const QString kStateIdle = QStringLiteral("idle");
|
||||
constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024;
|
||||
|
||||
QString slug(const QString &name)
|
||||
{
|
||||
QString result;
|
||||
result.reserve(name.size());
|
||||
for (const QChar c : name) {
|
||||
if (c.isLetterOrNumber() || c == u'-' || c == u'_')
|
||||
result.append(c);
|
||||
else if (c.isSpace() || c == u'.')
|
||||
result.append(u'_');
|
||||
}
|
||||
while (result.startsWith(u'_'))
|
||||
result.remove(0, 1);
|
||||
return result.isEmpty() ? QStringLiteral("build") : result.left(48);
|
||||
}
|
||||
|
||||
qint64 directorySize(const QString &path, const QStringList &excludeTop)
|
||||
{
|
||||
qint64 total = 0;
|
||||
const QDir root(path);
|
||||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||
| QDir::Hidden | QDir::System);
|
||||
for (const QFileInfo &entry : top) {
|
||||
if (excludeTop.contains(entry.fileName()) || entry.isSymLink())
|
||||
continue;
|
||||
if (entry.isFile()) {
|
||||
total += entry.size();
|
||||
continue;
|
||||
}
|
||||
QDirIterator it(entry.absoluteFilePath(),
|
||||
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||
QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
total += it.fileInfo().size();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
QStringList BuildSwitcher::sharedTopLevel()
|
||||
{
|
||||
return {QStringLiteral("assets"),
|
||||
QStringLiteral("libraries"),
|
||||
QStringLiteral("versions"),
|
||||
QStringLiteral("launcher_profiles.json"),
|
||||
QStringLiteral("runtime")};
|
||||
}
|
||||
|
||||
BuildSwitcher::BuildSwitcher(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thread(new QThread(this))
|
||||
, m_worker(new BuildArchiveWorker)
|
||||
{
|
||||
m_worker->moveToThread(m_thread);
|
||||
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
|
||||
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
|
||||
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
|
||||
connect(m_worker, &BuildArchiveWorker::packEntries, this,
|
||||
[this](const QStringList &entries) { m_lastPackEntries = entries; });
|
||||
m_thread->start();
|
||||
|
||||
sweepTempArchives();
|
||||
}
|
||||
|
||||
BuildSwitcher::~BuildSwitcher()
|
||||
{
|
||||
m_worker->requestCancel();
|
||||
m_thread->quit();
|
||||
m_thread->wait(5000);
|
||||
}
|
||||
|
||||
double BuildSwitcher::fraction() const
|
||||
{
|
||||
if (m_state == Idle || m_total <= 0)
|
||||
return -1.0;
|
||||
return double(m_done) / double(m_total);
|
||||
}
|
||||
|
||||
QString BuildSwitcher::buildDir(int buildId) const
|
||||
{
|
||||
return LauncherPaths::buildDir(buildId);
|
||||
}
|
||||
|
||||
QString BuildSwitcher::archivePathOf(int buildId) const
|
||||
{
|
||||
const QDir dir(buildDir(buildId));
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
|
||||
for (const QString &name : zips) {
|
||||
if (!name.startsWith(u'.'))
|
||||
return dir.absoluteFilePath(name);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void BuildSwitcher::setStage(const QString &stage)
|
||||
{
|
||||
m_stage = stage;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void BuildSwitcher::switchTo(int fromBuildId,
|
||||
const QString &fromName,
|
||||
int toBuildId,
|
||||
const QString &toName,
|
||||
const QString &gameDir)
|
||||
{
|
||||
if (m_state != Idle) {
|
||||
emit failed(toBuildId, Loc::text("common.error.switchInProgress"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
QString rootError;
|
||||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||
emit failed(toBuildId, rootError, true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_fromId = fromBuildId;
|
||||
m_toId = toBuildId;
|
||||
m_fromName = fromName;
|
||||
m_toName = toName;
|
||||
m_gameDir = gameDir;
|
||||
m_done = 0;
|
||||
m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
|
||||
if (fromBuildId < 0 || !QDir(gameDir).exists()) {
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!QDir().mkpath(buildDir(fromBuildId))) {
|
||||
emit failed(toBuildId,
|
||||
Loc::text("common.error.createFailed")
|
||||
.arg(QDir::toNativeSeparators(buildDir(fromBuildId))),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes;
|
||||
const QStorageInfo storage(LauncherPaths::buildStorageDir());
|
||||
if (storage.isValid() && storage.bytesAvailable() < needed) {
|
||||
emit failed(toBuildId,
|
||||
Loc::text("switch.error.notEnoughSpace")
|
||||
.arg(fromName)
|
||||
.arg(needed / (1024 * 1024)),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-")
|
||||
+ QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip");
|
||||
|
||||
m_state = Archiving;
|
||||
setStage(Loc::text("switch.progress.saving").arg(fromName));
|
||||
requestArchive(gameDir, m_tempZip, sharedTopLevel());
|
||||
}
|
||||
|
||||
void BuildSwitcher::cancel()
|
||||
{
|
||||
if (m_state == Archiving)
|
||||
m_worker->requestCancel();
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestArchive(const QString &gameDir,
|
||||
const QString &tempZip,
|
||||
const QStringList &exclude)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection,
|
||||
Q_ARG(QString, gameDir), Q_ARG(QString, tempZip),
|
||||
Q_ARG(QStringList, exclude));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection,
|
||||
Q_ARG(QString, gameDir), Q_ARG(QStringList, keep));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection,
|
||||
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestApplyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &remove)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "applyPack", Qt::QueuedConnection,
|
||||
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir),
|
||||
Q_ARG(QStringList, remove), Q_ARG(QStringList, sharedTopLevel()));
|
||||
}
|
||||
|
||||
void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath)
|
||||
{
|
||||
m_done = done;
|
||||
m_total = total;
|
||||
m_status = currentPath;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
|
||||
{
|
||||
switch (m_state) {
|
||||
case Idle:
|
||||
return;
|
||||
case Archiving:
|
||||
if (!ok) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(error, /*gameDirIntact=*/true);
|
||||
return;
|
||||
}
|
||||
commitArchive();
|
||||
return;
|
||||
case Clearing:
|
||||
if (!ok) {
|
||||
fail(error, /*gameDirIntact=*/false);
|
||||
return;
|
||||
}
|
||||
beginRestoring();
|
||||
return;
|
||||
case Restoring:
|
||||
if (!ok) {
|
||||
fail(error, /*gameDirIntact=*/false);
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
case Applying:
|
||||
if (!ok) {
|
||||
fail(error, /*gameDirIntact=*/m_done == 0);
|
||||
return;
|
||||
}
|
||||
finishApplying();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void BuildSwitcher::commitArchive()
|
||||
{
|
||||
const QString target = buildDir(m_fromId) + u'/'
|
||||
+ QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))
|
||||
+ u'_' + slug(m_fromName) + QStringLiteral(".zip");
|
||||
|
||||
if (QFile::exists(target) && !QFile::remove(target)) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(Loc::text("switch.error.archiveReplaceFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||
return;
|
||||
}
|
||||
if (!QFile::rename(m_tempZip, target)) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(Loc::text("switch.error.archiveSaveFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||
return;
|
||||
}
|
||||
|
||||
const QDir dir(buildDir(m_fromId));
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
|
||||
for (const QString &name : zips) {
|
||||
if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target)
|
||||
QFile::remove(dir.absoluteFilePath(name));
|
||||
}
|
||||
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateSwitching);
|
||||
index.insert(QStringLiteral("from"), m_fromId);
|
||||
index.insert(QStringLiteral("to"), m_toId);
|
||||
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
builds.insert(QString::number(m_fromId),
|
||||
QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()},
|
||||
{QStringLiteral("name"), m_fromName},
|
||||
{QStringLiteral("savedAt"),
|
||||
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||||
index.insert(QStringLiteral("builds"), builds);
|
||||
writeIndex(index);
|
||||
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginClearing()
|
||||
{
|
||||
setStage(Loc::text("switch.progress.preparing").arg(m_toName));
|
||||
requestClear(m_gameDir, sharedTopLevel());
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginRestoring()
|
||||
{
|
||||
const QString archive = archivePathOf(m_toId);
|
||||
if (archive.isEmpty()) {
|
||||
m_state = Restoring;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
m_state = Restoring;
|
||||
setStage(Loc::text("switch.progress.restoring").arg(m_toName));
|
||||
requestRestore(archive, m_gameDir);
|
||||
}
|
||||
|
||||
void BuildSwitcher::applyPack(int buildId,
|
||||
const QString &buildName,
|
||||
const QString &packZipPath,
|
||||
const QStringList &removeRelative,
|
||||
const QJsonObject ¬e,
|
||||
const QString &gameDir)
|
||||
{
|
||||
if (m_state != Idle) {
|
||||
emit failed(buildId, Loc::text("switch.error.busy"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
QString rootError;
|
||||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||
emit failed(buildId, rootError, true);
|
||||
return;
|
||||
}
|
||||
if (!QFile::exists(packZipPath)) {
|
||||
emit failed(buildId,
|
||||
Loc::text("switch.error.archiveMissing")
|
||||
.arg(QDir::toNativeSeparators(packZipPath)),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_fromId = -1;
|
||||
m_toId = buildId;
|
||||
m_toName = buildName;
|
||||
m_gameDir = gameDir;
|
||||
m_packPath = packZipPath;
|
||||
m_packRemove = removeRelative;
|
||||
m_packNote = note;
|
||||
m_lastPackEntries.clear();
|
||||
m_lastPackNote = {};
|
||||
m_done = 0;
|
||||
m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateApplying);
|
||||
index.insert(QStringLiteral("to"), buildId);
|
||||
index.insert(QStringLiteral("pack"), QFileInfo(packZipPath).fileName());
|
||||
index.insert(QStringLiteral("packName"), buildName);
|
||||
index.insert(QStringLiteral("packRemove"), QJsonArray::fromStringList(removeRelative));
|
||||
index.insert(QStringLiteral("packNote"), note);
|
||||
writeIndex(index);
|
||||
|
||||
m_state = Applying;
|
||||
beginApplying();
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginApplying()
|
||||
{
|
||||
setStage(Loc::text("switch.progress.installing").arg(m_toName));
|
||||
requestApplyPack(m_packPath, m_gameDir, m_packRemove);
|
||||
}
|
||||
|
||||
void BuildSwitcher::finishApplying()
|
||||
{
|
||||
QFile::remove(m_packPath);
|
||||
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateIdle);
|
||||
index.insert(QStringLiteral("active"), m_toId);
|
||||
index.remove(QStringLiteral("from"));
|
||||
index.remove(QStringLiteral("to"));
|
||||
index.remove(QStringLiteral("pack"));
|
||||
index.remove(QStringLiteral("packName"));
|
||||
index.remove(QStringLiteral("packRemove"));
|
||||
index.remove(QStringLiteral("packNote"));
|
||||
writeIndex(index);
|
||||
|
||||
m_lastPackNote = m_packNote;
|
||||
m_packPath.clear();
|
||||
m_packRemove.clear();
|
||||
m_packNote = {};
|
||||
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit finished(toId);
|
||||
}
|
||||
|
||||
void BuildSwitcher::finish()
|
||||
{
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateIdle);
|
||||
index.insert(QStringLiteral("active"), m_toId);
|
||||
index.remove(QStringLiteral("from"));
|
||||
index.remove(QStringLiteral("to"));
|
||||
writeIndex(index);
|
||||
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit finished(toId);
|
||||
}
|
||||
|
||||
void BuildSwitcher::fail(const QString &message, bool gameDirIntact)
|
||||
{
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit failed(toId, message, gameDirIntact);
|
||||
}
|
||||
|
||||
bool BuildSwitcher::forgetBuild(int buildId)
|
||||
{
|
||||
if (buildId < 0)
|
||||
return true;
|
||||
|
||||
QJsonObject index = readIndex();
|
||||
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
builds.remove(QString::number(buildId));
|
||||
index.insert(QStringLiteral("builds"), builds);
|
||||
if (index.value(QStringLiteral("active")).toInt(-1) == buildId)
|
||||
index.remove(QStringLiteral("active"));
|
||||
writeIndex(index);
|
||||
|
||||
QDir dir(buildDir(buildId));
|
||||
return !dir.exists() || dir.removeRecursively();
|
||||
}
|
||||
|
||||
QString BuildSwitcher::interruptedSwitchWarning() const
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
const QString state = index.value(QStringLiteral("state")).toString();
|
||||
|
||||
if (state == kStateApplying) {
|
||||
const QString name = index.value(QStringLiteral("packName")).toString();
|
||||
return Loc::text("switch.status.applyInterrupted")
|
||||
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.nameSuffix").arg(name));
|
||||
}
|
||||
if (state != kStateSwitching)
|
||||
return {};
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
const QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
const QString name = builds.value(QString::number(toId))
|
||||
.toObject()
|
||||
.value(QStringLiteral("name"))
|
||||
.toString();
|
||||
return Loc::text("switch.status.switchInterrupted")
|
||||
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.targetSuffix").arg(name));
|
||||
}
|
||||
|
||||
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
const QString state = index.value(QStringLiteral("state")).toString();
|
||||
|
||||
if (state == kStateApplying) {
|
||||
resumeApplying(index, gameDir);
|
||||
return;
|
||||
}
|
||||
if (state != kStateSwitching)
|
||||
return;
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
if (toId < 0 || m_state != Idle)
|
||||
return;
|
||||
|
||||
m_fromId = -1;
|
||||
m_toId = toId;
|
||||
m_toName = index.value(QStringLiteral("builds"))
|
||||
.toObject()
|
||||
.value(QString::number(toId))
|
||||
.toObject()
|
||||
.value(QStringLiteral("name"))
|
||||
.toString();
|
||||
m_gameDir = gameDir;
|
||||
m_done = m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
}
|
||||
|
||||
void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &gameDir)
|
||||
{
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
if (toId < 0 || m_state != Idle)
|
||||
return;
|
||||
|
||||
const QString packName = index.value(QStringLiteral("pack")).toString();
|
||||
const QString packPath = buildDir(toId) + u'/' + packName;
|
||||
if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) {
|
||||
QJsonObject cleaned = index;
|
||||
cleaned.insert(QStringLiteral("state"), kStateIdle);
|
||||
cleaned.remove(QStringLiteral("pack"));
|
||||
cleaned.remove(QStringLiteral("packName"));
|
||||
cleaned.remove(QStringLiteral("packRemove"));
|
||||
cleaned.remove(QStringLiteral("packNote"));
|
||||
cleaned.remove(QStringLiteral("to"));
|
||||
writeIndex(cleaned);
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList remove;
|
||||
const QJsonArray removeArray = index.value(QStringLiteral("packRemove")).toArray();
|
||||
for (const QJsonValue &value : removeArray)
|
||||
remove << value.toString();
|
||||
|
||||
m_fromId = -1;
|
||||
m_toId = toId;
|
||||
m_toName = index.value(QStringLiteral("packName")).toString();
|
||||
m_gameDir = gameDir;
|
||||
m_packPath = packPath;
|
||||
m_packRemove = remove;
|
||||
m_packNote = index.value(QStringLiteral("packNote")).toObject();
|
||||
m_lastPackEntries.clear();
|
||||
m_lastPackNote = {};
|
||||
m_done = m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
m_state = Applying;
|
||||
beginApplying();
|
||||
}
|
||||
|
||||
QJsonObject BuildSwitcher::readIndex() const
|
||||
{
|
||||
QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
return QJsonDocument::fromJson(file.readAll()).object();
|
||||
}
|
||||
|
||||
void BuildSwitcher::writeIndex(const QJsonObject &index) const
|
||||
{
|
||||
if (!LauncherPaths::ensureRootExists())
|
||||
return;
|
||||
QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return;
|
||||
}
|
||||
file.commit();
|
||||
}
|
||||
|
||||
void BuildSwitcher::sweepTempArchives() const
|
||||
{
|
||||
QDir root(LauncherPaths::buildStorageDir());
|
||||
const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
for (const QString &build : builds) {
|
||||
QDir dir(root.absoluteFilePath(build));
|
||||
const QStringList leftovers =
|
||||
dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden);
|
||||
for (const QString &name : leftovers)
|
||||
QFile::remove(dir.absoluteFilePath(name));
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class QThread;
|
||||
class BuildArchiveWorker;
|
||||
class BuildSwitcher : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit BuildSwitcher(QObject *parent = nullptr);
|
||||
~BuildSwitcher() override;
|
||||
|
||||
bool isRunning() const { return m_state != Idle; }
|
||||
QString stage() const { return m_stage; }
|
||||
QString status() const { return m_status; }
|
||||
double fraction() const;
|
||||
static QStringList sharedTopLevel();
|
||||
|
||||
void switchTo(int fromBuildId,
|
||||
const QString &fromName,
|
||||
int toBuildId,
|
||||
const QString &toName,
|
||||
const QString &gameDir);
|
||||
|
||||
void applyPack(int buildId,
|
||||
const QString &buildName,
|
||||
const QString &packZipPath,
|
||||
const QStringList &removeRelative,
|
||||
const QJsonObject ¬e,
|
||||
const QString &gameDir);
|
||||
|
||||
QStringList lastPackEntries() const { return m_lastPackEntries; }
|
||||
QJsonObject lastPackNote() const { return m_lastPackNote; }
|
||||
|
||||
void cancel();
|
||||
bool forgetBuild(int buildId);
|
||||
bool hasArchive(int buildId) const { return !archivePathOf(buildId).isEmpty(); }
|
||||
QString interruptedSwitchWarning() const;
|
||||
void resumeInterrupted(const QString &gameDir);
|
||||
|
||||
signals:
|
||||
void progressChanged();
|
||||
void finished(int toBuildId);
|
||||
void failed(int toBuildId, const QString &message, bool gameDirIntact);
|
||||
|
||||
private:
|
||||
enum State { Idle, Archiving, Clearing, Restoring, Applying };
|
||||
void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude);
|
||||
void requestClear(const QString &gameDir, const QStringList &keep);
|
||||
void requestRestore(const QString &zipPath, const QString &gameDir);
|
||||
void requestApplyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &remove);
|
||||
|
||||
void onWorkerFinished(bool ok, const QString &error);
|
||||
void onWorkerProgress(int done, int total, const QString ¤tPath);
|
||||
|
||||
void commitArchive();
|
||||
void beginApplying();
|
||||
void finishApplying();
|
||||
void resumeApplying(const QJsonObject &index, const QString &gameDir);
|
||||
void beginClearing();
|
||||
void beginRestoring();
|
||||
void finish();
|
||||
void fail(const QString &message, bool gameDirIntact);
|
||||
|
||||
QString buildDir(int buildId) const;
|
||||
QString archivePathOf(int buildId) const;
|
||||
void setStage(const QString &stage);
|
||||
|
||||
QJsonObject readIndex() const;
|
||||
void writeIndex(const QJsonObject &index) const;
|
||||
void sweepTempArchives() const;
|
||||
|
||||
QThread *m_thread;
|
||||
BuildArchiveWorker *m_worker;
|
||||
|
||||
State m_state = Idle;
|
||||
QString m_stage;
|
||||
QString m_status;
|
||||
int m_done = 0;
|
||||
int m_total = 0;
|
||||
|
||||
int m_fromId = -1;
|
||||
int m_toId = -1;
|
||||
QString m_fromName;
|
||||
QString m_toName;
|
||||
QString m_gameDir;
|
||||
QString m_tempZip;
|
||||
QString m_packPath;
|
||||
QStringList m_packRemove;
|
||||
QJsonObject m_packNote;
|
||||
QStringList m_lastPackEntries;
|
||||
QJsonObject m_lastPackNote;
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Подготовка Linux-раннера Gitea Actions для сборки BiLauncher (Ubuntu 22.04).
|
||||
#
|
||||
# Qt (~/Qt/6.11.1/gcc_64 с WebEngine), CMake и Ninja (~/Qt/Tools) ставятся заранее
|
||||
# через Qt Online Installer — скрипт их не трогает, а докачивает только то, чего Qt
|
||||
# не даёт: компилятор, системные библиотеки для сборки и деплоя, Node.js (для
|
||||
# actions/checkout), appimagetool и сам gitea-runner. Повторный запуск безопасен.
|
||||
#
|
||||
# Запуск от пользователя, которому принадлежит Qt (нужен sudo без пароля):
|
||||
# ./setup-runner-linux.sh [токен регистрации]
|
||||
set -euo pipefail
|
||||
|
||||
TOKEN="${1:-}"
|
||||
INSTANCE="${INSTANCE:-https://projects-live.galeonworx.com}"
|
||||
RUNNER_NAME="${RUNNER_NAME:-linux-vm}"
|
||||
RUNNER_VERSION="${RUNNER_VERSION:-4.1.0}"
|
||||
QT_ROOT="${QT_ROOT:-$HOME/Qt/6.11.1/gcc_64}"
|
||||
QT_TOOLS="${QT_TOOLS:-$HOME/Qt/Tools}"
|
||||
RDIR="$HOME/act_runner"
|
||||
|
||||
[ -d "$QT_ROOT/lib/cmake/Qt6WebEngineQuick" ] || { echo "Нет Qt с WebEngine в $QT_ROOT" >&2; exit 1; }
|
||||
|
||||
# --- Компилятор и библиотеки ---------------------------------------------------
|
||||
# libgl1-mesa-dev — заголовки OpenGL для Qt6::Gui; xcb-* и прочие — то, что
|
||||
# Linux-деплой Qt и плагин xcb ищут при упаковке и запуске; libfuse2 — для
|
||||
# запуска самого appimagetool (он тоже AppImage). ccache ускоряет прогоны
|
||||
# автотестов (.gitea/workflows/tests.yml), xvfb нужен их дымовой проверке
|
||||
# установленного дерева, если деплой Qt не положил плагин offscreen.
|
||||
sudo apt-get update -q
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
|
||||
build-essential patchelf file curl git xz-utils ccache xvfb python3 \
|
||||
libgl1-mesa-dev libfuse2 libfontconfig1 libdbus-1-3 \
|
||||
libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
|
||||
libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xkb1 \
|
||||
libnss3 libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxkbfile1 libxshmfence1 libasound2
|
||||
|
||||
# --- Node.js LTS ---------------------------------------------------------------
|
||||
if ! command -v node >/dev/null; then
|
||||
NODE_VER=$(curl -fsSL https://nodejs.org/dist/index.json \
|
||||
| python3 -c 'import json,sys; print(next(r["version"] for r in json.load(sys.stdin) if r["lts"]))')
|
||||
curl -fsSL "https://nodejs.org/dist/$NODE_VER/node-$NODE_VER-linux-x64.tar.xz" -o /tmp/node.tar.xz
|
||||
sudo rm -rf /opt/node && sudo mkdir -p /opt/node
|
||||
sudo tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1
|
||||
sudo ln -sf /opt/node/bin/node /opt/node/bin/npm /opt/node/bin/npx /usr/local/bin/
|
||||
fi
|
||||
|
||||
# --- appimagetool ----------------------------------------------------------------
|
||||
if ! command -v appimagetool >/dev/null; then
|
||||
sudo curl -fsSL -o /usr/local/bin/appimagetool \
|
||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
sudo chmod +x /usr/local/bin/appimagetool
|
||||
fi
|
||||
|
||||
# --- gitea-runner ------------------------------------------------------------------
|
||||
mkdir -p "$RDIR/work"
|
||||
if [ ! -x "$RDIR/gitea-runner" ]; then
|
||||
curl -fsSL -o "$RDIR/gitea-runner" \
|
||||
"https://gitea.com/gitea/runner/releases/download/v$RUNNER_VERSION/gitea-runner-$RUNNER_VERSION-linux-amd64"
|
||||
chmod +x "$RDIR/gitea-runner"
|
||||
fi
|
||||
|
||||
cat > "$RDIR/config.yaml" <<EOF
|
||||
log:
|
||||
level: info
|
||||
runner:
|
||||
file: $RDIR/.runner
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
envs:
|
||||
QT_ROOT: $QT_ROOT
|
||||
QT_TOOLS: $QT_TOOLS
|
||||
labels:
|
||||
- "linux:host"
|
||||
cache:
|
||||
enabled: false
|
||||
host:
|
||||
workdir_parent: $RDIR/work
|
||||
EOF
|
||||
|
||||
if [ -n "$TOKEN" ] && [ ! -f "$RDIR/.runner" ]; then
|
||||
(cd "$RDIR" && ./gitea-runner register --no-interactive --instance "$INSTANCE" \
|
||||
--token "$TOKEN" --name "$RUNNER_NAME" --labels "linux:host" --config config.yaml)
|
||||
fi
|
||||
|
||||
# --- systemd: раннер работает от владельца Qt, а не от root ------------------------
|
||||
sudo tee /etc/systemd/system/gitea-runner.service >/dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Gitea Actions runner (BiLauncher)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=$USER
|
||||
WorkingDirectory=$RDIR
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=$RDIR/gitea-runner daemon --config $RDIR/config.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now gitea-runner
|
||||
echo "Готово: $(systemctl is-active gitea-runner)"
|
||||
@@ -1,143 +0,0 @@
|
||||
# Подготовка Windows-раннера Gitea Actions для сборки BiLauncher.
|
||||
#
|
||||
# Qt (C:\Qt\6.11.1\msvc2022_64 с WebEngine), CMake и Ninja (C:\Qt\Tools) ставятся
|
||||
# заранее через Qt Online Installer — скрипт их не трогает, а докачивает только то,
|
||||
# чего Qt не даёт: компилятор MSVC, git, Node.js (для actions/checkout), Inno Setup
|
||||
# и сам gitea-runner. Повторный запуск безопасен: уже установленное пропускается.
|
||||
#
|
||||
# Запуск (PowerShell от администратора):
|
||||
# powershell -ExecutionPolicy Bypass -File setup-runner-windows.ps1 [-Token <токен регистрации>]
|
||||
|
||||
param(
|
||||
[string]$Token = "",
|
||||
[string]$Instance = "https://projects-live.galeonworx.com",
|
||||
[string]$RunnerName = "win-vm",
|
||||
[string]$RunnerVersion = "4.1.0",
|
||||
[string]$QtRoot = "C:\Qt\6.11.1\msvc2022_64",
|
||||
[string]$QtTools = "C:\Qt\Tools"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue" # иначе Invoke-WebRequest падает без консоли (по SSH)
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$dl = "C:\ci-downloads"
|
||||
New-Item -ItemType Directory -Force -Path $dl | Out-Null
|
||||
|
||||
function Fetch($url, $name) {
|
||||
$out = Join-Path $dl $name
|
||||
if (-not (Test-Path $out)) {
|
||||
Write-Host "Скачиваю $url"
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $out
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function Run($exe, $argList) {
|
||||
$p = Start-Process -FilePath $exe -ArgumentList $argList -Wait -PassThru -NoNewWindow
|
||||
# 3010 — «успешно, нужна перезагрузка» у установщиков MSI и VS
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw "$exe завершился с кодом $($p.ExitCode)" }
|
||||
}
|
||||
|
||||
# --- Visual Studio 2022 Build Tools (MSVC x64 + Windows SDK) -----------------
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$haveMsvc = (Test-Path $vswhere) -and
|
||||
(& $vswhere -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath)
|
||||
if (-not $haveMsvc) {
|
||||
$vs = Fetch "https://aka.ms/vs/17/release/vs_BuildTools.exe" "vs_BuildTools.exe"
|
||||
Write-Host "Ставлю VS 2022 Build Tools (20–40 минут)…"
|
||||
Run $vs @("--quiet", "--wait", "--norestart", "--nocache",
|
||||
"--add", "Microsoft.VisualStudio.Workload.VCTools", "--includeRecommended")
|
||||
} else { Write-Host "MSVC уже установлен" }
|
||||
|
||||
# --- Git for Windows -----------------------------------------------------------
|
||||
if (-not (Test-Path "C:\Program Files\Git\cmd\git.exe")) {
|
||||
$rel = Invoke-RestMethod "https://api.github.com/repos/git-for-windows/git/releases/latest"
|
||||
$asset = $rel.assets | Where-Object { $_.name -match '^Git-.*-64-bit\.exe$' } | Select-Object -First 1
|
||||
$git = Fetch $asset.browser_download_url $asset.name
|
||||
Run $git @("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-", "/COMPONENTS=gitlfs")
|
||||
} else { Write-Host "Git уже установлен" }
|
||||
|
||||
# --- Node.js LTS (нужен JS-экшенам вроде actions/checkout) ---------------------
|
||||
if (-not (Test-Path "C:\Program Files\nodejs\node.exe")) {
|
||||
# Скобки вокруг Invoke-RestMethod обязательны: PowerShell 5.1 иначе отдаёт
|
||||
# JSON-массив в конвейер одним объектом, и фильтр видит все версии сразу.
|
||||
$lts = ((Invoke-RestMethod "https://nodejs.org/dist/index.json") | Where-Object { $_.lts } | Select-Object -First 1).version
|
||||
$msi = Fetch "https://nodejs.org/dist/$lts/node-$lts-x64.msi" "node-$lts-x64.msi"
|
||||
Run "msiexec.exe" @("/i", "`"$msi`"", "/qn", "/norestart")
|
||||
} else { Write-Host "Node.js уже установлен" }
|
||||
|
||||
# --- Inno Setup ----------------------------------------------------------------
|
||||
$iscc = Get-ChildItem "C:\Program Files*\Inno Setup*\ISCC.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $iscc) {
|
||||
# jrsoftware.org/download.php без браузера отдаёт HTML — берём файл из релизов на GitHub
|
||||
$rel = Invoke-RestMethod "https://api.github.com/repos/jrsoftware/issrc/releases/latest"
|
||||
$asset = $rel.assets | Where-Object { $_.name -match '^innosetup-[\d.]+(-x64)?\.exe$' } |
|
||||
Sort-Object { $_.name -notmatch '-x64' } | Select-Object -First 1
|
||||
$is = Fetch $asset.browser_download_url $asset.name
|
||||
Run $is @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/ALLUSERS")
|
||||
$iscc = Get-ChildItem "C:\Program Files*\Inno Setup*\ISCC.exe" | Select-Object -First 1
|
||||
} else { Write-Host "Inno Setup уже установлен" }
|
||||
|
||||
# --- Длинные пути: дерево WebEngine легко вылезает за 260 символов --------------
|
||||
Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem -Name LongPathsEnabled -Value 1
|
||||
& "C:\Program Files\Git\cmd\git.exe" config --system core.longpaths true
|
||||
|
||||
# --- gitea-runner ----------------------------------------------------------------
|
||||
$rdir = "C:\act_runner"
|
||||
New-Item -ItemType Directory -Force -Path $rdir, "C:\r" | Out-Null
|
||||
$exe = Join-Path $rdir "gitea-runner.exe"
|
||||
if (-not (Test-Path $exe)) {
|
||||
$src = Fetch "https://gitea.com/gitea/runner/releases/download/v$RunnerVersion/gitea-runner-$RunnerVersion-windows-amd64.exe" "gitea-runner-$RunnerVersion.exe"
|
||||
Copy-Item $src $exe
|
||||
}
|
||||
|
||||
# Конфиг: host-режим, рабочая папка с коротким путём, пути к Qt через env.
|
||||
$isccDir = Split-Path $iscc.FullName
|
||||
$cfg = @"
|
||||
log:
|
||||
level: info
|
||||
runner:
|
||||
file: $($rdir -replace '\\','/')/.runner
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
envs:
|
||||
QT_ROOT: '$QtRoot'
|
||||
QT_TOOLS: '$QtTools'
|
||||
ISCC_DIR: '$isccDir'
|
||||
labels:
|
||||
- "windows:host"
|
||||
cache:
|
||||
enabled: false
|
||||
host:
|
||||
workdir_parent: C:/r
|
||||
"@
|
||||
Set-Content -Path (Join-Path $rdir "config.yaml") -Value $cfg -Encoding UTF8
|
||||
|
||||
if ($Token -and -not (Test-Path (Join-Path $rdir ".runner"))) {
|
||||
Push-Location $rdir
|
||||
& $exe register --no-interactive --instance $Instance --token $Token `
|
||||
--name $RunnerName --labels "windows:host" --config config.yaml
|
||||
if ($LASTEXITCODE -ne 0) { throw "Регистрация раннера не удалась" }
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# Обёртка запуска: PATH дополняется git и node — задача планировщика может
|
||||
# стартовать раньше, чем сессия получит обновлённый системный PATH.
|
||||
$cmd = @"
|
||||
@echo off
|
||||
set PATH=C:\Program Files\Git\cmd;C:\Program Files\Git\usr\bin;C:\Program Files\nodejs;%PATH%
|
||||
cd /d $rdir
|
||||
gitea-runner.exe daemon --config config.yaml >> runner.log 2>&1
|
||||
"@
|
||||
Set-Content -Path (Join-Path $rdir "run-runner.cmd") -Value $cmd -Encoding ASCII
|
||||
|
||||
# Автозапуск при входе galeon: так задаче не нужен пароль учётной записи.
|
||||
# Чтобы раннер поднимался и без входа, пересоздайте задачу с /sc onstart /rp *.
|
||||
schtasks /create /f /tn "Gitea Runner" /sc onlogon /ru $env:USERNAME /it /rl highest `
|
||||
/tr "powershell.exe -WindowStyle Hidden -NoProfile -Command $rdir\run-runner.cmd" | Out-Null
|
||||
if (-not (Get-Process gitea-runner -ErrorAction SilentlyContinue)) { schtasks /run /tn "Gitea Runner" | Out-Null }
|
||||
|
||||
Write-Host "Готово. MSVC, git, node, Inno Setup и gitea-runner на месте."
|
||||
@@ -1,177 +0,0 @@
|
||||
# Сборка, упаковка и релизы
|
||||
|
||||
## Что нужно
|
||||
|
||||
- CMake 3.20+, компилятор C++17 и C (C нужен для zlib на Linux).
|
||||
- Qt 6.8 или новее (`qt_standard_project_setup(REQUIRES 6.8)`). CI собирает на Qt 6.11.1.
|
||||
- Обязательные модули: `Quick`, `QuickControls2`, `Qml`, `Svg`, `Core`, `CorePrivate`, `Gui`,
|
||||
`Network`.
|
||||
- Необязательный: `WebEngineQuick`, для входа через Microsoft.
|
||||
|
||||
`CorePrivate` нужен ради `QZipReader` и `QZipWriter`: ими распаковываются нативные библиотеки
|
||||
LWJGL, архивы Temurin и архивы сборок. Приватные модули Qt не гарантируют совместимость между
|
||||
версиями, поэтому проект привязан к конкретной версии Qt. Предупреждение об этом отключено
|
||||
(`QT_NO_PRIVATE_MODULE_WARNING`).
|
||||
|
||||
### Qt WebEngine
|
||||
|
||||
Модуль ставится в MaintenanceTool отдельной галочкой и тянет за собой Qt WebChannel и Qt
|
||||
Positioning. Чтобы он работал, нужны все три.
|
||||
|
||||
Если сделать WebEngine обязательным, у любого, кто его не поставил, проект вообще не соберётся,
|
||||
хотя офлайн-вход и Ely.by без него работают. Поэтому:
|
||||
|
||||
- модуль найден — определяется `LAUNCHER_HAS_WEBENGINE`, `MicrosoftLoginDialog.qml` попадает в
|
||||
модуль, `backend.microsoftAvailable` равно `true`;
|
||||
- не найден — CMake выдаёт предупреждение со списком компонентов, которые надо доставить, а
|
||||
лаунчер собирается без входа через Microsoft.
|
||||
|
||||
Релизные сборки в CI всегда с WebEngine: скрипты подготовки раннеров проверяют, что он есть.
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos
|
||||
```
|
||||
|
||||
```bash
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
Из Qt Creator проект открывается как обычный CMake-проект.
|
||||
|
||||
Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в
|
||||
`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).
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,88 +0,0 @@
|
||||
# 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-потоке. Если сервис удалить во
|
||||
время запроса, менеджер сети уйдёт вместе с ним, и колбэк не вызовется вообще. Бэкенд к тому же
|
||||
проверяет в каждом колбэке, не отменили ли запуск, пока шёл запрос.
|
||||
|
||||
Повторных попыток при сетевых ошибках нет.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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` и не даёт отменить шаги после архивации.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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 секунд.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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, если его там ещё нет.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,61 +0,0 @@
|
||||
# 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` приходит, когда вернутся все активные запросы.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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()`.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,363 +0,0 @@
|
||||
# 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,173 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
Ключи, на которые никто не ссылается, выводятся предупреждением.
|
||||
|
||||
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
|
||||
будет поправить.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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` придёт после его отчёта.
|
||||
|
||||
Деструктор убивает процесс установщика, если тот ещё работает.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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-потоке. Шаги вызываются из колбэков предыдущих. Если удалить
|
||||
сервис посреди цепочки, она оборвётся вместе с менеджером сети, и колбэк не вызовется.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,94 +0,0 @@
|
||||
# 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()`.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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` приходит
|
||||
после его завершения.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,76 +0,0 @@
|
||||
# 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()` отвечает отказом.
|
||||
|
||||
Деструктор блокирует сигналы и отменяет установку.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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` из той же папки, если он там есть. Тогда при запуске
|
||||
игры не появляется консольное окно. На других системах возвращает путь без изменений.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,94 +0,0 @@
|
||||
# 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 потянет.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,103 +0,0 @@
|
||||
# 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/`. Если папки
|
||||
нет, это не ошибка.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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 |
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,63 +0,0 @@
|
||||
# zlibreference.h
|
||||
|
||||
`zlibreference.h`, `zlibreference.cpp`
|
||||
|
||||
Обход одной конкретной проблемы на Linux: Forge и NeoForge не ставятся на системах, где вместо
|
||||
zlib стоит zlib-ng.
|
||||
|
||||
## В чём проблема
|
||||
|
||||
Установщики Forge и NeoForge собирают часть jar прямо на машине пользователя и сверяют sha1
|
||||
каждого собранного файла с эталоном из `install_profile.json`. Эталон посчитан на обычном zlib.
|
||||
CachyOS, Fedora и некоторые другие дистрибутивы ставят вместо него zlib-ng в режиме совместимости.
|
||||
Он сжимает корректно, но результат побайтово другой. Поэтому установка падает на любой версии игры
|
||||
с сообщением `Processor failed, invalid outputs`.
|
||||
|
||||
Другая Java не помогает: сборки OpenJDK под Linux берут `libz.so.1` из системы.
|
||||
|
||||
## Что сделано
|
||||
|
||||
В репозитории лежат исходники zlib 1.3.1 (каталог `zlib/`). На Linux CMake собирает из них цель
|
||||
`launcher_zlib_reference` (`libz.so.1`). В дереве сборки она лежит в `<папка бинарника>/zlib/`, при
|
||||
установке — в `<prefix>/lib/BiLauncher/`. Сам лаунчер с ней не линкуется. Библиотека
|
||||
подкладывается через `LD_PRELOAD` только процессу установщика, системный zlib-ng не трогается.
|
||||
|
||||
Исходники лежат в репозитории, а не скачиваются при сборке, по двум причинам: сборка не зависит от
|
||||
сети, и версия зафиксирована. От версии зависит побайтовый результат сжатия.
|
||||
|
||||
## Функции
|
||||
|
||||
На Windows и macOS все три функции ничего не делают: `systemIsZlibNg()` возвращает `false`,
|
||||
`bundledPath()` пустую строку, `applyTo()` — `true`.
|
||||
|
||||
### systemIsZlibNg()
|
||||
|
||||
Открывает `libz.so.1` через `dlopen`, то есть ту же библиотеку, которую загрузчик выдаст процессу
|
||||
java, и смотрит, есть ли `zlib-ng` в строке `zlibVersion()`. Результат вычисляется один раз за
|
||||
запуск.
|
||||
|
||||
### bundledPath()
|
||||
|
||||
Ищет эталонную библиотеку по порядку:
|
||||
|
||||
1. `<applicationDirPath>/zlib/libz.so.1` — запуск из дерева сборки;
|
||||
2. `<applicationDirPath>/../lib/BiLauncher/libz.so.1` — установленный пакет, tar.gz и AppImage;
|
||||
3. `LAUNCHER_ZLIB_INSTALL_DIR/libz.so.1` — путь, заданный при конфигурации CMake.
|
||||
|
||||
Второй вариант рассчитан на `CMAKE_INSTALL_LIBDIR=lib`. Поэтому CI на Ubuntu передаёт его явно,
|
||||
иначе библиотека легла бы в `lib/x86_64-linux-gnu`.
|
||||
|
||||
### applyTo(env, ¬e)
|
||||
|
||||
- zlib-ng в системе нет — подменять нечего, возвращает `true`, `note` пуст.
|
||||
- zlib-ng есть, эталон найден — дописывает его путь в начало `LD_PRELOAD` (чужое содержимое
|
||||
переменной сохраняется), возвращает `true`, в `note` пишет, что подставлено.
|
||||
- zlib-ng есть, эталона нет — возвращает `false`, в `note` пишет предупреждение.
|
||||
|
||||
[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()`
|
||||
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая
|
||||
подсказка про zlib-ng.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,147 +0,0 @@
|
||||
# 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 |
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,95 +0,0 @@
|
||||
# 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` и обновляет строку комплектности: установка
|
||||
могла закончиться, пока окно открыто.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,16 +0,0 @@
|
||||
# DarkCombo.qml
|
||||
|
||||
`ComboBox` в тёмном оформлении окна: фон `#2a2a2a`, зелёная рамка при фокусе, выпадающая панель
|
||||
высотой до 220 px с полосой прокрутки.
|
||||
|
||||
Стрелка взята та же, что у списков профиля и версии на главном окне
|
||||
(`images/Profile_Box/Asset_23/24.svg`), вместо двойного шеврона стиля `Basic`.
|
||||
|
||||
Новых свойств нет, используется как обычный `ComboBox`. Есть одно ограничение: делегат выводит
|
||||
`modelData` как текст, поэтому модель должна быть списком строк. `textRole` не поддерживается.
|
||||
|
||||
Используется в [LoaderRow](LoaderRow.md) и в диалогах [Main](Main.md).
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,17 +0,0 @@
|
||||
# LabelledField.qml
|
||||
|
||||
Подпись и под ней поле ввода. Используется в настройках и в карточке сборки.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `label` | подпись |
|
||||
| `placeholder` | текст-подсказка в пустом поле |
|
||||
| `text` | алиас текста поля |
|
||||
| `validator` | алиас валидатора поля |
|
||||
| `editingFinished()` | правка закончена: поле потеряло фокус или нажат Enter |
|
||||
|
||||
Ширина поля равна ширине компонента, высота 32 px.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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()` заменит её на самую новую.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,132 +0,0 @@
|
||||
# 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`, литералов в разметке нет.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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` в адресе входа.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,24 +0,0 @@
|
||||
# ProgressPanel.qml
|
||||
|
||||
Плашка прогресса долгой операции, 320×72, в левом нижнем углу главного окна. Там она не закрывает
|
||||
кнопку запуска, сообщения по центру и кнопки папки и настроек.
|
||||
|
||||
В [Main](Main.md) их две: загрузка (версия, модлоадер, Java, пак) и смена сборки.
|
||||
|
||||
## Интерфейс
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `title` | заголовок, жирным |
|
||||
| `status` | вторая строка, если нет `detail` |
|
||||
| `fraction` | от 0 до 1. −1 — общий объём неизвестен, вместо процентов «…» |
|
||||
| `detail` | вторая строка вместо `status`: мегабайты у загрузки, путь у архивации. Длинный текст обрезается посередине |
|
||||
| `cancellable` | показывать ли крестик; по умолчанию `true` |
|
||||
| `cancelRequested()` | нажат крестик |
|
||||
|
||||
Панель только показывает данные. Видимость, значения и реакцию на отмену задаёт главное окно: у
|
||||
загрузки крестик вызывает `backend.cancelDownload()`, у смены сборки крестика нет.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,56 +0,0 @@
|
||||
# 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)`). Свежий кэш отвечает
|
||||
без сети.
|
||||
|
||||
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
|
||||
главного окна, и случайный щелчок мимо окна не должен её закрыть.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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()`. Если удалили выбранный профиль модлоадера,
|
||||
он пропадает из каталога совсем (он был в нём только потому, что установлен), и выбор
|
||||
сбрасывается.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,170 +0,0 @@
|
||||
# Автотесты
|
||||
|
||||
Тесты лежат в `tests/` и собираются только по опции `BILAUNCHER_BUILD_TESTS`. В обычную сборку и в
|
||||
релизы они не попадают, код лаунчера ради них не менялся.
|
||||
|
||||
## Как запустить
|
||||
|
||||
```bash
|
||||
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos
|
||||
```
|
||||
|
||||
```bash
|
||||
cmake --build build-tests
|
||||
```
|
||||
|
||||
```bash
|
||||
ctest --test-dir build-tests --output-on-failure -j 6
|
||||
```
|
||||
|
||||
Отдельный слой — по метке: `ctest --test-dir build-tests -L unit` (ещё `net`, `backend`, `qml`).
|
||||
Отдельный тест — напрямую, с фильтром по функции:
|
||||
|
||||
```bash
|
||||
build-tests/tests/net/tst_versioninstaller installsModernVersionCompletely
|
||||
```
|
||||
|
||||
Статические проверки (переводы, qmllint, раздел CHANGELOG для текущей версии):
|
||||
|
||||
```bash
|
||||
python3 tests/scripts/static_checks.py build-tests
|
||||
```
|
||||
|
||||
В Qt Creator проект с тестами открывается как обычно: включите `BILAUNCHER_BUILD_TESTS` в настройках
|
||||
CMake, тесты появятся в панели «Тесты».
|
||||
|
||||
## Что покрыто
|
||||
|
||||
| Метка | Тест | Что проверяет |
|
||||
|-------|------|---------------|
|
||||
| unit | `tst_launcherpaths` | раскладка папки лаунчера, `ensureRootExists` и её отказ, если на месте папки файл |
|
||||
| unit | `tst_javaruntime` | ключи видов Java, `JavaRuntimeStore`, какая Java нужна какой версии игры |
|
||||
| unit | `tst_modloader` | ключи и названия модлоадеров |
|
||||
| unit | `tst_localization` | переключение языков, запасной язык, отсутствующий ключ, язык из `settings.json` |
|
||||
| unit | `tst_minecraftversion` | правила Mojang, загрузка json версии, `inheritsFrom`, нативы, удаление версий |
|
||||
| unit | `tst_authoffline` | офлайн-UUID как у сервера в offline-mode |
|
||||
| unit | `tst_msaredirect` | разбор адреса возврата после входа Microsoft |
|
||||
| unit | `tst_gamelauncher` | командная строка игры, нативы, запуск, падение и остановка процесса |
|
||||
| unit | `tst_javalocator` | версия java по `-version`, выбор подходящей, поиск в папке лаунчера |
|
||||
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
|
||||
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
|
||||
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
|
||||
| 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`). Такие данные выносите в фикстуры или собирайте по шагам.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -1,372 +0,0 @@
|
||||
#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();
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
[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
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/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 "готово"
|
||||
|
Before Width: | Height: | Size: 409 KiB |
@@ -1,31 +0,0 @@
|
||||
#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
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 811 B |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 515 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 467 KiB |
@@ -1,22 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 743 B |
@@ -1,22 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 743 B |
@@ -1,22 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 743 B |
|
Before Width: | Height: | Size: 2.2 MiB |
@@ -1,26 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 978 B |
@@ -1,26 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 978 B |
@@ -1,26 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 978 B |