Compare commits
24
Commits
master
..
39b2204e67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39b2204e67 | ||
|
|
5635dcc558 | ||
|
|
6a2b596f32 | ||
|
|
3517f1f236 | ||
|
|
b94d852d7c | ||
|
|
47d30b1594 | ||
|
|
56dd58b8d1 | ||
|
|
50e0cd2aab | ||
|
|
4ceaf000fd | ||
|
|
d82a9d0729 | ||
|
|
2575aae5c6 | ||
|
|
6442514d4f | ||
|
|
1817157e4d | ||
|
|
4463d4e5da | ||
|
|
7dd89db508 | ||
|
|
a89bf20486 | ||
|
|
ec60df859a | ||
|
|
0cacbc3ef3 | ||
|
|
5508b9a936 | ||
|
|
24115b4ce6 | ||
|
|
1d781b5324 | ||
|
|
bc205fa2e7 | ||
|
|
aa021269be | ||
|
|
92531c6e9a |
No files matched your search
@@ -1,10 +1,18 @@
|
|||||||
name: release
|
name: release
|
||||||
|
|
||||||
|
# master -> релиз v<версия>, если такого ещё нет
|
||||||
|
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
|
||||||
|
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
|
||||||
|
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
#branches: [master, rc]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
|
||||||
@@ -14,53 +22,95 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.meta.outputs.version }}
|
version: ${{ steps.meta.outputs.version }}
|
||||||
tag: ${{ steps.meta.outputs.tag }}
|
tag: ${{ steps.meta.outputs.tag }}
|
||||||
|
skip: ${{ steps.meta.outputs.skip }}
|
||||||
release_id: ${{ steps.release.outputs.id }}
|
release_id: ${{ steps.release.outputs.id }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- id: meta
|
- id: meta
|
||||||
name: Версия из CMakeLists.txt и сверка с тегом
|
name: Версия из CMakeLists.txt, тег и тип релиза
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
|
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; }
|
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
|
||||||
tag=""
|
|
||||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
|
||||||
tag="${GITHUB_REF_NAME}"
|
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
|
||||||
base="${tag#v}"; base="${base%%-*}"
|
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
|
||||||
if [ "$base" != "$version" ]; then
|
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
|
||||||
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
|
||||||
fi
|
known() {
|
||||||
version="${tag#v}"
|
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
|
||||||
fi
|
printf '%s\n' "$release_tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
tag=""; skip=false
|
||||||
|
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
|
||||||
|
tag:*)
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
base="${tag#v}"; base="${base%%-*}"
|
||||||
|
if [ "$base" != "$version" ]; then
|
||||||
|
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||||
|
fi
|
||||||
|
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
|
||||||
|
if has_release "$tag"; then
|
||||||
|
echo "Релиз $tag уже есть"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:master)
|
||||||
|
tag="v$version"
|
||||||
|
if known | grep -xF "$tag" > /dev/null; then
|
||||||
|
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:rc)
|
||||||
|
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
|
||||||
|
tag="v$version-rc$(( ${n:-0} + 1 ))"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ -n "$tag" ] && version="${tag#v}"
|
||||||
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
echo "Версия: $version, тег: ${tag:-нет}"
|
echo "skip=$skip" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
|
||||||
|
|
||||||
- id: release
|
- id: release
|
||||||
name: Черновик релиза
|
name: Черновик релиза
|
||||||
if: steps.meta.outputs.tag != ''
|
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
TAG: ${{ steps.meta.outputs.tag }}
|
TAG: ${{ steps.meta.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
base="${TAG#v}"; base="${base%%-*}"
|
full="${TAG#v}"; base="${full%%-*}"
|
||||||
notes=$(awk -v v="$base" '
|
section() {
|
||||||
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
awk -v v="$1" '
|
||||||
on' CHANGELOG.md)
|
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||||
prerelease=false; case "$TAG" in *-*) prerelease=true;; esac
|
on' CHANGELOG.md
|
||||||
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")
|
# Сначала раздел ровно под тег ([1.0.1-rc1]), иначе общий под версию ([1.0.1])
|
||||||
|
notes=$(section "$full")
|
||||||
|
[ -n "$notes" ] || notes=$(section "$base")
|
||||||
|
[ -n "$notes" ] || echo "::warning::В CHANGELOG.md нет раздела [$full] или [$base]"
|
||||||
|
prerelease=false; case "$TAG" in *-rc*) prerelease=true;; esac
|
||||||
|
# target_commitish: у новой ветки тег появится на этом коммите при публикации
|
||||||
|
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"target_commitish":sys.argv[4],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
||||||
|
"$TAG" "$notes" "$prerelease" "$GITHUB_SHA")
|
||||||
id=$(curl -fsS -X POST "$API/releases" \
|
id=$(curl -fsS -X POST "$API/releases" \
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||||
echo "Создан черновик релиза $TAG (id $id)"
|
echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
needs: create-release
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
outputs:
|
outputs:
|
||||||
sums: ${{ steps.pack.outputs.sums }}
|
sums: ${{ steps.pack.outputs.sums }}
|
||||||
@@ -72,7 +122,7 @@ jobs:
|
|||||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
rm -rf build AppDir dist
|
rm -rf build AppDir dist
|
||||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||||
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
|
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
|
||||||
cmake --build build -j 8
|
cmake --build build -j 8
|
||||||
cmake --install build --prefix "$PWD/AppDir/usr"
|
cmake --install build --prefix "$PWD/AppDir/usr"
|
||||||
@@ -111,9 +161,12 @@ jobs:
|
|||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
needs: create-release
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
runs-on: windows
|
runs-on: windows
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
outputs:
|
outputs:
|
||||||
sums: ${{ steps.build.outputs.sums }}
|
sums: ${{ steps.build.outputs.sums }}
|
||||||
@@ -134,7 +187,7 @@ jobs:
|
|||||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
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"
|
$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"
|
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT" "-DLAUNCHER_BUILD_VERSION=$env:BUILD_VERSION"
|
||||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||||
cmake --build $build
|
cmake --build $build
|
||||||
if ($LASTEXITCODE) { throw "cmake build" }
|
if ($LASTEXITCODE) { throw "cmake build" }
|
||||||
@@ -181,9 +234,12 @@ jobs:
|
|||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
needs: create-release
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
runs-on: macos
|
runs-on: macos
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
outputs:
|
outputs:
|
||||||
sums: ${{ steps.pack.outputs.sums }}
|
sums: ${{ steps.pack.outputs.sums }}
|
||||||
@@ -195,7 +251,7 @@ jobs:
|
|||||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
rm -rf build stage dist dmgroot
|
rm -rf build stage dist dmgroot
|
||||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
||||||
cmake --build build
|
cmake --build build
|
||||||
cmake --install build --prefix "$PWD/stage"
|
cmake --install build --prefix "$PWD/stage"
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ concurrency:
|
|||||||
group: tests-${{ github.ref }}
|
group: tests-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux:
|
linux:
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
|
||||||
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -81,7 +82,9 @@ jobs:
|
|||||||
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
||||||
$code = $LASTEXITCODE
|
$code = $LASTEXITCODE
|
||||||
New-Item -ItemType Directory -Force test-results | Out-Null
|
New-Item -ItemType Directory -Force test-results | Out-Null
|
||||||
|
git rev-parse HEAD | Set-Content -Encoding UTF8 test-results\commit.txt
|
||||||
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
|
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
|
||||||
|
Copy-Item "$build\Testing\Temporary\LastTest.log" test-results\ -ErrorAction SilentlyContinue
|
||||||
if ($code) { throw "ctest" }
|
if ($code) { throw "ctest" }
|
||||||
|
|
||||||
- name: Отчёты
|
- name: Отчёты
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ replay_pid*
|
|||||||
build/
|
build/
|
||||||
.qtcreator/
|
.qtcreator/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/doc/adr/
|
||||||
CMakeLists.txt.user*
|
CMakeLists.txt.user*
|
||||||
stage/
|
stage/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
+6
-3
@@ -8,6 +8,7 @@ Dialog {
|
|||||||
id: buildsDialog
|
id: buildsDialog
|
||||||
|
|
||||||
required property var backend
|
required property var backend
|
||||||
|
enabled: !buildsDialog.backend.seasonalInstalling
|
||||||
property int editIndex: -1
|
property int editIndex: -1
|
||||||
property bool loading: false
|
property bool loading: false
|
||||||
|
|
||||||
@@ -15,8 +16,9 @@ Dialog {
|
|||||||
padding: 0
|
padding: 0
|
||||||
width: 880
|
width: 880
|
||||||
height: 600
|
height: 600
|
||||||
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||||
|
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: "#1e1e1e"
|
color: "#1e1e1e"
|
||||||
@@ -314,7 +316,8 @@ Dialog {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||||
text: Loc.t.build.label.active
|
text: Loc.t.build.label.active
|
||||||
color: "#91B315"
|
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||||
|
? "#ffffff" : "#91B315"
|
||||||
font.pixelSize: 10
|
font.pixelSize: 10
|
||||||
}
|
}
|
||||||
Image {
|
Image {
|
||||||
|
|||||||
@@ -10,3 +10,12 @@
|
|||||||
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
||||||
### Исправлено
|
### Исправлено
|
||||||
- UI фиксы
|
- UI фиксы
|
||||||
|
|
||||||
|
## [1.0.1-rc1] — 2026-10-23
|
||||||
|
### Добавлено
|
||||||
|
- Отображене текущей версии сборки лаунчера на главном окне
|
||||||
|
### Исправлено
|
||||||
|
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
|
||||||
|
- Исправлен баг отображения статуса активной сборки на зелёном фоне
|
||||||
|
- Исправлен баг, что во время игры можно было потенциально сменить сборку
|
||||||
|
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
|
||||||
+31
-27
@@ -1,6 +1,27 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
|
project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C)
|
||||||
|
|
||||||
|
# Канал сборки (dev / rc / релиз) не хранится в репозитории, а вычисляется
|
||||||
|
# при сборке — см. cmake/LauncherVersion.cmake. CI передаёт полную версию:
|
||||||
|
# -DLAUNCHER_BUILD_VERSION=1.0.1-rc2.
|
||||||
|
set(LAUNCHER_BUILD_VERSION "" CACHE STRING "Полная версия от CI (X.Y.Z[-rcN]); пусто — по тегу и ветке git")
|
||||||
|
set(LAUNCHER_VERSION_NUMERIC "${PROJECT_VERSION}")
|
||||||
|
set(LAUNCHER_SOURCE_DIR "${PROJECT_SOURCE_DIR}")
|
||||||
|
set(LAUNCHER_GENERATED_DIR "${PROJECT_BINARY_DIR}/generated")
|
||||||
|
set(LAUNCHER_VERSION_HEADER "${LAUNCHER_GENERATED_DIR}/launcherversion.h")
|
||||||
|
include(cmake/LauncherVersion.cmake)
|
||||||
|
message(STATUS "Версия лаунчера: ${LAUNCHER_VERSION} (канал ${LAUNCHER_CHANNEL})")
|
||||||
|
add_custom_target(launcher_version
|
||||||
|
COMMAND "${CMAKE_COMMAND}"
|
||||||
|
"-DLAUNCHER_VERSION_NUMERIC=${LAUNCHER_VERSION_NUMERIC}"
|
||||||
|
"-DLAUNCHER_SOURCE_DIR=${LAUNCHER_SOURCE_DIR}"
|
||||||
|
"-DLAUNCHER_VERSION_HEADER=${LAUNCHER_VERSION_HEADER}"
|
||||||
|
"-DLAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION}"
|
||||||
|
-P "${PROJECT_SOURCE_DIR}/cmake/LauncherVersion.cmake"
|
||||||
|
BYPRODUCTS "${LAUNCHER_VERSION_HEADER}"
|
||||||
|
COMMENT "Версия лаунчера из git"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||||
@@ -19,15 +40,6 @@ endif()
|
|||||||
|
|
||||||
qt_standard_project_setup(REQUIRES 6.8)
|
qt_standard_project_setup(REQUIRES 6.8)
|
||||||
|
|
||||||
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
|
|
||||||
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
|
|
||||||
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
|
|
||||||
# который ставится ниже вместе с темой hicolor.
|
|
||||||
#
|
|
||||||
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
|
|
||||||
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
|
|
||||||
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
|
|
||||||
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
|
|
||||||
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
@@ -47,10 +59,6 @@ qt_add_executable(BiLauncher
|
|||||||
${LAUNCHER_APP_ICON}
|
${LAUNCHER_APP_ICON}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
|
|
||||||
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
|
|
||||||
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
|
|
||||||
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
|
|
||||||
qt_add_resources(BiLauncher "appicon"
|
qt_add_resources(BiLauncher "appicon"
|
||||||
PREFIX "/icons"
|
PREFIX "/icons"
|
||||||
BASE "icons"
|
BASE "icons"
|
||||||
@@ -70,9 +78,11 @@ qt_add_resources(BiLauncher "appicon"
|
|||||||
icons/icon_512x512@2x.png
|
icons/icon_512x512@2x.png
|
||||||
)
|
)
|
||||||
|
|
||||||
# OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в
|
# Путь импорта для QML code model Qt Creator / qmlls: модуль лежит в
|
||||||
# ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым
|
# ${CMAKE_CURRENT_BINARY_DIR}/qml, а не в корне каталога сборки.
|
||||||
# файлом (у него нет расширения), и линковка падает с «Is a directory».
|
set(QML_IMPORT_PATH "${CMAKE_CURRENT_BINARY_DIR}/qml"
|
||||||
|
CACHE STRING "Пути импорта QML для Qt Creator" FORCE)
|
||||||
|
|
||||||
qt_add_qml_module(BiLauncher
|
qt_add_qml_module(BiLauncher
|
||||||
URI BiLauncher
|
URI BiLauncher
|
||||||
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
||||||
@@ -114,6 +124,8 @@ qt_add_qml_module(BiLauncher
|
|||||||
buildarchiveworker.cpp
|
buildarchiveworker.cpp
|
||||||
buildswitcher.h
|
buildswitcher.h
|
||||||
buildswitcher.cpp
|
buildswitcher.cpp
|
||||||
|
gamedirimport.h
|
||||||
|
gamedirimport.cpp
|
||||||
seasonalbuildservice.h
|
seasonalbuildservice.h
|
||||||
seasonalbuildservice.cpp
|
seasonalbuildservice.cpp
|
||||||
seasonalpackdownloader.h
|
seasonalpackdownloader.h
|
||||||
@@ -144,8 +156,6 @@ set_target_properties(BiLauncher PROPERTIES
|
|||||||
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
||||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||||
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
||||||
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
|
|
||||||
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
|
|
||||||
MACOSX_BUNDLE_ICON_FILE icon.icns
|
MACOSX_BUNDLE_ICON_FILE icon.icns
|
||||||
MACOSX_BUNDLE TRUE
|
MACOSX_BUNDLE TRUE
|
||||||
WIN32_EXECUTABLE TRUE
|
WIN32_EXECUTABLE TRUE
|
||||||
@@ -155,9 +165,8 @@ target_link_libraries(BiLauncher
|
|||||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(BiLauncher PRIVATE
|
target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
|
||||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
add_dependencies(BiLauncher launcher_version)
|
||||||
)
|
|
||||||
|
|
||||||
if(Qt6WebEngineQuick_FOUND)
|
if(Qt6WebEngineQuick_FOUND)
|
||||||
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
||||||
@@ -206,8 +215,6 @@ if(UNIX AND NOT APPLE)
|
|||||||
)
|
)
|
||||||
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
||||||
|
|
||||||
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
|
|
||||||
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
|
|
||||||
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
||||||
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
||||||
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
||||||
@@ -243,9 +250,6 @@ qt_generate_deploy_qml_app_script(
|
|||||||
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
||||||
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
||||||
install(SCRIPT ${deploy_script})
|
install(SCRIPT ${deploy_script})
|
||||||
# Автотесты: собираются только по запросу и в установку не попадают.
|
|
||||||
# Код лаунчера при этом не меняется — тесты компилируют те же исходники
|
|
||||||
# в отдельную статическую библиотеку, см. tests/CMakeLists.txt и doc/testing.md.
|
|
||||||
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
|
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
|
||||||
if(BILAUNCHER_BUILD_TESTS)
|
if(BILAUNCHER_BUILD_TESTS)
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ Window {
|
|||||||
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
|
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
|
||||||
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
|
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
|
||||||
onGameOutput: (line) => console.log(line)
|
onGameOutput: (line) => console.log(line)
|
||||||
|
onGameDirImported: (buildName) => {
|
||||||
|
window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000)
|
||||||
|
}
|
||||||
onSeasonalInstallFinished: (seasonalId, buildName) => {
|
onSeasonalInstallFinished: (seasonalId, buildName) => {
|
||||||
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
|
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
|
||||||
"#4b7a1f", 8000)
|
"#4b7a1f", 8000)
|
||||||
@@ -89,6 +92,38 @@ Window {
|
|||||||
id: image
|
id: image
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
source: "images/launcherBackground.png"
|
source: "images/launcherBackground.png"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: launcherVersion
|
||||||
|
x: 8
|
||||||
|
y: 8
|
||||||
|
color: "#ffffff"
|
||||||
|
text: Loc.t.app.label.version.arg(Qt.application.version)
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
// Канал сборки из суффикса версии (cmake/LauncherVersion.cmake): у релиза бейджа нет
|
||||||
|
Rectangle {
|
||||||
|
id: channelBadge
|
||||||
|
readonly property var channel: /-(dev|rc)/.exec(Qt.application.version)
|
||||||
|
visible: channel !== null
|
||||||
|
anchors.left: launcherVersion.right
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.verticalCenter: launcherVersion.verticalCenter
|
||||||
|
width: channelLabel.implicitWidth + 8
|
||||||
|
height: channelLabel.implicitHeight + 2
|
||||||
|
radius: 3
|
||||||
|
color: channel && channel[1] === "rc" ? "#c08a1e" : "#b03a3a"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: channelLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: channelBadge.channel ? channelBadge.channel[1].toUpperCase() : ""
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status toast ───────────────────────────────────────────────────────
|
// ── Status toast ───────────────────────────────────────────────────────
|
||||||
@@ -192,6 +227,10 @@ Window {
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
editable: false
|
editable: false
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
// Пока идёт загрузка или установка, профиль и сборку не меняем:
|
||||||
|
// единственное доступное действие — отмена на панели загрузки.
|
||||||
|
enabled: !backend.busy && !backend.gameRunning
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
|
||||||
model: backend.profileNames
|
model: backend.profileNames
|
||||||
|
|
||||||
@@ -377,8 +416,6 @@ Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Активная сборка ────────────────────────────────────────────────────
|
// ── Активная сборка ────────────────────────────────────────────────────
|
||||||
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
|
|
||||||
// и её содержимое правятся теперь в модальном окне.
|
|
||||||
Button {
|
Button {
|
||||||
id: buildButton
|
id: buildButton
|
||||||
x: 624
|
x: 624
|
||||||
@@ -390,7 +427,8 @@ Window {
|
|||||||
anchors.rightMargin: 0
|
anchors.rightMargin: 0
|
||||||
anchors.bottomMargin: -218
|
anchors.bottomMargin: -218
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
enabled: !backend.gameRunning && !backend.switching
|
enabled: !backend.gameRunning && !backend.switching && !backend.seasonalInstalling
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
|
||||||
contentItem: Text {
|
contentItem: Text {
|
||||||
text: backend.activeBuildName !== "" ? backend.activeBuildName
|
text: backend.activeBuildName !== "" ? backend.activeBuildName
|
||||||
@@ -464,6 +502,7 @@ Window {
|
|||||||
anchors.verticalCenter: folderButton.verticalCenter
|
anchors.verticalCenter: folderButton.verticalCenter
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: 8
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
enabled: !backend.busy && !backend.gameRunning
|
||||||
|
|
||||||
background: Image {
|
background: Image {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ Dialog {
|
|||||||
padding: 0
|
padding: 0
|
||||||
width: 960
|
width: 960
|
||||||
height: 560
|
height: 560
|
||||||
closePolicy: seasons.backend.seasonalInstalling
|
// Закрывается и во время установки: отменить её можно только на панели
|
||||||
? Popup.NoAutoClose
|
// загрузки главного окна, а модальное окно эту панель перекрывает.
|
||||||
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: "#1e1e1e"
|
color: "#1e1e1e"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Полная версия лаунчера и канал сборки (dev / rc / release).
|
||||||
|
#
|
||||||
|
# В репозитории хранится только число X.Y.Z из project(), одинаковое во всех
|
||||||
|
# ветках, — поэтому merge dev → rc → master не трогает версию. Канал
|
||||||
|
# вычисляется при сборке (по убыванию приоритета):
|
||||||
|
# 1. LAUNCHER_BUILD_VERSION — полная версия от CI: 1.0.1, 1.0.1-rc2;
|
||||||
|
# 2. тег на HEAD: vX.Y.Z — релиз, vX.Y.Z-rcN — rc;
|
||||||
|
# 3. ветка (из переменных CI или git): master — релиз, rc/beta — rc;
|
||||||
|
# 4. всё остальное, в т.ч. сборка без git, — dev.
|
||||||
|
#
|
||||||
|
# Работает в двух режимах:
|
||||||
|
# include() при configure — проверяет входные данные и пишет заголовок;
|
||||||
|
# cmake -P на каждой сборке — чтобы после git checkout без reconfigure
|
||||||
|
# канал и хэш коммита не устаревали.
|
||||||
|
# Входные переменные: LAUNCHER_VERSION_NUMERIC, LAUNCHER_SOURCE_DIR,
|
||||||
|
# LAUNCHER_VERSION_HEADER, LAUNCHER_BUILD_VERSION (необязательна).
|
||||||
|
# Результат: LAUNCHER_VERSION, LAUNCHER_CHANNEL и файл LAUNCHER_VERSION_HEADER.
|
||||||
|
|
||||||
|
set(_numeric "${LAUNCHER_VERSION_NUMERIC}")
|
||||||
|
|
||||||
|
find_package(Git QUIET)
|
||||||
|
function(_launcher_git out)
|
||||||
|
set(${out} "" PARENT_SCOPE)
|
||||||
|
if(NOT GIT_EXECUTABLE)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
execute_process(COMMAND "${GIT_EXECUTABLE}" ${ARGN}
|
||||||
|
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}"
|
||||||
|
RESULT_VARIABLE _rc OUTPUT_VARIABLE _out ERROR_QUIET
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE)
|
||||||
|
if(_rc EQUAL 0)
|
||||||
|
set(${out} "${_out}" PARENT_SCOPE)
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
set(_full "")
|
||||||
|
if(LAUNCHER_BUILD_VERSION)
|
||||||
|
set(_full "${LAUNCHER_BUILD_VERSION}")
|
||||||
|
string(REGEX REPLACE "^v" "" _full "${_full}")
|
||||||
|
string(REGEX REPLACE "-.*$" "" _base "${_full}")
|
||||||
|
if(NOT _base STREQUAL _numeric)
|
||||||
|
message(FATAL_ERROR "LAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION} не совпадает "
|
||||||
|
"с project(BiLauncher VERSION ${_numeric})")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
# Тег vX.Y.Z или vX.Y.Z-rcN ровно на HEAD, относящийся к текущей версии
|
||||||
|
_launcher_git(_tags tag --points-at HEAD)
|
||||||
|
string(REPLACE "\n" ";" _tags "${_tags}")
|
||||||
|
string(REPLACE "." "\\." _numeric_re "${_numeric}")
|
||||||
|
foreach(_tag IN LISTS _tags)
|
||||||
|
if(_tag MATCHES "^v(${_numeric_re}(-rc[0-9]+)?)$")
|
||||||
|
# Релизный тег важнее rc-тега на том же коммите
|
||||||
|
if(NOT _full OR NOT CMAKE_MATCH_2)
|
||||||
|
set(_full "${CMAKE_MATCH_1}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(_full)
|
||||||
|
if(_full MATCHES "-rc[0-9]*$")
|
||||||
|
set(LAUNCHER_CHANNEL "rc")
|
||||||
|
elseif(_full STREQUAL _numeric)
|
||||||
|
set(LAUNCHER_CHANNEL "release")
|
||||||
|
else()
|
||||||
|
set(LAUNCHER_CHANNEL "dev")
|
||||||
|
endif()
|
||||||
|
set(LAUNCHER_VERSION "${_full}")
|
||||||
|
else()
|
||||||
|
# В CI на detached HEAD имени ветки в git нет — берём его из окружения
|
||||||
|
set(_branch "$ENV{GITHUB_HEAD_REF}")
|
||||||
|
if(NOT _branch AND "$ENV{GITHUB_REF_TYPE}" STREQUAL "branch")
|
||||||
|
set(_branch "$ENV{GITHUB_REF_NAME}")
|
||||||
|
endif()
|
||||||
|
if(NOT _branch)
|
||||||
|
_launcher_git(_branch rev-parse --abbrev-ref HEAD)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(_branch STREQUAL "master")
|
||||||
|
set(LAUNCHER_CHANNEL "release")
|
||||||
|
set(LAUNCHER_VERSION "${_numeric}")
|
||||||
|
elseif(_branch STREQUAL "rc" OR _branch STREQUAL "beta")
|
||||||
|
set(LAUNCHER_CHANNEL "rc")
|
||||||
|
set(LAUNCHER_VERSION "${_numeric}-rc")
|
||||||
|
else()
|
||||||
|
set(LAUNCHER_CHANNEL "dev")
|
||||||
|
set(LAUNCHER_VERSION "${_numeric}-dev")
|
||||||
|
_launcher_git(_hash rev-parse --short=7 HEAD)
|
||||||
|
if(_hash)
|
||||||
|
# Неотслеживаемые файлы (каталоги сборки и т.п.) не считаем
|
||||||
|
execute_process(COMMAND "${GIT_EXECUTABLE}" diff --quiet HEAD --
|
||||||
|
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}" RESULT_VARIABLE _dirty ERROR_QUIET)
|
||||||
|
if(NOT _dirty EQUAL 0)
|
||||||
|
set(_hash "${_hash}.dirty")
|
||||||
|
endif()
|
||||||
|
string(APPEND LAUNCHER_VERSION "+${_hash}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
configure_file("${CMAKE_CURRENT_LIST_DIR}/launcherversion.h.in" "${LAUNCHER_VERSION_HEADER}" @ONLY)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Сгенерировано cmake/LauncherVersion.cmake — не редактировать.
|
||||||
|
|
||||||
|
#define LAUNCHER_VERSION "@LAUNCHER_VERSION@" // 1.0.1, 1.0.1-rc2, 1.0.1-dev+a1b2c3d
|
||||||
|
#define LAUNCHER_VERSION_NUMERIC "@LAUNCHER_VERSION_NUMERIC@"
|
||||||
|
#define LAUNCHER_CHANNEL "@LAUNCHER_CHANNEL@" // dev, rc, release
|
||||||
@@ -40,8 +40,41 @@ cmake --build build
|
|||||||
|
|
||||||
Из Qt Creator проект открывается как обычный CMake-проект.
|
Из Qt Creator проект открывается как обычный CMake-проект.
|
||||||
|
|
||||||
Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в
|
### Версия и канал сборки
|
||||||
`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза.
|
|
||||||
|
В репозитории хранится только число: `project(BiLauncher VERSION x.y.z)` в `CMakeLists.txt`. Оно
|
||||||
|
одинаковое во всех ветках, поэтому merge `dev` → `rc` → `master` версию не перетирает и не даёт
|
||||||
|
конфликтов. `PROJECT_VERSION` идёт в ресурс версии `.exe`, в `Info.plist` и в установщик: все три
|
||||||
|
принимают только числовую версию.
|
||||||
|
|
||||||
|
Канал (dev / rc / релиз) в коде не записан — его на каждой сборке вычисляет
|
||||||
|
`cmake/LauncherVersion.cmake` и пишет в сгенерированный `<build>/generated/launcherversion.h`
|
||||||
|
(`LAUNCHER_VERSION`, `LAUNCHER_CHANNEL`). Порядок определения:
|
||||||
|
|
||||||
|
1. `-DLAUNCHER_BUILD_VERSION=1.0.1-rc2` — полная версия от CI; база обязана совпадать с
|
||||||
|
`project()`, иначе configure падает;
|
||||||
|
2. тег на HEAD: `vx.y.z` — релиз, `vx.y.z-rcN` — rc;
|
||||||
|
3. ветка (из `GITHUB_HEAD_REF`/`GITHUB_REF_NAME` или git): `master` — релиз, `rc` и `beta` — rc;
|
||||||
|
4. любая другая ветка или сборка без git — dev.
|
||||||
|
|
||||||
|
| Сборка | `LAUNCHER_VERSION` | В окне |
|
||||||
|
|---------------------------|----------------------|-----------------|
|
||||||
|
| релиз (master, `vx.y.z`) | `1.0.1` | `Версия: 1.0.1` |
|
||||||
|
| rc из CI (`vx.y.z-rcN`) | `1.0.1-rc2` | + бейдж RC |
|
||||||
|
| rc локально | `1.0.1-rc` | + бейдж RC |
|
||||||
|
| dev | `1.0.1-dev+a1b2c3d` | + бейдж DEV |
|
||||||
|
|
||||||
|
У dev-сборки после `+` — короткий хэш коммита, `.dirty` — есть незакоммиченные правки в
|
||||||
|
отслеживаемых файлах. Генерация идёт на каждой сборке, так что после `git checkout` другой
|
||||||
|
ветки reconfigure не нужен.
|
||||||
|
|
||||||
|
Полная строка уходит в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в
|
||||||
|
подпись «Версия: …» в левом верхнем углу окна; бейдж канала QML берёт из её суффикса. Почему так,
|
||||||
|
см. [ADR-0002](adr/0002-launcher-version-display.md).
|
||||||
|
|
||||||
|
**Когда поднимать версию.** Номер поднимается в `dev` в начале цикла (сразу после релиза) и
|
||||||
|
дальше приходит в `rc` и `master` через merge. Хотфикс прямо в `rc` или `master` — правка и bump
|
||||||
|
patch там же, затем обратный merge `master` → `rc` → `dev`.
|
||||||
|
|
||||||
## Что собирается
|
## Что собирается
|
||||||
|
|
||||||
@@ -103,14 +136,24 @@ QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIREC
|
|||||||
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
|
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
|
||||||
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
|
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
|
||||||
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
|
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
|
||||||
3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает
|
Для пре-релиза можно завести отдельный раздел `## [x.y.z-rcN]`: он берётся в первую очередь,
|
||||||
релиз pre-release).
|
а если его нет — используется `## [x.y.z]`.
|
||||||
|
3. Запустить релиз одним из способов:
|
||||||
|
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
|
||||||
|
прогон ничего не собирает: сначала поднимите версию.
|
||||||
|
- **Пуш в `rc`** — пре-релиз `vx.y.z-rcN`, где `N` — следующий свободный номер для этой версии
|
||||||
|
(`rc1`, `rc2`, ...).
|
||||||
|
- **Тег `vx.y.z`** — полноценный релиз, **тег `vx.y.z-rcN`** — пре-релиз. Пре-релизом
|
||||||
|
становится только тег с суффиксом `-rc`, любой другой суффикс даёт обычный релиз.
|
||||||
|
|
||||||
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
|
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
|
||||||
|
|
||||||
|
Для веток тег создаёт сам Gitea при публикации релиза, на коммите прогона. Этот тег снова
|
||||||
|
запускает `release.yml`, но прогон видит, что релиз уже есть, и ничего не собирает.
|
||||||
|
|
||||||
### Что происходит
|
### Что происходит
|
||||||
|
|
||||||
1. **create-release** (Linux) — сверяет тег с версией, вырезает раздел из `CHANGELOG.md` и
|
1. **create-release** (Linux) — определяет тег (из пуша тега или по ветке), сверяет его с версией, вырезает раздел из `CHANGELOG.md` и
|
||||||
создаёт **черновик** релиза.
|
создаёт **черновик** релиза.
|
||||||
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
|
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
|
||||||
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
|
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
|
||||||
|
|||||||
@@ -46,16 +46,34 @@
|
|||||||
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
|
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
|
||||||
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
|
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
|
||||||
4. `loadData()` читает профили и сборки и сортирует их по `id`.
|
4. `loadData()` читает профили и сборки и сортирует их по `id`.
|
||||||
5. `ensureDataFiles()` создаёт недостающие файлы.
|
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
|
||||||
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
(см. ниже).
|
||||||
|
6. `ensureDataFiles()` создаёт недостающие файлы.
|
||||||
|
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
||||||
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
|
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
|
||||||
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
|
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
|
||||||
7. Подписка на сигналы сервисов.
|
8. Подписка на сигналы сервисов.
|
||||||
|
|
||||||
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
||||||
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
||||||
моменту QML уже подписан на сигнал.
|
моменту QML уже подписан на сигнал.
|
||||||
|
|
||||||
|
### Сборка из папки игры
|
||||||
|
|
||||||
|
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
|
||||||
|
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
|
||||||
|
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
|
||||||
|
пользователя «сборок нет», и импорта тогда нет.
|
||||||
|
|
||||||
|
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
|
||||||
|
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
|
||||||
|
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
|
||||||
|
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
|
||||||
|
|
||||||
|
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
|
||||||
|
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
|
||||||
|
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
|
||||||
|
|
||||||
## Файлы на диске
|
## Файлы на диске
|
||||||
|
|
||||||
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# gamedirimport.h
|
||||||
|
|
||||||
|
`gamedirimport.h`, `gamedirimport.cpp`
|
||||||
|
|
||||||
|
Пространство имён `GameDirImport`: что уже лежит в папке игры, когда лаунчер запускают впервые.
|
||||||
|
Состояния нет, сеть не нужна, файлы только читаются. Вызывает его
|
||||||
|
[LauncherBackend](LauncherBackend.md) при первом запуске, решение описано в
|
||||||
|
[ADR-0001](../adr/0001-import-existing-minecraft.md).
|
||||||
|
|
||||||
|
## detect(gameDir)
|
||||||
|
|
||||||
|
Возвращает `std::optional<Detected>`: `minecraftVersion`, `resolvedVersionId`, `loader`,
|
||||||
|
`loaderVersion` и `source` — откуда взята версия (`launcher_profiles`, `versions` или `content`).
|
||||||
|
|
||||||
|
`nullopt` — импортировать нечего: в `versions/` нет ни одной установленной версии и нет признаков
|
||||||
|
того, что в папке играли. Признаки — непустые `mods`, `saves`, `resourcepacks`, `shaderpacks`,
|
||||||
|
`config` или файлы `options.txt`, `servers.dat`. Служебные файлы других лаунчеров
|
||||||
|
(`launcher_log.txt`, `webcache2`) не считаются: иначе пустая папка официального лаунчера давала
|
||||||
|
бы сборку без версии.
|
||||||
|
|
||||||
|
Версия выбирается так:
|
||||||
|
|
||||||
|
1. профили из `launcher_profiles.json`, у которых `lastVersionId` установлен, по убыванию
|
||||||
|
`lastUsed` (профиль без даты считается самым старым). `latest-release` и `latest-snapshot` без
|
||||||
|
`lastVersionId` пропускаются: без манифеста не узнать, во что они разворачивались;
|
||||||
|
2. установленные версии по убыванию времени правки `<id>/<id>.json`.
|
||||||
|
|
||||||
|
Версия с нечитаемым описанием пропускается. Если в `mods/` есть хотя бы один `.jar`, сначала
|
||||||
|
ищется профиль с `inheritsFrom` (сперва в первом списке, потом во втором), и только потом берётся
|
||||||
|
что есть. Не нашлось ничего — `source` равен `content`, поля версии пустые.
|
||||||
|
|
||||||
|
У профиля с `inheritsFrom` версия игры — это `inheritsFrom`, а `resolvedVersionId` — его `id`,
|
||||||
|
поэтому запустится ровно найденный профиль, даже если модлоадер не опознан (OptiFine, LabyMod).
|
||||||
|
У профиля без `inheritsFrom` версия — сам `id`.
|
||||||
|
|
||||||
|
## loaderOf(versionJson)
|
||||||
|
|
||||||
|
Модлоадер по `libraries[].name` описания профиля:
|
||||||
|
|
||||||
|
| Библиотека | `key` | `version` |
|
||||||
|
|------------|-------|-----------|
|
||||||
|
| `net.fabricmc:fabric-loader:<v>` | `fabric` | `<v>` |
|
||||||
|
| `org.quiltmc:quilt-loader:<v>` | `quilt` | `<v>` |
|
||||||
|
| `net.neoforged:neoforge:<v>` | `neoforge` | `<v>` |
|
||||||
|
| `net.neoforged:forge:<mc>-<v>` | `neoforge` | `<v>` (NeoForge для 1.20.1) |
|
||||||
|
| `net.minecraftforge:forge:<mc>-<v>` | `forge` | `<v>` |
|
||||||
|
|
||||||
|
Префикс `<mc>-` срезается, хвост `-1.7.10` у старых Forge остаётся: в каталоге
|
||||||
|
[ModLoaderVersionService](ModLoaderVersionService.md) версии записаны так же, и карточка сборки
|
||||||
|
найдёт выбранную строку.
|
||||||
|
|
||||||
|
Если таких библиотек нет, смотрит на `id`, который дают установщики: `fabric-loader-<v>-<mc>`,
|
||||||
|
`quilt-loader-<v>-<mc>`, `neoforge-<v>`, `<mc>-forge-<v>`. Не опознано — пустой `key`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
приложения общий контекст OpenGL уже не выставить.
|
приложения общий контекст OpenGL уже не выставить.
|
||||||
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
||||||
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
|
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
|
||||||
(берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На
|
(в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её
|
||||||
|
через `Qt.application.version`), организация `GaleonDev`, домен `galeonworx.com`. На
|
||||||
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
|
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
|
||||||
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
||||||
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ LauncherBackend ──────────────── Loc (Localizati
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
|
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
|
||||||
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
|
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
|
||||||
|
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
|
||||||
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
|
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
|
||||||
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||||
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
|
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
|
||||||
|
|||||||
+7
-2
@@ -25,6 +25,10 @@
|
|||||||
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
|
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
|
||||||
правом нижнем углу, потому что остальные углы заняты.
|
правом нижнем углу, потому что остальные углы заняты.
|
||||||
|
|
||||||
|
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
|
||||||
|
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
|
||||||
|
(см. [main.cpp](../cpp/main.md)).
|
||||||
|
|
||||||
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
|
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
|
||||||
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
|
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
|
||||||
наоборот. Панель смены сборки отменить нельзя.
|
наоборот. Панель смены сборки отменить нельзя.
|
||||||
@@ -63,8 +67,9 @@
|
|||||||
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
|
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
|
||||||
| `javaRuntimeInstalled` | обновляет строку Java в настройках |
|
| `javaRuntimeInstalled` | обновляет строку Java в настройках |
|
||||||
|
|
||||||
Кнопка запуска выключена, пока `busy` или `gameRunning`. Кнопка активной сборки выключена во время
|
Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`;
|
||||||
игры и смены сборки.
|
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
|
||||||
|
числе сезонной сборки) доступна только отмена на панели загрузки.
|
||||||
|
|
||||||
## Профили
|
## Профили
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,9 @@
|
|||||||
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
|
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
|
||||||
без сети.
|
без сети.
|
||||||
|
|
||||||
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
|
Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только
|
||||||
главного окна, и случайный щелчок мимо окна не должен её закрыть.
|
на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна
|
||||||
|
продолжается.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -49,6 +49,7 @@ CMake, тесты появятся в панели «Тесты».
|
|||||||
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
|
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
|
||||||
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
|
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
|
||||||
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
|
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
|
||||||
|
| unit | `tst_gamedirimport` | выбор версии по `launcher_profiles.json` и `versions/`, модлоадер по библиотекам и id, папка не меняется |
|
||||||
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
|
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
|
||||||
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
|
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
|
||||||
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
|
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
|
||||||
@@ -86,7 +87,8 @@ CMake, тесты появятся в панели «Тесты».
|
|||||||
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
|
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
|
||||||
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
|
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
|
||||||
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
|
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
|
||||||
сообщением, а в CI на Linux (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
|
сообщением, а в CI на всех ОС (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
|
||||||
|
QML-тесты также завершаются ошибкой, если стенд не поднялся.
|
||||||
|
|
||||||
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
|
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
|
||||||
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
|
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
|
||||||
@@ -111,7 +113,6 @@ CMake, тесты появятся в панели «Тесты».
|
|||||||
| Тест | Проблема |
|
| Тест | Проблема |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» |
|
| `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_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` |
|
||||||
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
|
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
|
||||||
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
|
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
|
||||||
@@ -156,7 +157,8 @@ WebEngine задерживала бы релизы. Перед выпуском
|
|||||||
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
|
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
|
||||||
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
|
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
|
||||||
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
|
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
|
||||||
`hang`, `drop`, `truncateTo`.
|
`hang`, `drop`, `truncateTo`. Для заданного порядка ответов установите `deferred = true`
|
||||||
|
и вызовите `releaseResponse(url)` после обработки другого ответа клиентом.
|
||||||
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
|
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
|
||||||
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
|
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
|
||||||
`Fixtures::readTemplate()`.
|
`Fixtures::readTemplate()`.
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#include "gamedirimport.h"
|
||||||
|
|
||||||
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QList>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Признаки того, что в папке играли: это принадлежит сборке. Служебные файлы
|
||||||
|
// чужих лаунчеров (launcher_log.txt, webcache2 и т. п.) сюда не входят, иначе
|
||||||
|
// пустая папка официального лаунчера давала бы сборку без версии.
|
||||||
|
const QStringList kContentMarkers = {QStringLiteral("mods"),
|
||||||
|
QStringLiteral("saves"),
|
||||||
|
QStringLiteral("resourcepacks"),
|
||||||
|
QStringLiteral("shaderpacks"),
|
||||||
|
QStringLiteral("config"),
|
||||||
|
QStringLiteral("options.txt"),
|
||||||
|
QStringLiteral("servers.dat")};
|
||||||
|
|
||||||
|
struct Candidate
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QJsonObject json;
|
||||||
|
bool inherits = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
QJsonObject readObject(const QString &path)
|
||||||
|
{
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return {};
|
||||||
|
return QJsonDocument::fromJson(file.readAll()).object();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString versionJsonPath(const QString &gameDir, const QString &id)
|
||||||
|
{
|
||||||
|
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasContent(const QString &gameDir)
|
||||||
|
{
|
||||||
|
for (const QString &name : kContentMarkers) {
|
||||||
|
const QFileInfo info(gameDir + u'/' + name);
|
||||||
|
if (info.isFile())
|
||||||
|
return true;
|
||||||
|
if (info.isDir() && !QDir(info.filePath()).isEmpty())
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasMods(const QString &gameDir)
|
||||||
|
{
|
||||||
|
return !QDir(gameDir + QStringLiteral("/mods"))
|
||||||
|
.entryList({QStringLiteral("*.jar")}, QDir::Files)
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QDateTime parseLastUsed(const QString &text)
|
||||||
|
{
|
||||||
|
QDateTime when = QDateTime::fromString(text, Qt::ISODateWithMs);
|
||||||
|
if (!when.isValid())
|
||||||
|
when = QDateTime::fromString(text, Qt::ISODate);
|
||||||
|
return when;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Версии из launcher_profiles.json, которые есть в versions/, свежие сверху.
|
||||||
|
// Профили latest-release/latest-snapshot без lastVersionId пропускаются: без
|
||||||
|
// манифеста не узнать, во что они разворачивались.
|
||||||
|
QStringList profileVersions(const QString &gameDir, const QStringList &installed)
|
||||||
|
{
|
||||||
|
const QJsonObject profiles =
|
||||||
|
readObject(gameDir + QStringLiteral("/launcher_profiles.json"))
|
||||||
|
.value(QStringLiteral("profiles"))
|
||||||
|
.toObject();
|
||||||
|
|
||||||
|
struct Used
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QDateTime when;
|
||||||
|
};
|
||||||
|
QList<Used> used;
|
||||||
|
for (auto it = profiles.constBegin(); it != profiles.constEnd(); ++it) {
|
||||||
|
const QJsonObject profile = it.value().toObject();
|
||||||
|
const QString id = profile.value(QStringLiteral("lastVersionId")).toString();
|
||||||
|
if (id.isEmpty() || !installed.contains(id))
|
||||||
|
continue;
|
||||||
|
used.append({id, parseLastUsed(profile.value(QStringLiteral("lastUsed")).toString())});
|
||||||
|
}
|
||||||
|
// Профиль без даты считается самым старым.
|
||||||
|
std::stable_sort(used.begin(), used.end(), [](const Used &a, const Used &b) {
|
||||||
|
if (a.when.isValid() != b.when.isValid())
|
||||||
|
return a.when.isValid();
|
||||||
|
return a.when > b.when;
|
||||||
|
});
|
||||||
|
|
||||||
|
QStringList ids;
|
||||||
|
for (const Used &u : used) {
|
||||||
|
if (!ids.contains(u.id))
|
||||||
|
ids << u.id;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установленные версии по времени правки описания, свежие сверху.
|
||||||
|
QStringList versionsByMtime(const QString &gameDir, QStringList installed)
|
||||||
|
{
|
||||||
|
std::stable_sort(installed.begin(), installed.end(), [&](const QString &a, const QString &b) {
|
||||||
|
return QFileInfo(versionJsonPath(gameDir, a)).lastModified()
|
||||||
|
> QFileInfo(versionJsonPath(gameDir, b)).lastModified();
|
||||||
|
});
|
||||||
|
return installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Candidate> load(const QString &gameDir, const QStringList &ids)
|
||||||
|
{
|
||||||
|
QList<Candidate> out;
|
||||||
|
for (const QString &id : ids) {
|
||||||
|
const QJsonObject json = readObject(versionJsonPath(gameDir, id));
|
||||||
|
// Битое описание не запустится — такую версию не предлагаем.
|
||||||
|
if (json.isEmpty())
|
||||||
|
continue;
|
||||||
|
out.append({id, json, !json.value(QStringLiteral("inheritsFrom")).toString().isEmpty()});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "1.20.1-47.2.0" → "47.2.0". Хвост "-1.7.10" у старых Forge остаётся: в
|
||||||
|
// каталоге модлоадеров версии записаны так же.
|
||||||
|
QString stripGamePrefix(const QString &version, const QString &game)
|
||||||
|
{
|
||||||
|
if (!game.isEmpty() && version.startsWith(game + u'-'))
|
||||||
|
return version.mid(game.size() + 1);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace GameDirImport {
|
||||||
|
|
||||||
|
LoaderInfo loaderOf(const QJsonObject &versionJson)
|
||||||
|
{
|
||||||
|
const QString game = versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
||||||
|
|
||||||
|
for (const QJsonValue &value : versionJson.value(QStringLiteral("libraries")).toArray()) {
|
||||||
|
// group:artifact:version[:classifier]
|
||||||
|
const QStringList parts = value.toObject().value(QStringLiteral("name")).toString().split(u':');
|
||||||
|
if (parts.size() < 3)
|
||||||
|
continue;
|
||||||
|
const QString coord = parts[0] + u':' + parts[1];
|
||||||
|
const QString version = parts[2];
|
||||||
|
if (coord == QLatin1String("net.fabricmc:fabric-loader"))
|
||||||
|
return {QStringLiteral("fabric"), version};
|
||||||
|
if (coord == QLatin1String("org.quiltmc:quilt-loader"))
|
||||||
|
return {QStringLiteral("quilt"), version};
|
||||||
|
if (coord == QLatin1String("net.neoforged:neoforge"))
|
||||||
|
return {QStringLiteral("neoforge"), version};
|
||||||
|
// NeoForge для 1.20.1 публиковался под старым именем артефакта.
|
||||||
|
if (coord == QLatin1String("net.neoforged:forge"))
|
||||||
|
return {QStringLiteral("neoforge"), stripGamePrefix(version, game)};
|
||||||
|
if (coord == QLatin1String("net.minecraftforge:forge"))
|
||||||
|
return {QStringLiteral("forge"), stripGamePrefix(version, game)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Библиотек модлоадера в описании может не быть (их вынесли в родителя или
|
||||||
|
// описание урезали руками) — тогда смотрим на имя, которое дают установщики.
|
||||||
|
const QString id = versionJson.value(QStringLiteral("id")).toString();
|
||||||
|
for (const auto &[key, prefix] : {std::pair{QStringLiteral("fabric"), QStringLiteral("fabric-loader-")},
|
||||||
|
std::pair{QStringLiteral("quilt"), QStringLiteral("quilt-loader-")}}) {
|
||||||
|
const QString suffix = u'-' + game;
|
||||||
|
if (!game.isEmpty() && id.startsWith(prefix) && id.endsWith(suffix))
|
||||||
|
return {key, id.mid(prefix.size(), id.size() - prefix.size() - suffix.size())};
|
||||||
|
}
|
||||||
|
if (id.startsWith(QLatin1String("neoforge-")))
|
||||||
|
return {QStringLiteral("neoforge"), id.mid(9)};
|
||||||
|
const QString forgeInfix = game + QStringLiteral("-forge-");
|
||||||
|
if (!game.isEmpty() && id.startsWith(forgeInfix))
|
||||||
|
return {QStringLiteral("forge"), id.mid(forgeInfix.size())};
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<Detected> detect(const QString &gameDir)
|
||||||
|
{
|
||||||
|
if (gameDir.isEmpty() || !QFileInfo(gameDir).isDir())
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
const QStringList installed = VersionLoader::installedVersions(gameDir);
|
||||||
|
const bool content = hasContent(gameDir);
|
||||||
|
if (installed.isEmpty() && !content)
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
const QList<Candidate> fromProfiles = load(gameDir, profileVersions(gameDir, installed));
|
||||||
|
const QList<Candidate> fromVersions = load(gameDir, versionsByMtime(gameDir, installed));
|
||||||
|
|
||||||
|
// С модами в папке ванильная версия почти наверняка не та, с которой играли:
|
||||||
|
// сначала ищем профиль модлоадера, и только потом берём что есть.
|
||||||
|
const Candidate *chosen = nullptr;
|
||||||
|
QString source;
|
||||||
|
auto pick = [&](const QList<Candidate> &list, const QString &from, bool needInherits) {
|
||||||
|
if (chosen)
|
||||||
|
return;
|
||||||
|
for (const Candidate &c : list) {
|
||||||
|
if (!needInherits || c.inherits) {
|
||||||
|
chosen = &c;
|
||||||
|
source = from;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (hasMods(gameDir)) {
|
||||||
|
pick(fromProfiles, QStringLiteral("launcher_profiles"), true);
|
||||||
|
pick(fromVersions, QStringLiteral("versions"), true);
|
||||||
|
}
|
||||||
|
pick(fromProfiles, QStringLiteral("launcher_profiles"), false);
|
||||||
|
pick(fromVersions, QStringLiteral("versions"), false);
|
||||||
|
|
||||||
|
Detected result;
|
||||||
|
if (!chosen) {
|
||||||
|
result.source = QStringLiteral("content");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.source = source;
|
||||||
|
if (chosen->inherits) {
|
||||||
|
result.minecraftVersion = chosen->json.value(QStringLiteral("inheritsFrom")).toString();
|
||||||
|
result.resolvedVersionId = chosen->id;
|
||||||
|
const LoaderInfo loader = loaderOf(chosen->json);
|
||||||
|
result.loader = loader.key;
|
||||||
|
result.loaderVersion = loader.version;
|
||||||
|
} else {
|
||||||
|
result.minecraftVersion = chosen->id;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
// Что уже разложено в папке игры, когда лаунчер запускают впервые. Только
|
||||||
|
// читает файлы: .minecraft не меняется ни при каком исходе.
|
||||||
|
namespace GameDirImport {
|
||||||
|
|
||||||
|
// Модлоадер, найденный в описании профиля versions/<id>/<id>.json.
|
||||||
|
struct LoaderInfo
|
||||||
|
{
|
||||||
|
QString key; // "" | forge | fabric | neoforge | quilt
|
||||||
|
QString version; // в том же виде, что и в каталоге ModLoaderVersionService
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сборка, которую стоит завести под содержимое папки игры.
|
||||||
|
struct Detected
|
||||||
|
{
|
||||||
|
QString minecraftVersion; // inheritsFrom профиля, а без него — сам id
|
||||||
|
QString resolvedVersionId; // id профиля, если он наследует другую версию
|
||||||
|
QString loader;
|
||||||
|
QString loaderVersion;
|
||||||
|
// Откуда взята версия: "launcher_profiles", "versions" или "content" —
|
||||||
|
// версии не нашлось, но моды или миры есть. Для тестов и отладки.
|
||||||
|
QString source;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Модлоадер по библиотекам профиля, а если по ним не понять — по его id.
|
||||||
|
// Незнакомый профиль (OptiFine, LabyMod) даёт пустой key.
|
||||||
|
LoaderInfo loaderOf(const QJsonObject &versionJson);
|
||||||
|
|
||||||
|
// nullopt — в папке нет ни установленных версий, ни модов, миров и настроек игры.
|
||||||
|
std::optional<Detected> detect(const QString &gameDir);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "launcherversion.h"
|
||||||
#include "minecraftversion.h"
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"strings": {
|
"strings": {
|
||||||
|
"app.label.version": {
|
||||||
|
"ru": "Версия: %1",
|
||||||
|
"en": "Version: %1"
|
||||||
|
},
|
||||||
"app.title": {
|
"app.title": {
|
||||||
"ru": "BiLauncher",
|
"ru": "BiLauncher",
|
||||||
"en": "BiLauncher"
|
"en": "BiLauncher"
|
||||||
@@ -379,6 +383,10 @@
|
|||||||
"ru": "Активна сборка «%1»",
|
"ru": "Активна сборка «%1»",
|
||||||
"en": "Build \"%1\" is active"
|
"en": "Build \"%1\" is active"
|
||||||
},
|
},
|
||||||
|
"build.status.imported": {
|
||||||
|
"ru": "В папке игры уже были файлы Minecraft, под них создана сборка «%1». Проверьте её версию и модлоадер в окне сборок.",
|
||||||
|
"en": "The game folder already had Minecraft files, so the build \"%1\" was created for them. Check its version and mod loader in the builds window."
|
||||||
|
},
|
||||||
"build.status.named": {
|
"build.status.named": {
|
||||||
"ru": "Сборка «%1»: %2",
|
"ru": "Сборка «%1»: %2",
|
||||||
"en": "Build \"%1\": %2"
|
"en": "Build \"%1\": %2"
|
||||||
@@ -387,6 +395,10 @@
|
|||||||
"ru": "Готова к запуску",
|
"ru": "Готова к запуску",
|
||||||
"en": "Ready to launch"
|
"en": "Ready to launch"
|
||||||
},
|
},
|
||||||
|
"build.title.fromGameDir": {
|
||||||
|
"ru": "Сборка из папки игры",
|
||||||
|
"en": "Build from the game folder"
|
||||||
|
},
|
||||||
"build.title.numbered": {
|
"build.title.numbered": {
|
||||||
"ru": "Сборка %1",
|
"ru": "Сборка %1",
|
||||||
"en": "Build %1"
|
"en": "Build %1"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "msaauthservice.h"
|
#include "msaauthservice.h"
|
||||||
#include "modloaderversionservice.h"
|
#include "modloaderversionservice.h"
|
||||||
#include "buildswitcher.h"
|
#include "buildswitcher.h"
|
||||||
|
#include "gamedirimport.h"
|
||||||
#include "seasonalbuildservice.h"
|
#include "seasonalbuildservice.h"
|
||||||
#include "seasonalpackdownloader.h"
|
#include "seasonalpackdownloader.h"
|
||||||
|
|
||||||
@@ -97,11 +98,20 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
|||||||
if (!LauncherPaths::ensureRootExists(&storageError))
|
if (!LauncherPaths::ensureRootExists(&storageError))
|
||||||
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
|
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
|
||||||
|
|
||||||
|
// Первый запуск — ни настроек, ни списка сборок, ни старого versions.json.
|
||||||
|
// Смотреть надо до loadData(): битый файл она отводит в .bak, а
|
||||||
|
// ensureDataFiles() создаёт пустые. Пустой customBuilds.json — это уже
|
||||||
|
// решение пользователя «сборок нет», и сборку из папки игры мы не заводим.
|
||||||
|
const bool firstRun = !QFile::exists(LauncherPaths::settingsFile())
|
||||||
|
&& !QFile::exists(LauncherPaths::customBuildsFile())
|
||||||
|
&& !QFile::exists(LauncherPaths::legacyCustomBuildsFile());
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
||||||
// создаст пустой customBuilds.json поверх старых данных.
|
// создаст пустой customBuilds.json поверх старых данных.
|
||||||
migrateLegacyBuildsFile();
|
migrateLegacyBuildsFile();
|
||||||
loadData();
|
loadData();
|
||||||
|
const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString();
|
||||||
ensureDataFiles();
|
ensureDataFiles();
|
||||||
|
|
||||||
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
||||||
@@ -370,6 +380,10 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
||||||
|
// Ошибки хранилища идут следом: плашка одна, и важнее показать их.
|
||||||
|
if (!importedBuild.isEmpty()) {
|
||||||
|
QTimer::singleShot(0, this, [this, importedBuild] { emit gameDirImported(importedBuild); });
|
||||||
|
}
|
||||||
if (!m_storageIssues.isEmpty()) {
|
if (!m_storageIssues.isEmpty()) {
|
||||||
QTimer::singleShot(0, this, [this] {
|
QTimer::singleShot(0, this, [this] {
|
||||||
emit launchError(m_storageIssues.join(u'\n'));
|
emit launchError(m_storageIssues.join(u'\n'));
|
||||||
@@ -2483,6 +2497,36 @@ bool LauncherBackend::migrateLegacyBuildsFile()
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сборка описывает то, что уже разложено в .minecraft, поэтому BuildSwitcher не
|
||||||
|
// нужен: активная сборка без архива — штатное состояние. Через addCustomBuild()
|
||||||
|
// не идём, чтобы при старте не началась установка.
|
||||||
|
QString LauncherBackend::importGameDir()
|
||||||
|
{
|
||||||
|
const std::optional<GameDirImport::Detected> found = GameDirImport::detect(gameDir());
|
||||||
|
if (!found)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
CustomBuild build;
|
||||||
|
build.id = m_nextBuildId++;
|
||||||
|
build.minecraftVersion = found->minecraftVersion;
|
||||||
|
build.resolvedVersionId = found->resolvedVersionId;
|
||||||
|
build.loader = found->loader;
|
||||||
|
build.loaderVersion = found->loaderVersion;
|
||||||
|
if (build.minecraftVersion.isEmpty()) {
|
||||||
|
build.name = Loc::text("build.title.fromGameDir");
|
||||||
|
} else if (const auto loader = loaderFromKey(build.loader)) {
|
||||||
|
build.name = loaderTitle(*loader) + u' ' + build.minecraftVersion;
|
||||||
|
} else {
|
||||||
|
build.name = QStringLiteral("Minecraft ") + build.minecraftVersion;
|
||||||
|
}
|
||||||
|
m_builds.append(build);
|
||||||
|
|
||||||
|
m_settings.insert(QStringLiteral("activeBuildId"), build.id);
|
||||||
|
saveSettings();
|
||||||
|
saveCustomBuilds();
|
||||||
|
return build.name;
|
||||||
|
}
|
||||||
|
|
||||||
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
|
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
|
||||||
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
|
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
|
||||||
void LauncherBackend::ensureDataFiles()
|
void LauncherBackend::ensureDataFiles()
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ signals:
|
|||||||
// Сезонная сборка установлена и активна — можно запускать игру.
|
// Сезонная сборка установлена и активна — можно запускать игру.
|
||||||
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
||||||
|
|
||||||
|
// Первый запуск: по содержимому папки игры заведена активная сборка.
|
||||||
|
void gameDirImported(const QString &buildName);
|
||||||
|
|
||||||
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
||||||
void javaRuntimeInstalled(const QString &runtimeId);
|
void javaRuntimeInstalled(const QString &runtimeId);
|
||||||
|
|
||||||
@@ -313,6 +316,9 @@ private:
|
|||||||
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
||||||
// после переименования. true — миграция прошла, читать нужно новый файл.
|
// после переименования. true — миграция прошла, читать нужно новый файл.
|
||||||
bool migrateLegacyBuildsFile();
|
bool migrateLegacyBuildsFile();
|
||||||
|
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
|
||||||
|
// Папку игры только читает. Возвращает имя сборки или пустую строку.
|
||||||
|
QString importGameDir();
|
||||||
|
|
||||||
void invalidateCatalog();
|
void invalidateCatalog();
|
||||||
void rebuildCatalog() const;
|
void rebuildCatalog() const;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "launcherpaths.h"
|
#include "launcherpaths.h"
|
||||||
|
#include "launcherversion.h"
|
||||||
#include "localization.h"
|
#include "localization.h"
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
|||||||
{
|
{
|
||||||
int outstanding = 2;
|
int outstanding = 2;
|
||||||
QHash<QString, QList<LoaderVersionEntry>> merged;
|
QHash<QString, QList<LoaderVersionEntry>> merged;
|
||||||
|
QHash<QString, QList<LoaderVersionEntry>> legacy;
|
||||||
QHash<QString, QString> recommended;
|
QHash<QString, QString> recommended;
|
||||||
bool primaryOk = false;
|
bool primaryOk = false;
|
||||||
QString warning;
|
QString warning;
|
||||||
@@ -258,6 +259,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (auto it = state->legacy.cbegin(); it != state->legacy.cend(); ++it)
|
||||||
|
state->merged[it.key()] += it.value();
|
||||||
for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
|
for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
|
||||||
const QString promoted = state->recommended.value(it.key());
|
const QString promoted = state->recommended.value(it.key());
|
||||||
if (promoted.isEmpty())
|
if (promoted.isEmpty())
|
||||||
@@ -317,10 +320,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
|||||||
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
|
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
|
||||||
legacy->deleteLater();
|
legacy->deleteLater();
|
||||||
if (legacy->error() == QNetworkReply::NoError) {
|
if (legacy->error() == QNetworkReply::NoError) {
|
||||||
const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
state->legacy = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
||||||
/*legacyNeoForge=*/true);
|
/*legacyNeoForge=*/true);
|
||||||
for (auto it = extra.constBegin(); it != extra.constEnd(); ++it)
|
|
||||||
state->merged[it.key()] += it.value();
|
|
||||||
}
|
}
|
||||||
settle();
|
settle();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
; Установщик BiLauncher для Windows (Inno Setup 6).
|
#ifndef AppVersion
|
||||||
;
|
#error Передайте версию: /DAppVersion=X.Y.Z
|
||||||
; Собирается в CI после `cmake --install`:
|
|
||||||
; iscc /DAppVersion=1.0.0 /DSourceDir=C:\...\stage /DOutputDir=C:\...\dist packaging\windows\BiLauncher.iss
|
|
||||||
; SourceDir — префикс установки: bin\ с BiLauncher.exe и развёрнутым windeployqt Qt,
|
|
||||||
; share\doc\BiLauncher\ с лицензиями.
|
|
||||||
|
|
||||||
#ifndef AppVersion
|
|
||||||
#error Передайте версию: /DAppVersion=X.Y.Z
|
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceDir
|
#ifndef SourceDir
|
||||||
#error Передайте префикс установки: /DSourceDir=...
|
#error Передайте префикс установки: /DSourceDir=...
|
||||||
#endif
|
#endif
|
||||||
#ifndef OutputDir
|
#ifndef OutputDir
|
||||||
#define OutputDir "."
|
#define OutputDir "."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if Pos("-", AppVersion) > 0
|
||||||
|
#define NumericVersion Copy(AppVersion, 1, Pos("-", AppVersion) - 1)
|
||||||
|
#else
|
||||||
|
#define NumericVersion AppVersion
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
; AppId не менять между версиями — по нему обновление находит прежнюю установку.
|
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
||||||
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
AppName=BiLauncher
|
||||||
AppName=BiLauncher
|
AppVersion={#AppVersion}
|
||||||
AppVersion={#AppVersion}
|
AppVerName=BiLauncher {#AppVersion}
|
||||||
AppVerName=BiLauncher {#AppVersion}
|
AppPublisher=GaleonDev
|
||||||
AppPublisher=GaleonDev
|
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
||||||
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
||||||
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
VersionInfoVersion={#NumericVersion}
|
||||||
VersionInfoVersion={#AppVersion}
|
VersionInfoTextVersion={#AppVersion}
|
||||||
DefaultDirName={autopf}\BiLauncher
|
VersionInfoProductTextVersion={#AppVersion}
|
||||||
DefaultGroupName=BiLauncher
|
DefaultDirName={autopf}\BiLauncher
|
||||||
DisableProgramGroupPage=yes
|
DefaultGroupName=BiLauncher
|
||||||
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
DisableProgramGroupPage=yes
|
||||||
OutputDir={#OutputDir}
|
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
||||||
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
OutputDir={#OutputDir}
|
||||||
SetupIconFile=..\..\icons\icon.ico
|
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
||||||
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
SetupIconFile=..\..\icons\icon.ico
|
||||||
Compression=lzma2/ultra64
|
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
||||||
SolidCompression=yes
|
Compression=lzma2/ultra64
|
||||||
LZMANumBlockThreads=4
|
SolidCompression=yes
|
||||||
ArchitecturesAllowed=x64compatible
|
LZMANumBlockThreads=4
|
||||||
ArchitecturesInstallIn64BitMode=x64compatible
|
ArchitecturesAllowed=x64compatible
|
||||||
PrivilegesRequiredOverridesAllowed=dialog
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
WizardStyle=modern
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
|
WizardStyle=modern
|
||||||
|
|
||||||
[Languages]
|
[Languages]
|
||||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
||||||
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||||
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
||||||
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
|
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
|
||||||
@@ -58,10 +58,8 @@ qt_add_qml_module(bilauncher_testlib
|
|||||||
RESOURCES ${_module_res}
|
RESOURCES ${_module_res}
|
||||||
NO_LINT
|
NO_LINT
|
||||||
)
|
)
|
||||||
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
|
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}" "${LAUNCHER_GENERATED_DIR}")
|
||||||
target_compile_definitions(bilauncher_testlib PUBLIC
|
add_dependencies(bilauncher_testlib launcher_version)
|
||||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
|
||||||
)
|
|
||||||
target_link_libraries(bilauncher_testlib PUBLIC
|
target_link_libraries(bilauncher_testlib PUBLIC
|
||||||
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ class TestLauncherBackend : public QObject
|
|||||||
return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config);
|
return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Папка игры, в которой до лаунчера играли с Fabric. Возвращает её состав.
|
||||||
|
static QStringList populateGameDir(const QString &dir)
|
||||||
|
{
|
||||||
|
QDir(dir).removeRecursively();
|
||||||
|
const QString fabric = QStringLiteral("fabric-loader-0.15.0-1.20.1");
|
||||||
|
Fixtures::write(dir + "/versions/1.20.1/1.20.1.json", R"({"id": "1.20.1", "type": "release"})");
|
||||||
|
Fixtures::write(dir + "/versions/" + fabric + "/" + fabric + ".json",
|
||||||
|
Fixtures::read("versions/" + fabric + ".json"));
|
||||||
|
Fixtures::writeJson(dir + "/launcher_profiles.json",
|
||||||
|
{{"profiles", QJsonObject{{"fabric", QJsonObject{
|
||||||
|
{"type", "custom"}, {"lastVersionId", fabric},
|
||||||
|
{"lastUsed", "2026-03-01T10:00:00.000Z"}}}}}});
|
||||||
|
Fixtures::write(dir + "/mods/sodium.jar", "jar");
|
||||||
|
Fixtures::write(dir + "/saves/World/level.dat", "world");
|
||||||
|
Fixtures::write(dir + "/options.txt", "fov:90");
|
||||||
|
return Fixtures::listTree(dir);
|
||||||
|
}
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void initTestCase()
|
void initTestCase()
|
||||||
{
|
{
|
||||||
@@ -116,6 +134,89 @@ private slots:
|
|||||||
Localization::instance().setLanguage(QStringLiteral("en"));
|
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void firstRunImportsGameDir()
|
||||||
|
{
|
||||||
|
// Настоящий первый запуск: ни одного файла лаунчера, папка игры по умолчанию.
|
||||||
|
QFile::remove(LauncherPaths::settingsFile());
|
||||||
|
const QString game = LauncherPaths::defaultMinecraftDir();
|
||||||
|
const QStringList before = populateGameDir(game);
|
||||||
|
const QByteArray profilesBefore = Fixtures::readFile(game + "/launcher_profiles.json");
|
||||||
|
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||||
|
QVERIFY(imported.wait(2000)); // сообщение откладывается до подключения интерфейса
|
||||||
|
QCOMPARE(imported.constFirst().constFirst().toString(), QStringLiteral("Fabric Loader 1.20.1"));
|
||||||
|
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
|
||||||
|
QCOMPARE(backend->activeBuildIndex(), 0);
|
||||||
|
const QVariantMap build = backend->customBuildAt(0);
|
||||||
|
QCOMPARE(build.value("minecraftVersion").toString(), QStringLiteral("1.20.1"));
|
||||||
|
QCOMPARE(build.value("loader").toString(), QStringLiteral("fabric"));
|
||||||
|
QCOMPARE(build.value("loaderVersion").toString(), QStringLiteral("0.15.0"));
|
||||||
|
QCOMPARE(build.value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.15.0-1.20.1"));
|
||||||
|
const int id = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile()))
|
||||||
|
.array().at(0).toObject().value("id").toInt();
|
||||||
|
QCOMPARE(id, 1);
|
||||||
|
QCOMPARE(backend->settings().value("activeBuildId").toInt(), id);
|
||||||
|
QCOMPARE(Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(), id);
|
||||||
|
// Ничего не качается, ничего не переключается, папка игры та же.
|
||||||
|
QVERIFY(!backend->downloading());
|
||||||
|
QVERIFY(!backend->switching());
|
||||||
|
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||||
|
}
|
||||||
|
QCOMPARE(Fixtures::listTree(game), before);
|
||||||
|
QCOMPARE(Fixtures::readFile(game + "/launcher_profiles.json"), profilesBefore);
|
||||||
|
QVERIFY(!QFile::exists(LauncherPaths::buildStorageDir() + "/index.json"));
|
||||||
|
|
||||||
|
// Второй запуск — уже не первый: новой сборки нет.
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(imported.isEmpty());
|
||||||
|
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
|
||||||
|
}
|
||||||
|
QDir(game).removeRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
void importOnlyOnFirstRun()
|
||||||
|
{
|
||||||
|
// Настройки уже есть (init() их пишет), а списка сборок нет — это не первый запуск.
|
||||||
|
populateGameDir(m_game);
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(imported.isEmpty());
|
||||||
|
QVERIFY(backend->customBuildNames().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой список сборок — пользователь сам всё удалил; воскрешать не надо.
|
||||||
|
TestEnv::resetLauncherRoot();
|
||||||
|
QVERIFY(LauncherPaths::ensureRootExists());
|
||||||
|
const QString game = LauncherPaths::defaultMinecraftDir();
|
||||||
|
populateGameDir(game);
|
||||||
|
Fixtures::write(LauncherPaths::customBuildsFile(), "[]");
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(backend->customBuildNames().isEmpty());
|
||||||
|
}
|
||||||
|
QDir(game).removeRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
void firstRunWithEmptyGameDirCreatesNothing()
|
||||||
|
{
|
||||||
|
QFile::remove(LauncherPaths::settingsFile());
|
||||||
|
QDir(LauncherPaths::defaultMinecraftDir()).removeRecursively();
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(imported.isEmpty());
|
||||||
|
QCOMPARE(backend->activeBuildIndex(), -1);
|
||||||
|
}
|
||||||
|
|
||||||
void profilesCrudAndPersistence()
|
void profilesCrudAndPersistence()
|
||||||
{
|
{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -201,12 +201,32 @@ void FakeInternet::unroute(const QString &pattern)
|
|||||||
|
|
||||||
void FakeInternet::reset()
|
void FakeInternet::reset()
|
||||||
{
|
{
|
||||||
|
m_deferred.clear();
|
||||||
|
m_diagnostics.clear();
|
||||||
m_routes.clear();
|
m_routes.clear();
|
||||||
m_requests.clear();
|
m_requests.clear();
|
||||||
m_unmatched.clear();
|
m_unmatched.clear();
|
||||||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool FakeInternet::releaseResponse(const QString &url)
|
||||||
|
{
|
||||||
|
for (qsizetype i = 0; i < m_deferred.size(); ++i) {
|
||||||
|
if (m_deferred.at(i).url == url) {
|
||||||
|
const auto pending = m_deferred.takeAt(i);
|
||||||
|
respond(pending.socket, pending.response, pending.keepAlive);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FakeInternet::diagnostics() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("TLS backend: %1\nRequests: %2\n%3")
|
||||||
|
.arg(QSslSocket::activeBackend(), requestedUrls().join(u' '), m_diagnostics.join(u'\n'));
|
||||||
|
}
|
||||||
|
|
||||||
QList<FakeRequest> FakeInternet::requests() const
|
QList<FakeRequest> FakeInternet::requests() const
|
||||||
{
|
{
|
||||||
return m_requests;
|
return m_requests;
|
||||||
@@ -366,9 +386,13 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bool keepAlive = request.header("connection").toLower() != "close";
|
const bool keepAlive = request.header("connection").toLower() != "close";
|
||||||
QPointer<QTcpSocket> socket(connection->socket);
|
QPointer<QSslSocket> socket(connection->socket);
|
||||||
if (response.hang)
|
if (response.hang)
|
||||||
return;
|
return;
|
||||||
|
if (response.deferred) {
|
||||||
|
m_deferred.append({url, socket, response, keepAlive});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.delayMs > 0) {
|
if (response.delayMs > 0) {
|
||||||
QTimer::singleShot(response.delayMs, this,
|
QTimer::singleShot(response.delayMs, this,
|
||||||
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
||||||
@@ -378,7 +402,7 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
|||||||
emit requestServed(url);
|
emit requestServed(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive)
|
void FakeInternet::respond(QPointer<QSslSocket> socket, const FakeResponse &response, bool keepAlive)
|
||||||
{
|
{
|
||||||
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||||
return;
|
return;
|
||||||
@@ -397,20 +421,28 @@ void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &resp
|
|||||||
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
||||||
head += "\r\n";
|
head += "\r\n";
|
||||||
|
|
||||||
socket->write(head);
|
|
||||||
if (response.truncateTo >= 0) {
|
if (response.truncateTo >= 0) {
|
||||||
socket->write(response.body.left(response.truncateTo));
|
m_diagnostics << QStringLiteral("truncate: declared=%1, sent=%2")
|
||||||
socket->flush();
|
.arg(response.body.size()).arg(response.body.left(response.truncateTo).size());
|
||||||
socket->disconnectFromHost();
|
// Сначала отправляем TLS-записи с неполным телом, затем рвём TCP.
|
||||||
// Schannel (Windows) на disconnectFromHost шлёт только TLS close_notify и держит
|
// disconnectFromHost() выполняет штатное завершение TLS, а здесь
|
||||||
// TCP открытым, ожидая ответного close_notify, — клиент тогда висит до таймаута.
|
// моделируется обрыв. Готовность определяется буферами, не задержкой.
|
||||||
// Обрыв на то и обрыв: если сокет не закрылся сам, рвём соединение.
|
connect(socket, &QSslSocket::encryptedBytesWritten, socket, [this, socket](qint64) {
|
||||||
QTimer::singleShot(200, socket, [socket] {
|
QTimer::singleShot(0, socket, [this, socket] {
|
||||||
if (socket && socket->state() != QAbstractSocket::UnconnectedState)
|
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||||
|
return;
|
||||||
|
if (socket->bytesToWrite() || socket->encryptedBytesToWrite())
|
||||||
|
return;
|
||||||
|
m_diagnostics << QStringLiteral("truncate: TLS buffers drained, aborting TCP");
|
||||||
socket->abort();
|
socket->abort();
|
||||||
|
m_diagnostics << QStringLiteral("truncate: state after abort=%1").arg(socket->state());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
socket->write(head);
|
||||||
|
socket->write(response.body.left(response.truncateTo));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
socket->write(head);
|
||||||
socket->write(response.body);
|
socket->write(response.body);
|
||||||
if (close)
|
if (close)
|
||||||
socket->disconnectFromHost();
|
socket->disconnectFromHost();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ struct FakeResponse
|
|||||||
QList<QPair<QByteArray, QByteArray>> headers;
|
QList<QPair<QByteArray, QByteArray>> headers;
|
||||||
int delayMs = 0; // ответить с задержкой
|
int delayMs = 0; // ответить с задержкой
|
||||||
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
||||||
|
bool deferred = false; // ждать явного releaseResponse(), без таймера
|
||||||
bool drop = false; // оборвать соединение без ответа
|
bool drop = false; // оборвать соединение без ответа
|
||||||
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ public:
|
|||||||
|
|
||||||
// Сбросить маршруты и журнал.
|
// Сбросить маршруты и журнал.
|
||||||
void reset();
|
void reset();
|
||||||
|
bool releaseResponse(const QString &url);
|
||||||
|
QString diagnostics() const;
|
||||||
|
|
||||||
QList<FakeRequest> requests() const;
|
QList<FakeRequest> requests() const;
|
||||||
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
||||||
@@ -122,7 +125,7 @@ private:
|
|||||||
void onReadyRead(Connection *connection);
|
void onReadyRead(Connection *connection);
|
||||||
void processBuffer(Connection *connection);
|
void processBuffer(Connection *connection);
|
||||||
void dispatch(Connection *connection, FakeRequest request);
|
void dispatch(Connection *connection, FakeRequest request);
|
||||||
void respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive);
|
void respond(QPointer<QSslSocket> socket, const FakeResponse &response, bool keepAlive);
|
||||||
void startTls(Connection *connection);
|
void startTls(Connection *connection);
|
||||||
const Route *match(const FakeRequest &request) const;
|
const Route *match(const FakeRequest &request) const;
|
||||||
|
|
||||||
@@ -130,6 +133,15 @@ private:
|
|||||||
FakeResponse m_fallback;
|
FakeResponse m_fallback;
|
||||||
QList<FakeRequest> m_requests;
|
QList<FakeRequest> m_requests;
|
||||||
QStringList m_unmatched;
|
QStringList m_unmatched;
|
||||||
|
struct DeferredResponse
|
||||||
|
{
|
||||||
|
QString url;
|
||||||
|
QPointer<QSslSocket> socket;
|
||||||
|
FakeResponse response;
|
||||||
|
bool keepAlive;
|
||||||
|
};
|
||||||
|
QList<DeferredResponse> m_deferred;
|
||||||
|
QStringList m_diagnostics;
|
||||||
QByteArray m_certificate;
|
QByteArray m_certificate;
|
||||||
QByteArray m_key;
|
QByteArray m_key;
|
||||||
bool m_started = false;
|
bool m_started = false;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#include <QtTest/QTest>
|
#include <QtTest/QTest>
|
||||||
|
|
||||||
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
|
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
|
||||||
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на Linux) недоступность —
|
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на всех ОС) недоступность —
|
||||||
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
||||||
#define BILAUNCHER_START_FAKE_INTERNET(net) \
|
#define BILAUNCHER_START_FAKE_INTERNET(net) \
|
||||||
do { \
|
do { \
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "testenv.h"
|
#include "testenv.h"
|
||||||
#include "fakeinternet.h"
|
#include "fakeinternet.h"
|
||||||
|
#include "nettest.h"
|
||||||
|
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
@@ -30,9 +31,7 @@ class TestFakeInternet : public QObject
|
|||||||
private slots:
|
private slots:
|
||||||
void initTestCase()
|
void initTestCase()
|
||||||
{
|
{
|
||||||
QString error;
|
BILAUNCHER_START_FAKE_INTERNET(m_net);
|
||||||
if (!m_net.start(&error))
|
|
||||||
QSKIP(qPrintable(QStringLiteral("FakeInternet недоступен: ") + error));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void init() { m_net.reset(); }
|
void init() { m_net.reset(); }
|
||||||
@@ -100,12 +99,6 @@ private slots:
|
|||||||
response.drop = true;
|
response.drop = true;
|
||||||
return response;
|
return response;
|
||||||
}());
|
}());
|
||||||
m_net.route(QStringLiteral("https://a.test/short"), [] {
|
|
||||||
FakeResponse response = FakeResponse::bytes(QByteArray(1000, 'x'));
|
|
||||||
response.truncateTo = 10;
|
|
||||||
return response;
|
|
||||||
}());
|
|
||||||
|
|
||||||
QNetworkAccessManager manager;
|
QNetworkAccessManager manager;
|
||||||
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
|
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
|
||||||
QVERIFY(waitFinished(r500.data()));
|
QVERIFY(waitFinished(r500.data()));
|
||||||
@@ -114,10 +107,31 @@ private slots:
|
|||||||
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
|
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
|
||||||
QVERIFY(waitFinished(drop.data()));
|
QVERIFY(waitFinished(drop.data()));
|
||||||
QVERIFY(drop->error() != QNetworkReply::NoError);
|
QVERIFY(drop->error() != QNetworkReply::NoError);
|
||||||
|
}
|
||||||
|
|
||||||
|
void truncatedResponse_data()
|
||||||
|
{
|
||||||
|
QTest::addColumn<int>("bytes");
|
||||||
|
QTest::newRow("empty") << 0;
|
||||||
|
QTest::newRow("short") << 10;
|
||||||
|
QTest::newRow("multiple-tls-records") << 65536;
|
||||||
|
QTest::newRow("last-byte-missing") << 256 * 1024 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void truncatedResponse()
|
||||||
|
{
|
||||||
|
QFETCH(int, bytes);
|
||||||
|
const QByteArray body(256 * 1024, 'x');
|
||||||
|
FakeResponse cut = FakeResponse::bytes(body);
|
||||||
|
cut.truncateTo = bytes;
|
||||||
|
m_net.route(QStringLiteral("https://a.test/short"), cut);
|
||||||
|
QNetworkAccessManager manager;
|
||||||
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
|
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
|
||||||
QVERIFY(waitFinished(shortReply.data()));
|
QVERIFY2(waitFinished(shortReply.data()),
|
||||||
|
qPrintable(m_net.diagnostics() + "\nClient: " + shortReply->errorString()));
|
||||||
QVERIFY(shortReply->error() != QNetworkReply::NoError);
|
QVERIFY(shortReply->error() != QNetworkReply::NoError);
|
||||||
|
QCOMPARE(shortReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
|
||||||
|
QCOMPARE(shortReply->readAll(), body.left(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
void plainHttpThroughProxy()
|
void plainHttpThroughProxy()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
#include "modloaderversionservice.h"
|
#include "modloaderversionservice.h"
|
||||||
|
|
||||||
#include <QSignalSpy>
|
#include <QSignalSpy>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
||||||
@@ -55,6 +57,25 @@ class TestModLoaderVersionService : public QObject
|
|||||||
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
|
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool loadNeoForgeInOrder(ModLoaderVersionService &service, CallResult &result,
|
||||||
|
const QString &first, const QString &second)
|
||||||
|
{
|
||||||
|
QStringList completed;
|
||||||
|
QObject observer;
|
||||||
|
auto *network = service.findChild<QNetworkAccessManager *>();
|
||||||
|
if (!network)
|
||||||
|
qFatal("ModLoaderVersionService has no network manager");
|
||||||
|
connect(network, &QNetworkAccessManager::finished, &observer, [&](QNetworkReply *reply) {
|
||||||
|
completed << reply->url().toString();
|
||||||
|
});
|
||||||
|
service.ensureLoaded(ModLoader::NeoForge, "1.20.1", result.callback());
|
||||||
|
if (!QTest::qWaitFor([&] { return completed.contains(first) && m_net.hits(second) == 1; }, 15000))
|
||||||
|
return false;
|
||||||
|
if (!m_net.releaseResponse(second))
|
||||||
|
return false;
|
||||||
|
return QTest::qWaitFor([&] { return result.calls == 1; }, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||||
|
|
||||||
@@ -167,33 +188,30 @@ private slots:
|
|||||||
void neoForgeLegacyArtifactArrivesLast()
|
void neoForgeLegacyArtifactArrivesLast()
|
||||||
{
|
{
|
||||||
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
|
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
|
||||||
FakeResponse legacySlow = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
FakeResponse legacy = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
||||||
legacySlow.delayMs = 400;
|
legacy.deferred = true;
|
||||||
m_net.route(kNeoLegacyMeta, legacySlow);
|
m_net.route(kNeoLegacyMeta, legacy);
|
||||||
ModLoaderVersionService service;
|
ModLoaderVersionService service;
|
||||||
CallResult r;
|
CallResult r;
|
||||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
QVERIFY(loadNeoForgeInOrder(service, r, kNeoMeta, kNeoLegacyMeta));
|
||||||
QVERIFY(r.ok);
|
QVERIFY(r.ok);
|
||||||
const QList<LoaderVersionEntry> legacy = service.versions(ModLoader::NeoForge, "1.20.1");
|
const QList<LoaderVersionEntry> versions = service.versions(ModLoader::NeoForge, "1.20.1");
|
||||||
QCOMPARE(loaderVersions(legacy), QStringList({"47.1.106", "47.1.79"}));
|
QCOMPARE(loaderVersions(versions), QStringList({"47.1.106", "47.1.79"}));
|
||||||
QCOMPARE(legacy.at(0).installerUrl,
|
QCOMPARE(versions.at(0).installerUrl,
|
||||||
QUrl("https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"));
|
QUrl("https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void neoForgeLegacyArtifactArrivesFirst()
|
void neoForgeLegacyArtifactArrivesFirst()
|
||||||
{
|
{
|
||||||
// Тот же каталог, но маленький legacy-ответ приходит раньше основного.
|
// Основной ответ выпускается только после обработки legacy клиентом.
|
||||||
FakeResponse primarySlow = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
FakeResponse primary = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
||||||
primarySlow.delayMs = 400;
|
primary.deferred = true;
|
||||||
m_net.route(kNeoMeta, primarySlow);
|
m_net.route(kNeoMeta, primary);
|
||||||
ModLoaderVersionService service;
|
ModLoaderVersionService service;
|
||||||
CallResult r;
|
CallResult r;
|
||||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
QVERIFY(loadNeoForgeInOrder(service, r, kNeoLegacyMeta, kNeoMeta));
|
||||||
QVERIFY(r.ok);
|
QVERIFY(r.ok);
|
||||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
|
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
|
||||||
// В startRefresh() основной ответ делает state->merged = parsed и затирает
|
|
||||||
// уже слитые записи legacy — версии NeoForge для 1.20.1 пропадают.
|
|
||||||
QEXPECT_FAIL("", "гонка: основной maven-metadata NeoForge затирает уже пришедший legacy", Continue);
|
|
||||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
|
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ private slots:
|
|||||||
m_net.route(kPack, cut);
|
m_net.route(kPack, cut);
|
||||||
Run run(downloader);
|
Run run(downloader);
|
||||||
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
|
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
|
||||||
QVERIFY(run.waitEnd());
|
QVERIFY2(run.waitEnd(), qPrintable(m_net.diagnostics()));
|
||||||
QCOMPARE(run.failed.count(), 1);
|
QCOMPARE(run.failed.count(), 1);
|
||||||
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
|
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ public slots:
|
|||||||
seedData();
|
seedData();
|
||||||
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
|
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
|
||||||
QString error;
|
QString error;
|
||||||
if (m_net.start(&error))
|
if (!m_net.start(&error))
|
||||||
m_net.setFallback(FakeResponse::error(503));
|
qFatal("FakeInternet: %s", qPrintable(error));
|
||||||
|
m_net.setFallback(FakeResponse::error(503));
|
||||||
}
|
}
|
||||||
|
|
||||||
void qmlEngineAvailable(QQmlEngine *engine)
|
void qmlEngineAvailable(QQmlEngine *engine)
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ private slots:
|
|||||||
void initTestCase()
|
void initTestCase()
|
||||||
{
|
{
|
||||||
QString error;
|
QString error;
|
||||||
if (m_net.start(&error))
|
QVERIFY2(m_net.start(&error), qPrintable(error));
|
||||||
m_net.setFallback(FakeResponse::error(503));
|
m_net.setFallback(FakeResponse::error(503));
|
||||||
const QString game = TestEnv::freshDir(QStringLiteral("game"));
|
const QString game = TestEnv::freshDir(QStringLiteral("game"));
|
||||||
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
|
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
|
||||||
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ def check_changelog():
|
|||||||
return False
|
return False
|
||||||
version = match.group(1)
|
version = match.group(1)
|
||||||
changelog = open(os.path.join(ROOT, "CHANGELOG.md"), encoding="utf-8").read()
|
changelog = open(os.path.join(ROOT, "CHANGELOG.md"), encoding="utf-8").read()
|
||||||
if re.search(r"^## \[" + re.escape(version) + r"\]", changelog, re.M):
|
if re.search(r"^## \[" + re.escape(version) + r"(-rc[0-9]+)?\]", changelog, re.M):
|
||||||
print(f"версия {version}: раздел в CHANGELOG.md есть")
|
print(f"версия {version}: раздел в CHANGELOG.md есть")
|
||||||
else:
|
else:
|
||||||
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
|
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ foreach(test IN ITEMS
|
|||||||
tst_javalocator
|
tst_javalocator
|
||||||
tst_zlibreference
|
tst_zlibreference
|
||||||
tst_buildarchiveworker
|
tst_buildarchiveworker
|
||||||
tst_buildswitcher)
|
tst_buildswitcher
|
||||||
|
tst_gamedirimport)
|
||||||
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
|
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
|
||||||
endforeach()
|
endforeach()
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#include "testenv.h"
|
||||||
|
#include "fixtures.h"
|
||||||
|
|
||||||
|
#include "gamedirimport.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
|
class TestGameDirImport : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
QString m_game;
|
||||||
|
|
||||||
|
void writeVersion(const QString &id, const QJsonObject &json)
|
||||||
|
{
|
||||||
|
QVERIFY(Fixtures::writeJson(m_game + "/versions/" + id + "/" + id + ".json", json));
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeVanilla(const QString &id) { writeVersion(id, {{"id", id}, {"type", "release"}}); }
|
||||||
|
|
||||||
|
void writeLoaderProfile(const QString &id, const QString &game, const QString &library)
|
||||||
|
{
|
||||||
|
QJsonArray libraries;
|
||||||
|
if (!library.isEmpty())
|
||||||
|
libraries.append(QJsonObject{{"name", library}});
|
||||||
|
writeVersion(id, {{"id", id}, {"inheritsFrom", game}, {"libraries", libraries}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeProfiles(const QJsonObject &profiles)
|
||||||
|
{
|
||||||
|
QVERIFY(Fixtures::writeJson(m_game + "/launcher_profiles.json", {{"profiles", profiles}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static QJsonObject profile(const QString &versionId, const QString &lastUsed)
|
||||||
|
{
|
||||||
|
return {{"type", "custom"}, {"lastVersionId", versionId}, {"lastUsed", lastUsed}};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мтайм ставим явно: файлы, записанные подряд, легко получают одну секунду.
|
||||||
|
void touch(const QString &id, int secondsAgo)
|
||||||
|
{
|
||||||
|
QFile file(m_game + "/versions/" + id + "/" + id + ".json");
|
||||||
|
QVERIFY(file.open(QIODevice::ReadWrite));
|
||||||
|
QVERIFY(file.setFileTime(QDateTime::currentDateTime().addSecs(-secondsAgo),
|
||||||
|
QFileDevice::FileModificationTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void init() { m_game = TestEnv::freshDir(QStringLiteral("game")); }
|
||||||
|
|
||||||
|
void missingOrEmptyFolderGivesNothing()
|
||||||
|
{
|
||||||
|
QVERIFY(!GameDirImport::detect(m_game + "/nope"));
|
||||||
|
QVERIFY(!GameDirImport::detect(m_game));
|
||||||
|
QVERIFY(!GameDirImport::detect(QString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void sharedFoldersAndLauncherJunkAreNotABuild()
|
||||||
|
{
|
||||||
|
Fixtures::write(m_game + "/libraries/x/y.jar", "lib");
|
||||||
|
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
|
||||||
|
Fixtures::write(m_game + "/launcher_log.txt", "log");
|
||||||
|
Fixtures::write(m_game + "/webcache2/a", "a");
|
||||||
|
QVERIFY(Fixtures::writeJson(m_game + "/launcher_profiles.json", {{"profiles", QJsonObject{}}}));
|
||||||
|
QVERIFY(QDir().mkpath(m_game + "/saves")); // пустая папка — не признак
|
||||||
|
QVERIFY(!GameDirImport::detect(m_game));
|
||||||
|
}
|
||||||
|
|
||||||
|
void contentWithoutVersionGivesEmptyBuild()
|
||||||
|
{
|
||||||
|
Fixtures::write(m_game + "/saves/World/level.dat", "w");
|
||||||
|
const auto found = GameDirImport::detect(m_game);
|
||||||
|
QVERIFY(found);
|
||||||
|
QCOMPARE(found->source, QStringLiteral("content"));
|
||||||
|
QVERIFY(found->minecraftVersion.isEmpty());
|
||||||
|
QVERIFY(found->resolvedVersionId.isEmpty());
|
||||||
|
QVERIFY(found->loader.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void lastUsedProfileWins()
|
||||||
|
{
|
||||||
|
writeVanilla("1.20.1");
|
||||||
|
writeVanilla("1.21.1");
|
||||||
|
writeVanilla("1.19.4");
|
||||||
|
touch("1.19.4", 0); // самая свежая по мтайму, но профили важнее
|
||||||
|
writeProfiles({{"a", profile("1.20.1", "2026-01-01T10:00:00.000Z")},
|
||||||
|
{"b", profile("1.21.1", "2026-03-01T10:00:00.000Z")},
|
||||||
|
// Версии нет на диске — пропускается, даже будучи последней.
|
||||||
|
{"c", profile("1.8.9", "2026-05-01T10:00:00.000Z")},
|
||||||
|
// latest-release без lastVersionId не разрешить без манифеста.
|
||||||
|
{"d", QJsonObject{{"type", "latest-release"}, {"lastUsed", "2026-06-01T10:00:00Z"}}}});
|
||||||
|
const auto found = GameDirImport::detect(m_game);
|
||||||
|
QVERIFY(found);
|
||||||
|
QCOMPARE(found->source, QStringLiteral("launcher_profiles"));
|
||||||
|
QCOMPARE(found->minecraftVersion, QStringLiteral("1.21.1"));
|
||||||
|
QVERIFY(found->resolvedVersionId.isEmpty());
|
||||||
|
QVERIFY(found->loader.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void withoutProfilesNewestVersionWins()
|
||||||
|
{
|
||||||
|
writeVanilla("1.20.1");
|
||||||
|
writeVanilla("1.21.1");
|
||||||
|
touch("1.20.1", 0);
|
||||||
|
touch("1.21.1", 3600);
|
||||||
|
Fixtures::write(m_game + "/launcher_profiles.json", "{ broken");
|
||||||
|
const auto found = GameDirImport::detect(m_game);
|
||||||
|
QVERIFY(found);
|
||||||
|
QCOMPARE(found->source, QStringLiteral("versions"));
|
||||||
|
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void brokenVersionJsonIsSkipped()
|
||||||
|
{
|
||||||
|
writeVanilla("1.20.1");
|
||||||
|
Fixtures::write(m_game + "/versions/1.21.1/1.21.1.json", "not json");
|
||||||
|
touch("1.20.1", 3600);
|
||||||
|
touch("1.21.1", 0);
|
||||||
|
const auto found = GameDirImport::detect(m_game);
|
||||||
|
QVERIFY(found);
|
||||||
|
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void modsPreferLoaderProfile()
|
||||||
|
{
|
||||||
|
writeVanilla("1.20.1");
|
||||||
|
writeLoaderProfile("fabric-loader-0.15.0-1.20.1", "1.20.1", "net.fabricmc:fabric-loader:0.15.0");
|
||||||
|
// Последней запускали ванилу, но моды лежат — значит, играли с Fabric.
|
||||||
|
writeProfiles({{"vanilla", profile("1.20.1", "2026-03-01T10:00:00.000Z")}});
|
||||||
|
Fixtures::write(m_game + "/mods/sodium.jar", "jar");
|
||||||
|
const auto found = GameDirImport::detect(m_game);
|
||||||
|
QVERIFY(found);
|
||||||
|
QCOMPARE(found->source, QStringLiteral("versions"));
|
||||||
|
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||||
|
QCOMPARE(found->resolvedVersionId, QStringLiteral("fabric-loader-0.15.0-1.20.1"));
|
||||||
|
QCOMPARE(found->loader, QStringLiteral("fabric"));
|
||||||
|
QCOMPARE(found->loaderVersion, QStringLiteral("0.15.0"));
|
||||||
|
|
||||||
|
// Без модов тот же набор даёт ванилу из профилей.
|
||||||
|
QVERIFY(QFile::remove(m_game + "/mods/sodium.jar"));
|
||||||
|
QCOMPARE(GameDirImport::detect(m_game)->resolvedVersionId, QString());
|
||||||
|
}
|
||||||
|
|
||||||
|
void fabricFixtureIsRecognised()
|
||||||
|
{
|
||||||
|
const QJsonObject json = Fixtures::readJson("versions/fabric-loader-0.15.0-1.20.1.json");
|
||||||
|
const auto loader = GameDirImport::loaderOf(json);
|
||||||
|
QCOMPARE(loader.key, QStringLiteral("fabric"));
|
||||||
|
QCOMPARE(loader.version, QStringLiteral("0.15.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void loaderFromLibraries_data()
|
||||||
|
{
|
||||||
|
QTest::addColumn<QString>("game");
|
||||||
|
QTest::addColumn<QString>("library");
|
||||||
|
QTest::addColumn<QString>("key");
|
||||||
|
QTest::addColumn<QString>("version");
|
||||||
|
|
||||||
|
QTest::newRow("quilt") << "1.20.1" << "org.quiltmc:quilt-loader:0.26.0" << "quilt" << "0.26.0";
|
||||||
|
QTest::newRow("forge-1.20.1") << "1.20.1" << "net.minecraftforge:forge:1.20.1-47.2.0:universal"
|
||||||
|
<< "forge" << "47.2.0";
|
||||||
|
QTest::newRow("forge-1.12.2") << "1.12.2" << "net.minecraftforge:forge:1.12.2-14.23.5.2860"
|
||||||
|
<< "forge" << "14.23.5.2860";
|
||||||
|
// Как в каталоге модлоадеров: хвост "-1.7.10" остаётся.
|
||||||
|
QTest::newRow("forge-1.7.10") << "1.7.10" << "net.minecraftforge:forge:1.7.10-10.13.4.1614-1.7.10"
|
||||||
|
<< "forge" << "10.13.4.1614-1.7.10";
|
||||||
|
QTest::newRow("neoforge") << "1.21.1" << "net.neoforged:neoforge:21.1.77:universal"
|
||||||
|
<< "neoforge" << "21.1.77";
|
||||||
|
QTest::newRow("neoforge-1.20.1") << "1.20.1" << "net.neoforged:forge:1.20.1-47.1.106:universal"
|
||||||
|
<< "neoforge" << "47.1.106";
|
||||||
|
QTest::newRow("optifine") << "1.20.1" << "optifine:OptiFine:1.20.1_HD_U_I6" << "" << "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void loaderFromLibraries()
|
||||||
|
{
|
||||||
|
QFETCH(QString, game);
|
||||||
|
QFETCH(QString, library);
|
||||||
|
QFETCH(QString, key);
|
||||||
|
QFETCH(QString, version);
|
||||||
|
const QJsonObject json{{"id", "custom"}, {"inheritsFrom", game},
|
||||||
|
{"libraries", QJsonArray{QJsonObject{{"name", "com.google:guava:31.0"}},
|
||||||
|
QJsonObject{{"name", library}}}}};
|
||||||
|
const auto loader = GameDirImport::loaderOf(json);
|
||||||
|
QCOMPARE(loader.key, key);
|
||||||
|
QCOMPARE(loader.version, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loaderFromIdWhenLibrariesSilent_data()
|
||||||
|
{
|
||||||
|
QTest::addColumn<QString>("id");
|
||||||
|
QTest::addColumn<QString>("game");
|
||||||
|
QTest::addColumn<QString>("key");
|
||||||
|
QTest::addColumn<QString>("version");
|
||||||
|
|
||||||
|
QTest::newRow("fabric") << "fabric-loader-0.16.10-1.21.1" << "1.21.1" << "fabric" << "0.16.10";
|
||||||
|
QTest::newRow("quilt") << "quilt-loader-0.26.0-1.20.1" << "1.20.1" << "quilt" << "0.26.0";
|
||||||
|
QTest::newRow("forge") << "1.20.1-forge-47.2.0" << "1.20.1" << "forge" << "47.2.0";
|
||||||
|
QTest::newRow("neoforge") << "neoforge-21.1.77" << "1.21.1" << "neoforge" << "21.1.77";
|
||||||
|
QTest::newRow("unknown") << "1.20.1-OptiFine_HD_U_I6" << "1.20.1" << "" << "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void loaderFromIdWhenLibrariesSilent()
|
||||||
|
{
|
||||||
|
QFETCH(QString, id);
|
||||||
|
QFETCH(QString, game);
|
||||||
|
QFETCH(QString, key);
|
||||||
|
QFETCH(QString, version);
|
||||||
|
const auto loader = GameDirImport::loaderOf({{"id", id}, {"inheritsFrom", game}});
|
||||||
|
QCOMPARE(loader.key, key);
|
||||||
|
QCOMPARE(loader.version, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
void unknownInheritorKeepsResolvedId()
|
||||||
|
{
|
||||||
|
writeVanilla("1.20.1");
|
||||||
|
writeLoaderProfile("1.20.1-OptiFine_HD_U_I6", "1.20.1", "optifine:OptiFine:1.20.1_HD_U_I6");
|
||||||
|
writeProfiles({{"of", profile("1.20.1-OptiFine_HD_U_I6", "2026-03-01T10:00:00Z")}});
|
||||||
|
const auto found = GameDirImport::detect(m_game);
|
||||||
|
QVERIFY(found);
|
||||||
|
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||||
|
QCOMPARE(found->resolvedVersionId, QStringLiteral("1.20.1-OptiFine_HD_U_I6"));
|
||||||
|
QVERIFY(found->loader.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void detectionDoesNotTouchGameDir()
|
||||||
|
{
|
||||||
|
writeVanilla("1.20.1");
|
||||||
|
writeLoaderProfile("fabric-loader-0.15.0-1.20.1", "1.20.1", "net.fabricmc:fabric-loader:0.15.0");
|
||||||
|
writeProfiles({{"f", profile("fabric-loader-0.15.0-1.20.1", "2026-03-01T10:00:00Z")}});
|
||||||
|
Fixtures::write(m_game + "/mods/a.jar", "a");
|
||||||
|
Fixtures::write(m_game + "/saves/W/level.dat", "w");
|
||||||
|
const QStringList before = Fixtures::listTree(m_game);
|
||||||
|
QVERIFY(GameDirImport::detect(m_game));
|
||||||
|
QCOMPARE(Fixtures::listTree(m_game), before);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
BILAUNCHER_TEST_MAIN(TestGameDirImport)
|
||||||
|
|
||||||
|
#include "tst_gamedirimport.moc"
|
||||||
Reference in new issue
Block a user