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++
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
# Prerequisites
|
## files generated by popular Visual Studio add-ons.
|
||||||
*.d
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
# Compiled Object files
|
# User-specific files
|
||||||
*.slo
|
*.rsuser
|
||||||
*.lo
|
*.suo
|
||||||
*.o
|
*.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
|
*.obj
|
||||||
|
*.iobj
|
||||||
# Precompiled Headers
|
|
||||||
*.gch
|
|
||||||
*.pch
|
*.pch
|
||||||
|
*.pdb
|
||||||
# Compiled Dynamic libraries
|
*.ipdb
|
||||||
*.so
|
*.pgc
|
||||||
*.dylib
|
*.pgd
|
||||||
*.dll
|
*.rsp
|
||||||
|
*.sbr
|
||||||
# Fortran module files
|
*.tlb
|
||||||
*.mod
|
*.tli
|
||||||
*.smod
|
*.tlh
|
||||||
|
*.tmp
|
||||||
# Compiled Static libraries
|
*.tmp_proj
|
||||||
*.lai
|
*_wpftmp.csproj
|
||||||
*.la
|
|
||||||
*.a
|
|
||||||
*.lib
|
|
||||||
|
|
||||||
# Executables
|
|
||||||
*.exe
|
|
||||||
*.out
|
|
||||||
*.app
|
|
||||||
|
|
||||||
# ---> Java
|
|
||||||
# Compiled class file
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# Log file
|
|
||||||
*.log
|
*.log
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
# BlueJ files
|
# Chutzpah Test files
|
||||||
*.ctxt
|
_Chutzpah*
|
||||||
|
|
||||||
# Mobile Tools for Java (J2ME)
|
# Visual C++ cache files
|
||||||
.mtj.tmp/
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
# Package Files #
|
# Visual Studio profiler
|
||||||
*.jar
|
*.psess
|
||||||
*.war
|
*.vsp
|
||||||
*.nar
|
*.vspx
|
||||||
*.ear
|
*.sap
|
||||||
*.zip
|
|
||||||
*.tar.gz
|
|
||||||
*.rar
|
|
||||||
|
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
# Visual Studio Trace Files
|
||||||
hs_err_pid*
|
*.e2e
|
||||||
replay_pid*
|
|
||||||
|
|
||||||
build/
|
# TFS 2012 Local Workspace
|
||||||
.qtcreator/
|
$tf/
|
||||||
.DS_Store
|
|
||||||
CMakeLists.txt.user*
|
# Guidance Automation Toolkit
|
||||||
stage/
|
*.gpState
|
||||||
dist/
|
|
||||||
build-tests/
|
# ReSharper is a .NET coding add-in
|
||||||
build-*/
|
_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)
|
# CMakeList.txt: проект CMake для minecraft-launcher; включите исходный код и определения,
|
||||||
|
# укажите здесь логику для конкретного проекта.
|
||||||
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
|
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
||||||
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Qml Svg Core CorePrivate Gui Network)
|
|
||||||
find_package(Qt6 QUIET COMPONENTS WebEngineQuick)
|
|
||||||
if(Qt6WebEngineQuick_FOUND)
|
|
||||||
message(STATUS "Qt WebEngine найден: вход через аккаунт Microsoft будет доступен")
|
|
||||||
set(LAUNCHER_MSA_QML_FILES MicrosoftLoginDialog.qml)
|
|
||||||
else()
|
|
||||||
message(WARNING
|
|
||||||
"Qt WebEngine не найден — лаунчер соберётся без входа через аккаунт Microsoft. "
|
|
||||||
"Чтобы его включить, доустановите в MaintenanceTool для своей версии Qt сразу три "
|
|
||||||
"компонента: Qt WebEngine, Qt WebChannel и Qt Positioning.")
|
|
||||||
set(LAUNCHER_MSA_QML_FILES "")
|
|
||||||
endif()
|
|
||||||
|
|
||||||
qt_standard_project_setup(REQUIRES 6.8)
|
|
||||||
|
|
||||||
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
|
|
||||||
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
|
|
||||||
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
|
|
||||||
# который ставится ниже вместе с темой hicolor.
|
|
||||||
#
|
#
|
||||||
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
|
cmake_minimum_required (VERSION 3.8)
|
||||||
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
|
|
||||||
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
|
|
||||||
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
|
|
||||||
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
|
||||||
|
|
||||||
if(WIN32)
|
# Включение горячей перезагрузки для компиляторов MSVC, если поддерживается.
|
||||||
configure_file("${LAUNCHER_ICON_DIR}/icon.rc.in"
|
if (POLICY CMP0141)
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/icon.rc" @ONLY)
|
cmake_policy(SET CMP0141 NEW)
|
||||||
set(LAUNCHER_APP_ICON "${CMAKE_CURRENT_BINARY_DIR}/icon.rc")
|
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<IF:$<AND:$<C_COMPILER_ID:MSVC>,$<CXX_COMPILER_ID:MSVC>>,$<$<CONFIG:Debug,RelWithDebInfo>:EditAndContinue>,$<$<CONFIG:Debug,RelWithDebInfo>:ProgramDatabase>>")
|
||||||
elseif(APPLE)
|
|
||||||
set(LAUNCHER_APP_ICON "${LAUNCHER_ICON_DIR}/icon.icns")
|
|
||||||
set_source_files_properties(${LAUNCHER_APP_ICON} PROPERTIES
|
|
||||||
MACOSX_PACKAGE_LOCATION "Resources")
|
|
||||||
else()
|
|
||||||
set(LAUNCHER_APP_ICON "")
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
qt_add_executable(BiLauncher
|
project ("minecraft-launcher")
|
||||||
main.cpp
|
|
||||||
${LAUNCHER_APP_ICON}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
|
# Добавьте источник в исполняемый файл этого проекта.
|
||||||
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
|
add_executable (minecraft-launcher "minecraft-launcher.cpp" "minecraft-launcher.h")
|
||||||
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
|
|
||||||
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
|
|
||||||
qt_add_resources(BiLauncher "appicon"
|
|
||||||
PREFIX "/icons"
|
|
||||||
BASE "icons"
|
|
||||||
FILES
|
|
||||||
icons/icon_16x16.png
|
|
||||||
icons/icon_16x16@2x.png
|
|
||||||
icons/icon_24x24.png
|
|
||||||
icons/icon_32x32.png
|
|
||||||
icons/icon_32x32@2x.png
|
|
||||||
icons/icon_48x48.png
|
|
||||||
icons/icon_64x64.png
|
|
||||||
icons/icon_128x128.png
|
|
||||||
icons/icon_128x128@2x.png
|
|
||||||
icons/icon_256x256.png
|
|
||||||
icons/icon_256x256@2x.png
|
|
||||||
icons/icon_512x512.png
|
|
||||||
icons/icon_512x512@2x.png
|
|
||||||
)
|
|
||||||
|
|
||||||
# OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в
|
if (CMAKE_VERSION VERSION_GREATER 3.12)
|
||||||
# ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым
|
set_property(TARGET minecraft-launcher PROPERTY CXX_STANDARD 20)
|
||||||
# файлом (у него нет расширения), и линковка падает с «Is a directory».
|
|
||||||
qt_add_qml_module(BiLauncher
|
|
||||||
URI BiLauncher
|
|
||||||
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
|
||||||
SOURCES
|
|
||||||
localization.h
|
|
||||||
localization.cpp
|
|
||||||
launcherbackend.h
|
|
||||||
launcherbackend.cpp
|
|
||||||
launcherpaths.h
|
|
||||||
launcherpaths.cpp
|
|
||||||
minecraftversion.h
|
|
||||||
minecraftversion.cpp
|
|
||||||
javalocator.h
|
|
||||||
javalocator.cpp
|
|
||||||
zlibreference.h
|
|
||||||
zlibreference.cpp
|
|
||||||
javaruntime.h
|
|
||||||
javaruntime.cpp
|
|
||||||
javaruntimeservice.h
|
|
||||||
javaruntimeservice.cpp
|
|
||||||
javainstaller.h
|
|
||||||
javainstaller.cpp
|
|
||||||
authservice.h
|
|
||||||
authservice.cpp
|
|
||||||
msaauthservice.h
|
|
||||||
msaauthservice.cpp
|
|
||||||
gamelauncher.h
|
|
||||||
gamelauncher.cpp
|
|
||||||
versionmanifestservice.h
|
|
||||||
versionmanifestservice.cpp
|
|
||||||
versioninstaller.h
|
|
||||||
versioninstaller.cpp
|
|
||||||
modloader.h
|
|
||||||
modloaderversionservice.h
|
|
||||||
modloaderversionservice.cpp
|
|
||||||
modloaderinstaller.h
|
|
||||||
modloaderinstaller.cpp
|
|
||||||
buildarchiveworker.h
|
|
||||||
buildarchiveworker.cpp
|
|
||||||
buildswitcher.h
|
|
||||||
buildswitcher.cpp
|
|
||||||
seasonalbuildservice.h
|
|
||||||
seasonalbuildservice.cpp
|
|
||||||
seasonalpackdownloader.h
|
|
||||||
seasonalpackdownloader.cpp
|
|
||||||
QML_FILES
|
|
||||||
Main.qml
|
|
||||||
DarkCombo.qml
|
|
||||||
VersionPickerDialog.qml
|
|
||||||
JavaPickerDialog.qml
|
|
||||||
LabelledField.qml
|
|
||||||
BuildsDialog.qml
|
|
||||||
SeasonalBuildsDialog.qml
|
|
||||||
LoaderRow.qml
|
|
||||||
ProgressPanel.qml
|
|
||||||
${LAUNCHER_MSA_QML_FILES}
|
|
||||||
RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg i18n/translations.json
|
|
||||||
RESOURCES images/launcherBackground.png
|
|
||||||
RESOURCES images/launcherBackground2static.jpg
|
|
||||||
RESOURCES CHANGELOG.md
|
|
||||||
RESOURCES LICENSE
|
|
||||||
RESOURCES README.md
|
|
||||||
)
|
|
||||||
|
|
||||||
# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
|
|
||||||
# If you are developing for iOS or macOS you should consider setting an
|
|
||||||
# explicit, fixed bundle identifier manually though.
|
|
||||||
set_target_properties(BiLauncher PROPERTIES
|
|
||||||
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
|
||||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
|
||||||
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
|
||||||
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
|
|
||||||
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
|
|
||||||
MACOSX_BUNDLE_ICON_FILE icon.icns
|
|
||||||
MACOSX_BUNDLE TRUE
|
|
||||||
WIN32_EXECUTABLE TRUE
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(BiLauncher
|
|
||||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
|
||||||
)
|
|
||||||
|
|
||||||
target_compile_definitions(BiLauncher PRIVATE
|
|
||||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if(Qt6WebEngineQuick_FOUND)
|
|
||||||
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
|
||||||
target_compile_definitions(BiLauncher PRIVATE LAUNCHER_HAS_WEBENGINE)
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
include(GNUInstallDirs)
|
# TODO: Добавьте тесты и целевые объекты, если это необходимо.
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -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 |