Compare commits
25
Commits
v1.0.0
..
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 | ||
|
|
acd9334c16 |
No files matched your search
@@ -1,10 +1,18 @@
|
||||
name: release
|
||||
|
||||
# master -> релиз v<версия>, если такого ещё нет
|
||||
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
|
||||
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
|
||||
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
||||
on:
|
||||
push:
|
||||
#branches: [master, rc]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
env:
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
|
||||
@@ -14,53 +22,95 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
tag: ${{ steps.meta.outputs.tag }}
|
||||
skip: ${{ steps.meta.outputs.skip }}
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: meta
|
||||
name: Версия из CMakeLists.txt и сверка с тегом
|
||||
name: Версия из CMakeLists.txt, тег и тип релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
|
||||
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
|
||||
tag=""
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
base="${tag#v}"; base="${base%%-*}"
|
||||
if [ "$base" != "$version" ]; then
|
||||
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||
fi
|
||||
version="${tag#v}"
|
||||
fi
|
||||
|
||||
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
|
||||
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
|
||||
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
|
||||
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
|
||||
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
|
||||
known() {
|
||||
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
|
||||
printf '%s\n' "$release_tags"
|
||||
}
|
||||
|
||||
tag=""; skip=false
|
||||
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
|
||||
tag:*)
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
base="${tag#v}"; base="${base%%-*}"
|
||||
if [ "$base" != "$version" ]; then
|
||||
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||
fi
|
||||
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
|
||||
if has_release "$tag"; then
|
||||
echo "Релиз $tag уже есть"; skip=true
|
||||
fi
|
||||
;;
|
||||
branch:master)
|
||||
tag="v$version"
|
||||
if known | grep -xF "$tag" > /dev/null; then
|
||||
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
|
||||
fi
|
||||
;;
|
||||
branch:rc)
|
||||
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
|
||||
tag="v$version-rc$(( ${n:-0} + 1 ))"
|
||||
;;
|
||||
esac
|
||||
[ -n "$tag" ] && version="${tag#v}"
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Версия: $version, тег: ${tag:-нет}"
|
||||
echo "skip=$skip" >> "$GITHUB_OUTPUT"
|
||||
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
|
||||
|
||||
- id: release
|
||||
name: Черновик релиза
|
||||
if: steps.meta.outputs.tag != ''
|
||||
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
run: |
|
||||
base="${TAG#v}"; base="${base%%-*}"
|
||||
notes=$(awk -v v="$base" '
|
||||
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||
on' CHANGELOG.md)
|
||||
prerelease=false; case "$TAG" in *-*) prerelease=true;; esac
|
||||
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
||||
"$TAG" "$notes" "$prerelease")
|
||||
full="${TAG#v}"; base="${full%%-*}"
|
||||
section() {
|
||||
awk -v v="$1" '
|
||||
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||
on' CHANGELOG.md
|
||||
}
|
||||
# Сначала раздел ровно под тег ([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" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
echo "Создан черновик релиза $TAG (id $id)"
|
||||
echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
if: needs.create-release.outputs.skip != 'true'
|
||||
runs-on: linux
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
outputs:
|
||||
sums: ${{ steps.pack.outputs.sums }}
|
||||
@@ -72,7 +122,7 @@ jobs:
|
||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build AppDir dist
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
|
||||
cmake --build build -j 8
|
||||
cmake --install build --prefix "$PWD/AppDir/usr"
|
||||
@@ -111,9 +161,12 @@ jobs:
|
||||
|
||||
build-windows:
|
||||
needs: create-release
|
||||
if: needs.create-release.outputs.skip != 'true'
|
||||
runs-on: windows
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
outputs:
|
||||
sums: ${{ steps.build.outputs.sums }}
|
||||
@@ -134,7 +187,7 @@ jobs:
|
||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||
|
||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT" "-DLAUNCHER_BUILD_VERSION=$env:BUILD_VERSION"
|
||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||
cmake --build $build
|
||||
if ($LASTEXITCODE) { throw "cmake build" }
|
||||
@@ -181,9 +234,12 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
needs: create-release
|
||||
if: needs.create-release.outputs.skip != 'true'
|
||||
runs-on: macos
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
outputs:
|
||||
sums: ${{ steps.pack.outputs.sums }}
|
||||
@@ -195,7 +251,7 @@ jobs:
|
||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build stage dist dmgroot
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
||||
cmake --build build
|
||||
cmake --install build --prefix "$PWD/stage"
|
||||
|
||||
@@ -12,12 +12,13 @@ concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: linux
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -81,7 +82,9 @@ jobs:
|
||||
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
||||
$code = $LASTEXITCODE
|
||||
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\Testing\Temporary\LastTest.log" test-results\ -ErrorAction SilentlyContinue
|
||||
if ($code) { throw "ctest" }
|
||||
|
||||
- name: Отчёты
|
||||
|
||||
@@ -61,6 +61,7 @@ replay_pid*
|
||||
build/
|
||||
.qtcreator/
|
||||
.DS_Store
|
||||
/doc/adr/
|
||||
CMakeLists.txt.user*
|
||||
stage/
|
||||
dist/
|
||||
|
||||
+6
-3
@@ -8,6 +8,7 @@ Dialog {
|
||||
id: buildsDialog
|
||||
|
||||
required property var backend
|
||||
enabled: !buildsDialog.backend.seasonalInstalling
|
||||
property int editIndex: -1
|
||||
property bool loading: false
|
||||
|
||||
@@ -15,8 +16,9 @@ Dialog {
|
||||
padding: 0
|
||||
width: 880
|
||||
height: 600
|
||||
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
@@ -314,7 +316,8 @@ Dialog {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||
text: Loc.t.build.label.active
|
||||
color: "#91B315"
|
||||
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||
? "#ffffff" : "#91B315"
|
||||
font.pixelSize: 10
|
||||
}
|
||||
Image {
|
||||
|
||||
+10
-1
@@ -9,4 +9,13 @@
|
||||
- Система сезонных сборок: полная автоматизация в один клик
|
||||
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
||||
### Исправлено
|
||||
- UI фиксы
|
||||
- UI фиксы
|
||||
|
||||
## [1.0.1-rc1] — 2026-10-23
|
||||
### Добавлено
|
||||
- Отображене текущей версии сборки лаунчера на главном окне
|
||||
### Исправлено
|
||||
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
|
||||
- Исправлен баг отображения статуса активной сборки на зелёном фоне
|
||||
- Исправлен баг, что во время игры можно было потенциально сменить сборку
|
||||
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
|
||||
+32
-28
@@ -1,6 +1,27 @@
|
||||
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(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||
@@ -19,15 +40,6 @@ endif()
|
||||
|
||||
qt_standard_project_setup(REQUIRES 6.8)
|
||||
|
||||
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
|
||||
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
|
||||
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
|
||||
# который ставится ниже вместе с темой hicolor.
|
||||
#
|
||||
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
|
||||
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
|
||||
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
|
||||
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
|
||||
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
||||
|
||||
if(WIN32)
|
||||
@@ -47,10 +59,6 @@ qt_add_executable(BiLauncher
|
||||
${LAUNCHER_APP_ICON}
|
||||
)
|
||||
|
||||
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
|
||||
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
|
||||
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
|
||||
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
|
||||
qt_add_resources(BiLauncher "appicon"
|
||||
PREFIX "/icons"
|
||||
BASE "icons"
|
||||
@@ -70,9 +78,11 @@ qt_add_resources(BiLauncher "appicon"
|
||||
icons/icon_512x512@2x.png
|
||||
)
|
||||
|
||||
# OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в
|
||||
# ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым
|
||||
# файлом (у него нет расширения), и линковка падает с «Is a directory».
|
||||
# Путь импорта для QML code model Qt Creator / qmlls: модуль лежит в
|
||||
# ${CMAKE_CURRENT_BINARY_DIR}/qml, а не в корне каталога сборки.
|
||||
set(QML_IMPORT_PATH "${CMAKE_CURRENT_BINARY_DIR}/qml"
|
||||
CACHE STRING "Пути импорта QML для Qt Creator" FORCE)
|
||||
|
||||
qt_add_qml_module(BiLauncher
|
||||
URI BiLauncher
|
||||
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
||||
@@ -114,6 +124,8 @@ qt_add_qml_module(BiLauncher
|
||||
buildarchiveworker.cpp
|
||||
buildswitcher.h
|
||||
buildswitcher.cpp
|
||||
gamedirimport.h
|
||||
gamedirimport.cpp
|
||||
seasonalbuildservice.h
|
||||
seasonalbuildservice.cpp
|
||||
seasonalpackdownloader.h
|
||||
@@ -144,8 +156,6 @@ set_target_properties(BiLauncher PROPERTIES
|
||||
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
||||
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
|
||||
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
|
||||
MACOSX_BUNDLE_ICON_FILE icon.icns
|
||||
MACOSX_BUNDLE TRUE
|
||||
WIN32_EXECUTABLE TRUE
|
||||
@@ -155,9 +165,8 @@ target_link_libraries(BiLauncher
|
||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||
)
|
||||
|
||||
target_compile_definitions(BiLauncher PRIVATE
|
||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
||||
)
|
||||
target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
|
||||
add_dependencies(BiLauncher launcher_version)
|
||||
|
||||
if(Qt6WebEngineQuick_FOUND)
|
||||
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})
|
||||
|
||||
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
|
||||
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
|
||||
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
||||
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
||||
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
||||
@@ -243,11 +250,8 @@ qt_generate_deploy_qml_app_script(
|
||||
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
||||
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
||||
install(SCRIPT ${deploy_script})
|
||||
# Автотесты: собираются только по запросу и в установку не попадают.
|
||||
# Код лаунчера при этом не меняется — тесты компилируют те же исходники
|
||||
# в отдельную статическую библиотеку, см. tests/CMakeLists.txt и doc/testing.md.
|
||||
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
|
||||
if(BILAUNCHER_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
endif()
|
||||
@@ -39,6 +39,9 @@ Window {
|
||||
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
|
||||
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
|
||||
onGameOutput: (line) => console.log(line)
|
||||
onGameDirImported: (buildName) => {
|
||||
window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000)
|
||||
}
|
||||
onSeasonalInstallFinished: (seasonalId, buildName) => {
|
||||
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
|
||||
"#4b7a1f", 8000)
|
||||
@@ -89,6 +92,38 @@ Window {
|
||||
id: image
|
||||
anchors.fill: parent
|
||||
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 ───────────────────────────────────────────────────────
|
||||
@@ -192,6 +227,10 @@ Window {
|
||||
rotation: 0
|
||||
editable: false
|
||||
hoverEnabled: true
|
||||
// Пока идёт загрузка или установка, профиль и сборку не меняем:
|
||||
// единственное доступное действие — отмена на панели загрузки.
|
||||
enabled: !backend.busy && !backend.gameRunning
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
|
||||
model: backend.profileNames
|
||||
|
||||
@@ -377,8 +416,6 @@ Window {
|
||||
}
|
||||
|
||||
// ── Активная сборка ────────────────────────────────────────────────────
|
||||
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
|
||||
// и её содержимое правятся теперь в модальном окне.
|
||||
Button {
|
||||
id: buildButton
|
||||
x: 624
|
||||
@@ -390,7 +427,8 @@ Window {
|
||||
anchors.rightMargin: 0
|
||||
anchors.bottomMargin: -218
|
||||
hoverEnabled: true
|
||||
enabled: !backend.gameRunning && !backend.switching
|
||||
enabled: !backend.gameRunning && !backend.switching && !backend.seasonalInstalling
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
|
||||
contentItem: Text {
|
||||
text: backend.activeBuildName !== "" ? backend.activeBuildName
|
||||
@@ -464,6 +502,7 @@ Window {
|
||||
anchors.verticalCenter: folderButton.verticalCenter
|
||||
anchors.leftMargin: 8
|
||||
hoverEnabled: true
|
||||
enabled: !backend.busy && !backend.gameRunning
|
||||
|
||||
background: Image {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
|
||||
</p>
|
||||
|
||||
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависиомстей для конечных пользователей</h3>
|
||||
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависимостей для конечных пользователей</h3>
|
||||
|
||||
<p float="left">
|
||||
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
|
||||
|
||||
@@ -22,9 +22,9 @@ Dialog {
|
||||
padding: 0
|
||||
width: 960
|
||||
height: 560
|
||||
closePolicy: seasons.backend.seasonalInstalling
|
||||
? Popup.NoAutoClose
|
||||
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
||||
// Закрывается и во время установки: отменить её можно только на панели
|
||||
// загрузки главного окна, а модальное окно эту панель перекрывает.
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
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-проект.
|
||||
|
||||
Версия приложения задаётся в одном месте, в `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).
|
||||
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
|
||||
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
|
||||
3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает
|
||||
релиз pre-release).
|
||||
Для пре-релиза можно завести отдельный раздел `## [x.y.z-rcN]`: он берётся в первую очередь,
|
||||
а если его нет — используется `## [x.y.z]`.
|
||||
3. Запустить релиз одним из способов:
|
||||
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
|
||||
прогон ничего не собирает: сначала поднимите версию.
|
||||
- **Пуш в `rc`** — пре-релиз `vx.y.z-rcN`, где `N` — следующий свободный номер для этой версии
|
||||
(`rc1`, `rc2`, ...).
|
||||
- **Тег `vx.y.z`** — полноценный релиз, **тег `vx.y.z-rcN`** — пре-релиз. Пре-релизом
|
||||
становится только тег с суффиксом `-rc`, любой другой суффикс даёт обычный релиз.
|
||||
|
||||
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
|
||||
|
||||
Для веток тег создаёт сам Gitea при публикации релиза, на коммите прогона. Этот тег снова
|
||||
запускает `release.yml`, но прогон видит, что релиз уже есть, и ничего не собирает.
|
||||
|
||||
### Что происходит
|
||||
|
||||
1. **create-release** (Linux) — сверяет тег с версией, вырезает раздел из `CHANGELOG.md` и
|
||||
1. **create-release** (Linux) — определяет тег (из пуша тега или по ветке), сверяет его с версией, вырезает раздел из `CHANGELOG.md` и
|
||||
создаёт **черновик** релиза.
|
||||
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
|
||||
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
|
||||
|
||||
@@ -46,16 +46,34 @@
|
||||
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
|
||||
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
|
||||
4. `loadData()` читает профили и сборки и сортирует их по `id`.
|
||||
5. `ensureDataFiles()` создаёт недостающие файлы.
|
||||
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
||||
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
|
||||
(см. ниже).
|
||||
6. `ensureDataFiles()` создаёт недостающие файлы.
|
||||
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
||||
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
|
||||
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
|
||||
7. Подписка на сигналы сервисов.
|
||||
8. Подписка на сигналы сервисов.
|
||||
|
||||
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
||||
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
||||
моменту QML уже подписан на сигнал.
|
||||
|
||||
### Сборка из папки игры
|
||||
|
||||
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
|
||||
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
|
||||
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
|
||||
пользователя «сборок нет», и импорта тогда нет.
|
||||
|
||||
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
|
||||
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
|
||||
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
|
||||
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
|
||||
|
||||
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
|
||||
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
|
||||
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
|
||||
|
||||
## Файлы на диске
|
||||
|
||||
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
||||
|
||||
@@ -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 уже не выставить.
|
||||
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
||||
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).
|
||||
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
||||
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
||||
|
||||
@@ -122,6 +122,7 @@ LauncherBackend ──────────────── Loc (Localizati
|
||||
|---|---|
|
||||
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
|
||||
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
|
||||
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
|
||||
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
|
||||
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
|
||||
|
||||
+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` | сообщение «сборка установлена, можно играть» |
|
||||
| `javaRuntimeInstalled` | обновляет строку Java в настройках |
|
||||
|
||||
Кнопка запуска выключена, пока `busy` или `gameRunning`. Кнопка активной сборки выключена во время
|
||||
игры и смены сборки.
|
||||
Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`;
|
||||
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
|
||||
числе сезонной сборки) доступна только отмена на панели загрузки.
|
||||
|
||||
## Профили
|
||||
|
||||
|
||||
@@ -48,8 +48,9 @@
|
||||
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
|
||||
без сети.
|
||||
|
||||
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
|
||||
главного окна, и случайный щелчок мимо окна не должен её закрыть.
|
||||
Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только
|
||||
на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна
|
||||
продолжается.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-3
@@ -49,6 +49,7 @@ CMake, тесты появятся в панели «Тесты».
|
||||
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
|
||||
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
|
||||
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
|
||||
| unit | `tst_gamedirimport` | выбор версии по `launcher_profiles.json` и `versions/`, модлоадер по библиотекам и id, папка не меняется |
|
||||
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
|
||||
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
|
||||
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
|
||||
@@ -86,7 +87,8 @@ CMake, тесты появятся в панели «Тесты».
|
||||
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
|
||||
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
|
||||
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
|
||||
сообщением, а в CI на Linux (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
|
||||
сообщением, а в CI на всех ОС (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
|
||||
QML-тесты также завершаются ошибкой, если стенд не поднялся.
|
||||
|
||||
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
|
||||
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
|
||||
@@ -111,7 +113,6 @@ CMake, тесты появятся в панели «Тесты».
|
||||
| Тест | Проблема |
|
||||
|------|----------|
|
||||
| `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» |
|
||||
| `tst_modloaderversionservice::neoForgeLegacyArtifactArrivesFirst` | если ответ старого артефакта `net.neoforged:forge` приходит раньше основного, основной затирает слитые записи (`state->merged = parsed`), и версии NeoForge для 1.20.1 пропадают |
|
||||
| `tst_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` |
|
||||
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
|
||||
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
|
||||
@@ -156,7 +157,8 @@ WebEngine задерживала бы релизы. Перед выпуском
|
||||
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
|
||||
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
|
||||
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
|
||||
`hang`, `drop`, `truncateTo`.
|
||||
`hang`, `drop`, `truncateTo`. Для заданного порядка ответов установите `deferred = true`
|
||||
и вызовите `releaseResponse(url)` после обработки другого ответа клиентом.
|
||||
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
|
||||
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
|
||||
`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
|
||||
|
||||
#include "launcherversion.h"
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QObject>
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
}
|
||||
},
|
||||
"strings": {
|
||||
"app.label.version": {
|
||||
"ru": "Версия: %1",
|
||||
"en": "Version: %1"
|
||||
},
|
||||
"app.title": {
|
||||
"ru": "BiLauncher",
|
||||
"en": "BiLauncher"
|
||||
@@ -379,6 +383,10 @@
|
||||
"ru": "Активна сборка «%1»",
|
||||
"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": {
|
||||
"ru": "Сборка «%1»: %2",
|
||||
"en": "Build \"%1\": %2"
|
||||
@@ -387,6 +395,10 @@
|
||||
"ru": "Готова к запуску",
|
||||
"en": "Ready to launch"
|
||||
},
|
||||
"build.title.fromGameDir": {
|
||||
"ru": "Сборка из папки игры",
|
||||
"en": "Build from the game folder"
|
||||
},
|
||||
"build.title.numbered": {
|
||||
"ru": "Сборка %1",
|
||||
"en": "Build %1"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "msaauthservice.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "buildswitcher.h"
|
||||
#include "gamedirimport.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
#include "seasonalpackdownloader.h"
|
||||
|
||||
@@ -97,11 +98,20 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
if (!LauncherPaths::ensureRootExists(&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();
|
||||
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
||||
// создаст пустой customBuilds.json поверх старых данных.
|
||||
migrateLegacyBuildsFile();
|
||||
loadData();
|
||||
const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString();
|
||||
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()) {
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
emit launchError(m_storageIssues.join(u'\n'));
|
||||
@@ -2483,6 +2497,36 @@ bool LauncherBackend::migrateLegacyBuildsFile()
|
||||
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 —
|
||||
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
|
||||
void LauncherBackend::ensureDataFiles()
|
||||
|
||||
@@ -232,6 +232,9 @@ signals:
|
||||
// Сезонная сборка установлена и активна — можно запускать игру.
|
||||
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
||||
|
||||
// Первый запуск: по содержимому папки игры заведена активная сборка.
|
||||
void gameDirImported(const QString &buildName);
|
||||
|
||||
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
||||
void javaRuntimeInstalled(const QString &runtimeId);
|
||||
|
||||
@@ -313,6 +316,9 @@ private:
|
||||
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
||||
// после переименования. true — миграция прошла, читать нужно новый файл.
|
||||
bool migrateLegacyBuildsFile();
|
||||
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
|
||||
// Папку игры только читает. Возвращает имя сборки или пустую строку.
|
||||
QString importGameDir();
|
||||
|
||||
void invalidateCatalog();
|
||||
void rebuildCatalog() const;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "launcherpaths.h"
|
||||
#include "launcherversion.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDebug>
|
||||
|
||||
@@ -244,6 +244,7 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
||||
{
|
||||
int outstanding = 2;
|
||||
QHash<QString, QList<LoaderVersionEntry>> merged;
|
||||
QHash<QString, QList<LoaderVersionEntry>> legacy;
|
||||
QHash<QString, QString> recommended;
|
||||
bool primaryOk = false;
|
||||
QString warning;
|
||||
@@ -258,6 +259,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
||||
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) {
|
||||
const QString promoted = state->recommended.value(it.key());
|
||||
if (promoted.isEmpty())
|
||||
@@ -317,10 +320,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
||||
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
|
||||
legacy->deleteLater();
|
||||
if (legacy->error() == QNetworkReply::NoError) {
|
||||
const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
||||
/*legacyNeoForge=*/true);
|
||||
for (auto it = extra.constBegin(); it != extra.constEnd(); ++it)
|
||||
state->merged[it.key()] += it.value();
|
||||
state->legacy = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
||||
/*legacyNeoForge=*/true);
|
||||
}
|
||||
settle();
|
||||
});
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
; Установщик BiLauncher для Windows (Inno Setup 6).
|
||||
;
|
||||
; Собирается в 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
|
||||
#ifndef AppVersion
|
||||
#error Передайте версию: /DAppVersion=X.Y.Z
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#error Передайте префикс установки: /DSourceDir=...
|
||||
#error Передайте префикс установки: /DSourceDir=...
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
|
||||
#if Pos("-", AppVersion) > 0
|
||||
#define NumericVersion Copy(AppVersion, 1, Pos("-", AppVersion) - 1)
|
||||
#else
|
||||
#define NumericVersion AppVersion
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
; AppId не менять между версиями — по нему обновление находит прежнюю установку.
|
||||
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
||||
AppName=BiLauncher
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName=BiLauncher {#AppVersion}
|
||||
AppPublisher=GaleonDev
|
||||
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
||||
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
||||
VersionInfoVersion={#AppVersion}
|
||||
DefaultDirName={autopf}\BiLauncher
|
||||
DefaultGroupName=BiLauncher
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
||||
SetupIconFile=..\..\icons\icon.ico
|
||||
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
LZMANumBlockThreads=4
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
WizardStyle=modern
|
||||
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
||||
AppName=BiLauncher
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName=BiLauncher {#AppVersion}
|
||||
AppPublisher=GaleonDev
|
||||
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
||||
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
||||
VersionInfoVersion={#NumericVersion}
|
||||
VersionInfoTextVersion={#AppVersion}
|
||||
VersionInfoProductTextVersion={#AppVersion}
|
||||
DefaultDirName={autopf}\BiLauncher
|
||||
DefaultGroupName=BiLauncher
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
||||
SetupIconFile=..\..\icons\icon.ico
|
||||
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
LZMANumBlockThreads=4
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
[Languages]
|
||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
||||
[Files]
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "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
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
||||
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
||||
[Icons]
|
||||
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
||||
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
[Run]
|
||||
#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
|
||||
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}
|
||||
NO_LINT
|
||||
)
|
||||
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
|
||||
target_compile_definitions(bilauncher_testlib PUBLIC
|
||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
||||
)
|
||||
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}" "${LAUNCHER_GENERATED_DIR}")
|
||||
add_dependencies(bilauncher_testlib launcher_version)
|
||||
target_link_libraries(bilauncher_testlib PUBLIC
|
||||
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);
|
||||
}
|
||||
|
||||
// Папка игры, в которой до лаунчера играли с 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:
|
||||
void initTestCase()
|
||||
{
|
||||
@@ -116,6 +134,89 @@ private slots:
|
||||
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()
|
||||
{
|
||||
{
|
||||
|
||||
@@ -201,12 +201,32 @@ void FakeInternet::unroute(const QString &pattern)
|
||||
|
||||
void FakeInternet::reset()
|
||||
{
|
||||
m_deferred.clear();
|
||||
m_diagnostics.clear();
|
||||
m_routes.clear();
|
||||
m_requests.clear();
|
||||
m_unmatched.clear();
|
||||
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
|
||||
{
|
||||
return m_requests;
|
||||
@@ -366,9 +386,13 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
||||
}
|
||||
|
||||
const bool keepAlive = request.header("connection").toLower() != "close";
|
||||
QPointer<QTcpSocket> socket(connection->socket);
|
||||
QPointer<QSslSocket> socket(connection->socket);
|
||||
if (response.hang)
|
||||
return;
|
||||
if (response.deferred) {
|
||||
m_deferred.append({url, socket, response, keepAlive});
|
||||
return;
|
||||
}
|
||||
if (response.delayMs > 0) {
|
||||
QTimer::singleShot(response.delayMs, this,
|
||||
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
||||
@@ -378,7 +402,7 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
||||
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)
|
||||
return;
|
||||
@@ -397,13 +421,28 @@ void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &resp
|
||||
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
||||
head += "\r\n";
|
||||
|
||||
socket->write(head);
|
||||
if (response.truncateTo >= 0) {
|
||||
m_diagnostics << QStringLiteral("truncate: declared=%1, sent=%2")
|
||||
.arg(response.body.size()).arg(response.body.left(response.truncateTo).size());
|
||||
// Сначала отправляем TLS-записи с неполным телом, затем рвём TCP.
|
||||
// disconnectFromHost() выполняет штатное завершение TLS, а здесь
|
||||
// моделируется обрыв. Готовность определяется буферами, не задержкой.
|
||||
connect(socket, &QSslSocket::encryptedBytesWritten, socket, [this, socket](qint64) {
|
||||
QTimer::singleShot(0, socket, [this, socket] {
|
||||
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||
return;
|
||||
if (socket->bytesToWrite() || socket->encryptedBytesToWrite())
|
||||
return;
|
||||
m_diagnostics << QStringLiteral("truncate: TLS buffers drained, aborting TCP");
|
||||
socket->abort();
|
||||
m_diagnostics << QStringLiteral("truncate: state after abort=%1").arg(socket->state());
|
||||
});
|
||||
});
|
||||
socket->write(head);
|
||||
socket->write(response.body.left(response.truncateTo));
|
||||
socket->flush();
|
||||
socket->disconnectFromHost();
|
||||
return;
|
||||
}
|
||||
socket->write(head);
|
||||
socket->write(response.body);
|
||||
if (close)
|
||||
socket->disconnectFromHost();
|
||||
|
||||
@@ -56,6 +56,7 @@ struct FakeResponse
|
||||
QList<QPair<QByteArray, QByteArray>> headers;
|
||||
int delayMs = 0; // ответить с задержкой
|
||||
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
||||
bool deferred = false; // ждать явного releaseResponse(), без таймера
|
||||
bool drop = false; // оборвать соединение без ответа
|
||||
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
||||
|
||||
@@ -95,6 +96,8 @@ public:
|
||||
|
||||
// Сбросить маршруты и журнал.
|
||||
void reset();
|
||||
bool releaseResponse(const QString &url);
|
||||
QString diagnostics() const;
|
||||
|
||||
QList<FakeRequest> requests() const;
|
||||
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
||||
@@ -122,7 +125,7 @@ private:
|
||||
void onReadyRead(Connection *connection);
|
||||
void processBuffer(Connection *connection);
|
||||
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);
|
||||
const Route *match(const FakeRequest &request) const;
|
||||
|
||||
@@ -130,6 +133,15 @@ private:
|
||||
FakeResponse m_fallback;
|
||||
QList<FakeRequest> m_requests;
|
||||
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_key;
|
||||
bool m_started = false;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#include <QtTest/QTest>
|
||||
|
||||
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
|
||||
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на Linux) недоступность —
|
||||
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на всех ОС) недоступность —
|
||||
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
||||
#define BILAUNCHER_START_FAKE_INTERNET(net) \
|
||||
do { \
|
||||
|
||||
@@ -103,7 +103,8 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
|
||||
if (!argsFile.empty()) {
|
||||
std::ofstream out(argsFile, std::ios::trunc);
|
||||
// binary: в текстовом режиме Windows превратит '\n' в "\r\n", и тесты увидят «-jar\r».
|
||||
std::ofstream out(argsFile, std::ios::trunc | std::ios::binary);
|
||||
for (int i = 1; i < argc; ++i)
|
||||
out << argv[i] << '\n';
|
||||
for (const std::string &name : dumpEnv) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "testenv.h"
|
||||
#include "fakeinternet.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
@@ -30,9 +31,7 @@ class TestFakeInternet : public QObject
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
QString error;
|
||||
if (!m_net.start(&error))
|
||||
QSKIP(qPrintable(QStringLiteral("FakeInternet недоступен: ") + error));
|
||||
BILAUNCHER_START_FAKE_INTERNET(m_net);
|
||||
}
|
||||
|
||||
void init() { m_net.reset(); }
|
||||
@@ -100,12 +99,6 @@ private slots:
|
||||
response.drop = true;
|
||||
return response;
|
||||
}());
|
||||
m_net.route(QStringLiteral("https://a.test/short"), [] {
|
||||
FakeResponse response = FakeResponse::bytes(QByteArray(1000, 'x'));
|
||||
response.truncateTo = 10;
|
||||
return response;
|
||||
}());
|
||||
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
|
||||
QVERIFY(waitFinished(r500.data()));
|
||||
@@ -114,10 +107,31 @@ private slots:
|
||||
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
|
||||
QVERIFY(waitFinished(drop.data()));
|
||||
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")));
|
||||
QVERIFY(waitFinished(shortReply.data()));
|
||||
QVERIFY2(waitFinished(shortReply.data()),
|
||||
qPrintable(m_net.diagnostics() + "\nClient: " + shortReply->errorString()));
|
||||
QVERIFY(shortReply->error() != QNetworkReply::NoError);
|
||||
QCOMPARE(shortReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
|
||||
QCOMPARE(shortReply->readAll(), body.left(bytes));
|
||||
}
|
||||
|
||||
void plainHttpThroughProxy()
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#include "modloaderversionservice.h"
|
||||
|
||||
#include <QSignalSpy>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
|
||||
namespace {
|
||||
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);
|
||||
}
|
||||
|
||||
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:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
@@ -167,33 +188,30 @@ private slots:
|
||||
void neoForgeLegacyArtifactArrivesLast()
|
||||
{
|
||||
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
|
||||
FakeResponse legacySlow = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
||||
legacySlow.delayMs = 400;
|
||||
m_net.route(kNeoLegacyMeta, legacySlow);
|
||||
FakeResponse legacy = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
||||
legacy.deferred = true;
|
||||
m_net.route(kNeoLegacyMeta, legacy);
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
||||
QVERIFY(loadNeoForgeInOrder(service, r, kNeoMeta, kNeoLegacyMeta));
|
||||
QVERIFY(r.ok);
|
||||
const QList<LoaderVersionEntry> legacy = service.versions(ModLoader::NeoForge, "1.20.1");
|
||||
QCOMPARE(loaderVersions(legacy), QStringList({"47.1.106", "47.1.79"}));
|
||||
QCOMPARE(legacy.at(0).installerUrl,
|
||||
const QList<LoaderVersionEntry> versions = service.versions(ModLoader::NeoForge, "1.20.1");
|
||||
QCOMPARE(loaderVersions(versions), QStringList({"47.1.106", "47.1.79"}));
|
||||
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"));
|
||||
}
|
||||
|
||||
void neoForgeLegacyArtifactArrivesFirst()
|
||||
{
|
||||
// Тот же каталог, но маленький legacy-ответ приходит раньше основного.
|
||||
FakeResponse primarySlow = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
||||
primarySlow.delayMs = 400;
|
||||
m_net.route(kNeoMeta, primarySlow);
|
||||
// Основной ответ выпускается только после обработки legacy клиентом.
|
||||
FakeResponse primary = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
||||
primary.deferred = true;
|
||||
m_net.route(kNeoMeta, primary);
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
||||
QVERIFY(loadNeoForgeInOrder(service, r, kNeoLegacyMeta, kNeoMeta));
|
||||
QVERIFY(r.ok);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,11 @@ private slots:
|
||||
QCOMPARE(c.result.userType, QStringLiteral("msa"));
|
||||
QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
|
||||
QVERIFY(!c.result.licenseMissing);
|
||||
const qint64 lifetime = before.secsTo(c.result.expiresAt);
|
||||
QVERIFY(lifetime > 3500 && lifetime <= 3600);
|
||||
// expiresAt = момент ответа login_with_xbox + expires_in; на медленном раннере
|
||||
// вся цепочка может идти дольше секунды, поэтому границы — до и после логина.
|
||||
const QDateTime after = QDateTime::currentDateTimeUtc();
|
||||
QVERIFY(c.result.expiresAt >= before.addSecs(3600));
|
||||
QVERIFY(c.result.expiresAt <= after.addSecs(3600));
|
||||
QVERIFY(progress.count() >= 5);
|
||||
|
||||
// Порядок шагов и формат запроса токена.
|
||||
|
||||
@@ -94,7 +94,7 @@ private slots:
|
||||
m_net.route(kPack, cut);
|
||||
Run run(downloader);
|
||||
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);
|
||||
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ public slots:
|
||||
seedData();
|
||||
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
|
||||
QString error;
|
||||
if (m_net.start(&error))
|
||||
m_net.setFallback(FakeResponse::error(503));
|
||||
if (!m_net.start(&error))
|
||||
qFatal("FakeInternet: %s", qPrintable(error));
|
||||
m_net.setFallback(FakeResponse::error(503));
|
||||
}
|
||||
|
||||
void qmlEngineAvailable(QQmlEngine *engine)
|
||||
|
||||
@@ -49,8 +49,8 @@ private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
QString error;
|
||||
if (m_net.start(&error))
|
||||
m_net.setFallback(FakeResponse::error(503));
|
||||
QVERIFY2(m_net.start(&error), qPrintable(error));
|
||||
m_net.setFallback(FakeResponse::error(503));
|
||||
const QString game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
|
||||
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Дымовая проверка установленного дерева (результат cmake --install) на Windows.
|
||||
# Дымовая проверка установленного дерева (результат cmake --install) на Windows.
|
||||
#
|
||||
# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage
|
||||
#
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
опустить планку, чтобы достигнутое не откатилось.
|
||||
3. CHANGELOG.md содержит раздел для версии из project(BiLauncher VERSION …):
|
||||
без него release.yml создаст релиз с пустым описанием (только предупреждение).
|
||||
4. Скрипты .ps1 с не-ASCII символами сохранены в UTF-8 с BOM: без BOM Windows
|
||||
PowerShell 5.1 читает их в ANSI-кодировке и падает на разборе кириллицы.
|
||||
"""
|
||||
import os, re, subprocess, sys
|
||||
import glob, os, re, subprocess, sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BASELINE = os.path.join(ROOT, "tests", "qmllint-baseline.txt")
|
||||
@@ -68,19 +70,34 @@ def check_changelog():
|
||||
return False
|
||||
version = match.group(1)
|
||||
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 есть")
|
||||
else:
|
||||
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
|
||||
return True
|
||||
|
||||
|
||||
def check_ps1_bom():
|
||||
step("Кодировка .ps1")
|
||||
ok = True
|
||||
for path in glob.glob(os.path.join(ROOT, "**", "*.ps1"), recursive=True):
|
||||
if os.path.relpath(path, ROOT).startswith("build"): # папки сборки
|
||||
continue
|
||||
data = open(path, "rb").read()
|
||||
if not data.startswith(b"\xef\xbb\xbf") and any(b > 0x7F for b in data):
|
||||
print(f"ОШИБКА: {os.path.relpath(path, ROOT)} содержит не-ASCII, но сохранён без UTF-8 BOM")
|
||||
ok = False
|
||||
if ok:
|
||||
print("все .ps1 читаются Windows PowerShell 5.1")
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
build_dir = os.path.abspath(sys.argv[1])
|
||||
results = [check_translations(), check_qmllint(build_dir), check_changelog()]
|
||||
results = [check_translations(), check_qmllint(build_dir), check_changelog(), check_ps1_bom()]
|
||||
print("\nИТОГ:", "OK" if all(results) else "ЕСТЬ ОШИБКИ")
|
||||
return 0 if all(results) else 1
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ foreach(test IN ITEMS
|
||||
tst_javalocator
|
||||
tst_zlibreference
|
||||
tst_buildarchiveworker
|
||||
tst_buildswitcher)
|
||||
tst_buildswitcher
|
||||
tst_gamedirimport)
|
||||
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
|
||||
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