11 Commits
Author SHA1 Message Date
galeon 50e0cd2aab sync BiLauncher.iss with rc
tests / package-smoke-linux (push) Skipped
tests / windows (pull_request) Skipped
tests / package-smoke-windows (push) Skipped
tests / macos (pull_request) Skipped
tests / package-smoke-macos (push) Skipped
tests / package-smoke-linux (pull_request) Skipped
tests / package-smoke-windows (pull_request) Skipped
tests / package-smoke-macos (pull_request) Skipped
tests / macos (push) Successful in 1m20s
tests / linux (push) Successful in 3m4s
tests / linux (pull_request) Successful in 2m19s
tests / windows (push) Failing after 23m42s
2026-10-24 22:19:55 +03:00
galeon d82a9d0729 fixes
tests / windows (pull_request) Skipped
tests / macos (pull_request) Skipped
tests / package-smoke-linux (pull_request) Skipped
tests / package-smoke-windows (pull_request) Skipped
tests / package-smoke-macos (pull_request) Skipped
tests / linux (pull_request) Successful in 2m33s
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m19s
tests / linux (push) Successful in 2m35s
tests / windows (push) Canceled after 0s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-24 22:10:42 +03:00
galeon 1817157e4d release pipeline fixes
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / windows (pull_request) Skipped
tests / macos (pull_request) Skipped
tests / package-smoke-linux (pull_request) Skipped
tests / package-smoke-windows (pull_request) Skipped
tests / package-smoke-macos (pull_request) Skipped
tests / macos (push) Successful in 1m19s
tests / linux (push) Successful in 5m48s
tests / linux (pull_request) Successful in 5m20s
tests / windows (push) Canceled after 30m5s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-24 21:45:45 +03:00
galeon 4463d4e5da release fixes and changelog
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / linux (push) Successful in 2m13s
tests / windows (push) Failing after 9m0s
tests / macos (push) Failing after 10m19s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-24 11:38:39 +03:00
galeon 7dd89db508 changelog update
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m25s
tests / linux (push) Successful in 2m4s
tests / windows (push) Successful in 29m8s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-23 15:42:54 +03:00
galeon a89bf20486 release fixes
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m24s
tests / linux (push) Successful in 2m18s
tests / windows (push) Successful in 14m14s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-23 15:26:03 +03:00
galeon 24115b4ce6 release workflow fixes
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m26s
tests / linux (push) Successful in 2m36s
tests / windows (push) Failing after 23m28s
tests / windows (pull_request) Skipped
tests / macos (pull_request) Skipped
tests / package-smoke-linux (pull_request) Skipped
tests / package-smoke-windows (pull_request) Skipped
tests / package-smoke-macos (pull_request) Skipped
tests / linux (pull_request) Successful in 2m36s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-23 10:45:27 +03:00
galeon 1d781b5324 fix #23 by adding validating on first startup and close #30 by adding
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m20s
tests / linux (push) Successful in 2m14s
tests / windows (push) Failing after 23m21s
version number

Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-23 09:55:15 +03:00
galeon bc205fa2e7 fixes #27 by locking builds while game active
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Failing after 1m51s
tests / linux (push) Successful in 1m54s
tests / windows (push) Failing after 11m29s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-22 08:50:43 +03:00
galeon aa021269be resolves #28, added check for selected row
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / linux (push) Successful in 2m14s
tests / macos (push) Failing after 1m55s
tests / windows (push) Failing after 12m48s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-21 09:59:31 +03:00
galeon 92531c6e9a fix #25 by allowing user to close model windows while backend is busy
tests / macos (push) Successful in 1m18s
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / linux (push) Successful in 2m17s
tests / windows (push) Failing after 14m10s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-20 17:43:43 +03:00
29 changed files with 1149 additions and 122 deletions

No files matched your search

+69 -19
View File
@@ -1,10 +1,18 @@
name: release name: release
# master -> релиз v<версия>, если такого ещё нет
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
# ручной запуск -> только сборки, файлы остаются артефактами прогона
on: on:
push: push:
#branches: [master, rc]
tags: ["v*"] tags: ["v*"]
workflow_dispatch: workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
env: env:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
@@ -14,31 +22,63 @@ jobs:
outputs: outputs:
version: ${{ steps.meta.outputs.version }} version: ${{ steps.meta.outputs.version }}
tag: ${{ steps.meta.outputs.tag }} tag: ${{ steps.meta.outputs.tag }}
skip: ${{ steps.meta.outputs.skip }}
release_id: ${{ steps.release.outputs.id }} release_id: ${{ steps.release.outputs.id }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- id: meta - id: meta
name: Версия из CMakeLists.txt и сверка с тегом name: Версия из CMakeLists.txt, тег и тип релиза
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt) version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; } [ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
tag=""
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then # Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
tag="${GITHUB_REF_NAME}" release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
base="${tag#v}"; base="${base%%-*}" | python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
if [ "$base" != "$version" ]; then has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1 # У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
fi known() {
version="${tag#v}" git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
fi printf '%s\n' "$release_tags"
}
tag=""; skip=false
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
tag:*)
tag="${GITHUB_REF_NAME}"
base="${tag#v}"; base="${base%%-*}"
if [ "$base" != "$version" ]; then
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
fi
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
if has_release "$tag"; then
echo "Релиз $tag уже есть"; skip=true
fi
;;
branch:master)
tag="v$version"
if known | grep -xF "$tag" > /dev/null; then
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
fi
;;
branch:rc)
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
tag="v$version-rc$(( ${n:-0} + 1 ))"
;;
esac
[ -n "$tag" ] && version="${tag#v}"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Версия: $version, тег: ${tag:-нет}" echo "skip=$skip" >> "$GITHUB_OUTPUT"
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
- id: release - id: release
name: Черновик релиза name: Черновик релиза
if: steps.meta.outputs.tag != '' if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }} TAG: ${{ steps.meta.outputs.tag }}
@@ -47,20 +87,24 @@ jobs:
notes=$(awk -v v="$base" ' notes=$(awk -v v="$base" '
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } } /^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
on' CHANGELOG.md) on' CHANGELOG.md)
prerelease=false; case "$TAG" in *-*) prerelease=true;; esac prerelease=false; case "$TAG" in *-rc*) 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"}))' \ # target_commitish: у новой ветки тег появится на этом коммите при публикации
"$TAG" "$notes" "$prerelease") body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"target_commitish":sys.argv[4],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
"$TAG" "$notes" "$prerelease" "$GITHUB_SHA")
id=$(curl -fsS -X POST "$API/releases" \ id=$(curl -fsS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') -d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "id=$id" >> "$GITHUB_OUTPUT" echo "id=$id" >> "$GITHUB_OUTPUT"
echo "Создан черновик релиза $TAG (id $id)" echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
build-linux: build-linux:
needs: create-release needs: create-release
if: needs.create-release.outputs.skip != 'true'
runs-on: linux runs-on: linux
env: env:
VERSION: ${{ needs.create-release.outputs.version }} VERSION: ${{ needs.create-release.outputs.version }}
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs: outputs:
sums: ${{ steps.pack.outputs.sums }} sums: ${{ steps.pack.outputs.sums }}
@@ -72,7 +116,7 @@ jobs:
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH" export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build AppDir dist rm -rf build AppDir dist
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \ -DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
cmake --build build -j 8 cmake --build build -j 8
cmake --install build --prefix "$PWD/AppDir/usr" cmake --install build --prefix "$PWD/AppDir/usr"
@@ -111,9 +155,12 @@ jobs:
build-windows: build-windows:
needs: create-release needs: create-release
if: needs.create-release.outputs.skip != 'true'
runs-on: windows runs-on: windows
env: env:
VERSION: ${{ needs.create-release.outputs.version }} VERSION: ${{ needs.create-release.outputs.version }}
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs: outputs:
sums: ${{ steps.build.outputs.sums }} sums: ${{ steps.build.outputs.sums }}
@@ -134,7 +181,7 @@ jobs:
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64" Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH" $env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT" cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT" "-DLAUNCHER_BUILD_VERSION=$env:BUILD_VERSION"
if ($LASTEXITCODE) { throw "cmake configure" } if ($LASTEXITCODE) { throw "cmake configure" }
cmake --build $build cmake --build $build
if ($LASTEXITCODE) { throw "cmake build" } if ($LASTEXITCODE) { throw "cmake build" }
@@ -181,9 +228,12 @@ jobs:
build-macos: build-macos:
needs: create-release needs: create-release
if: needs.create-release.outputs.skip != 'true'
runs-on: macos runs-on: macos
env: env:
VERSION: ${{ needs.create-release.outputs.version }} VERSION: ${{ needs.create-release.outputs.version }}
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs: outputs:
sums: ${{ steps.pack.outputs.sums }} sums: ${{ steps.pack.outputs.sums }}
@@ -195,7 +245,7 @@ jobs:
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH" export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build stage dist dmgroot rm -rf build stage dist dmgroot
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \ -DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
cmake --build build cmake --build build
cmake --install build --prefix "$PWD/stage" cmake --install build --prefix "$PWD/stage"
+1
View File
@@ -61,6 +61,7 @@ replay_pid*
build/ build/
.qtcreator/ .qtcreator/
.DS_Store .DS_Store
/doc/adr/
CMakeLists.txt.user* CMakeLists.txt.user*
stage/ stage/
dist/ dist/
+5 -3
View File
@@ -15,8 +15,9 @@ Dialog {
padding: 0 padding: 0
width: 880 width: 880
height: 600 height: 600
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
: Popup.CloseOnEscape | Popup.CloseOnPressOutside //closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle { background: Rectangle {
color: "#1e1e1e" color: "#1e1e1e"
@@ -314,7 +315,8 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: buildRow.index === buildsDialog.backend.activeBuildIndex visible: buildRow.index === buildsDialog.backend.activeBuildIndex
text: Loc.t.build.label.active text: Loc.t.build.label.active
color: "#91B315" color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
? "#ffffff" : "#91B315"
font.pixelSize: 10 font.pixelSize: 10
} }
Image { Image {
+10 -1
View File
@@ -9,4 +9,13 @@
- Система сезонных сборок: полная автоматизация в один клик - Система сезонных сборок: полная автоматизация в один клик
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов - Авторизация через ely.by и\или лицензию, с возможностью работы скинов
### Исправлено ### Исправлено
- UI фиксы - UI фиксы
## [1.0.1-rc1] — 2026-10-23
### Добавлено
- Отображене текущей версии сборки лаунчера на главном окне
### Исправлено
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
- Исправлен баг отображения статуса активной сборки на зелёном фоне
- Исправлен баг, что во время игры можно было потенциально сменить сборку
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
+31 -27
View File
@@ -1,6 +1,27 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C) project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C)
# Канал сборки (dev / rc / релиз) не хранится в репозитории, а вычисляется
# при сборке — см. cmake/LauncherVersion.cmake. CI передаёт полную версию:
# -DLAUNCHER_BUILD_VERSION=1.0.1-rc2.
set(LAUNCHER_BUILD_VERSION "" CACHE STRING "Полная версия от CI (X.Y.Z[-rcN]); пусто — по тегу и ветке git")
set(LAUNCHER_VERSION_NUMERIC "${PROJECT_VERSION}")
set(LAUNCHER_SOURCE_DIR "${PROJECT_SOURCE_DIR}")
set(LAUNCHER_GENERATED_DIR "${PROJECT_BINARY_DIR}/generated")
set(LAUNCHER_VERSION_HEADER "${LAUNCHER_GENERATED_DIR}/launcherversion.h")
include(cmake/LauncherVersion.cmake)
message(STATUS "Версия лаунчера: ${LAUNCHER_VERSION} (канал ${LAUNCHER_CHANNEL})")
add_custom_target(launcher_version
COMMAND "${CMAKE_COMMAND}"
"-DLAUNCHER_VERSION_NUMERIC=${LAUNCHER_VERSION_NUMERIC}"
"-DLAUNCHER_SOURCE_DIR=${LAUNCHER_SOURCE_DIR}"
"-DLAUNCHER_VERSION_HEADER=${LAUNCHER_VERSION_HEADER}"
"-DLAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION}"
-P "${PROJECT_SOURCE_DIR}/cmake/LauncherVersion.cmake"
BYPRODUCTS "${LAUNCHER_VERSION_HEADER}"
COMMENT "Версия лаунчера из git"
VERBATIM)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(QT_NO_PRIVATE_MODULE_WARNING ON) set(QT_NO_PRIVATE_MODULE_WARNING ON)
@@ -19,15 +40,6 @@ endif()
qt_standard_project_setup(REQUIRES 6.8) qt_standard_project_setup(REQUIRES 6.8)
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
# который ставится ниже вместе с темой hicolor.
#
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons") set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
if(WIN32) if(WIN32)
@@ -47,10 +59,6 @@ qt_add_executable(BiLauncher
${LAUNCHER_APP_ICON} ${LAUNCHER_APP_ICON}
) )
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
qt_add_resources(BiLauncher "appicon" qt_add_resources(BiLauncher "appicon"
PREFIX "/icons" PREFIX "/icons"
BASE "icons" BASE "icons"
@@ -70,9 +78,11 @@ qt_add_resources(BiLauncher "appicon"
icons/icon_512x512@2x.png icons/icon_512x512@2x.png
) )
# OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в # Путь импорта для QML code model Qt Creator / qmlls: модуль лежит в
# ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым # ${CMAKE_CURRENT_BINARY_DIR}/qml, а не в корне каталога сборки.
# файлом (у него нет расширения), и линковка падает с «Is a directory». set(QML_IMPORT_PATH "${CMAKE_CURRENT_BINARY_DIR}/qml"
CACHE STRING "Пути импорта QML для Qt Creator" FORCE)
qt_add_qml_module(BiLauncher qt_add_qml_module(BiLauncher
URI BiLauncher URI BiLauncher
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher" OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
@@ -114,6 +124,8 @@ qt_add_qml_module(BiLauncher
buildarchiveworker.cpp buildarchiveworker.cpp
buildswitcher.h buildswitcher.h
buildswitcher.cpp buildswitcher.cpp
gamedirimport.h
gamedirimport.cpp
seasonalbuildservice.h seasonalbuildservice.h
seasonalbuildservice.cpp seasonalbuildservice.cpp
seasonalpackdownloader.h seasonalpackdownloader.h
@@ -144,8 +156,6 @@ set_target_properties(BiLauncher PROPERTIES
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
MACOSX_BUNDLE_ICON_FILE icon.icns MACOSX_BUNDLE_ICON_FILE icon.icns
MACOSX_BUNDLE TRUE MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE TRUE WIN32_EXECUTABLE TRUE
@@ -155,9 +165,8 @@ target_link_libraries(BiLauncher
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
) )
target_compile_definitions(BiLauncher PRIVATE target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
LAUNCHER_VERSION="${PROJECT_VERSION}" add_dependencies(BiLauncher launcher_version)
)
if(Qt6WebEngineQuick_FOUND) if(Qt6WebEngineQuick_FOUND)
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick) target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
@@ -206,8 +215,6 @@ if(UNIX AND NOT APPLE)
) )
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR}) install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512) foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png" install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
@@ -243,9 +250,6 @@ qt_generate_deploy_qml_app_script(
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
DEPLOY_TOOL_OPTIONS ${deploy_opts}) DEPLOY_TOOL_OPTIONS ${deploy_opts})
install(SCRIPT ${deploy_script}) install(SCRIPT ${deploy_script})
# Автотесты: собираются только по запросу и в установку не попадают.
# Код лаунчера при этом не меняется — тесты компилируют те же исходники
# в отдельную статическую библиотеку, см. tests/CMakeLists.txt и doc/testing.md.
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF) option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
if(BILAUNCHER_BUILD_TESTS) if(BILAUNCHER_BUILD_TESTS)
enable_testing() enable_testing()
+41 -2
View File
@@ -39,6 +39,9 @@ Window {
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000) onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex) onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
onGameOutput: (line) => console.log(line) onGameOutput: (line) => console.log(line)
onGameDirImported: (buildName) => {
window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000)
}
onSeasonalInstallFinished: (seasonalId, buildName) => { onSeasonalInstallFinished: (seasonalId, buildName) => {
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName), window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
"#4b7a1f", 8000) "#4b7a1f", 8000)
@@ -89,6 +92,38 @@ Window {
id: image id: image
anchors.fill: parent anchors.fill: parent
source: "images/launcherBackground.png" source: "images/launcherBackground.png"
Text {
id: launcherVersion
x: 8
y: 8
color: "#ffffff"
text: Loc.t.app.label.version.arg(Qt.application.version)
font.pixelSize: 12
}
// Канал сборки из суффикса версии (cmake/LauncherVersion.cmake): у релиза бейджа нет
Rectangle {
id: channelBadge
readonly property var channel: /-(dev|rc)/.exec(Qt.application.version)
visible: channel !== null
anchors.left: launcherVersion.right
anchors.leftMargin: 6
anchors.verticalCenter: launcherVersion.verticalCenter
width: channelLabel.implicitWidth + 8
height: channelLabel.implicitHeight + 2
radius: 3
color: channel && channel[1] === "rc" ? "#c08a1e" : "#b03a3a"
Text {
id: channelLabel
anchors.centerIn: parent
text: channelBadge.channel ? channelBadge.channel[1].toUpperCase() : ""
color: "#ffffff"
font.pixelSize: 10
font.bold: true
}
}
} }
// ── Status toast ─────────────────────────────────────────────────────── // ── Status toast ───────────────────────────────────────────────────────
@@ -192,6 +227,10 @@ Window {
rotation: 0 rotation: 0
editable: false editable: false
hoverEnabled: true hoverEnabled: true
// Пока идёт загрузка или установка, профиль и сборку не меняем:
// единственное доступное действие — отмена на панели загрузки.
enabled: !backend.busy && !backend.gameRunning
opacity: enabled ? 1.0 : 0.45
model: backend.profileNames model: backend.profileNames
@@ -377,8 +416,6 @@ Window {
} }
// ── Активная сборка ──────────────────────────────────────────────────── // ── Активная сборка ────────────────────────────────────────────────────
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
// и её содержимое правятся теперь в модальном окне.
Button { Button {
id: buildButton id: buildButton
x: 624 x: 624
@@ -391,6 +428,7 @@ Window {
anchors.bottomMargin: -218 anchors.bottomMargin: -218
hoverEnabled: true hoverEnabled: true
enabled: !backend.gameRunning && !backend.switching enabled: !backend.gameRunning && !backend.switching
opacity: enabled ? 1.0 : 0.45
contentItem: Text { contentItem: Text {
text: backend.activeBuildName !== "" ? backend.activeBuildName text: backend.activeBuildName !== "" ? backend.activeBuildName
@@ -464,6 +502,7 @@ Window {
anchors.verticalCenter: folderButton.verticalCenter anchors.verticalCenter: folderButton.verticalCenter
anchors.leftMargin: 8 anchors.leftMargin: 8
hoverEnabled: true hoverEnabled: true
enabled: !backend.busy && !backend.gameRunning
background: Image { background: Image {
anchors.fill: parent anchors.fill: parent
+3 -3
View File
@@ -22,9 +22,9 @@ Dialog {
padding: 0 padding: 0
width: 960 width: 960
height: 560 height: 560
closePolicy: seasons.backend.seasonalInstalling // Закрывается и во время установки: отменить её можно только на панели
? Popup.NoAutoClose // загрузки главного окна, а модальное окно эту панель перекрывает.
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside) closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle { background: Rectangle {
color: "#1e1e1e" color: "#1e1e1e"
+101
View File
@@ -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)
+7
View File
@@ -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
+46 -5
View File
@@ -40,8 +40,41 @@ cmake --build build
Из Qt Creator проект открывается как обычный CMake-проект. Из Qt Creator проект открывается как обычный CMake-проект.
Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в ### Версия и канал сборки
`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза.
В репозитории хранится только число: `project(BiLauncher VERSION x.y.z)` в `CMakeLists.txt`. Оно
одинаковое во всех ветках, поэтому merge `dev` → `rc` → `master` версию не перетирает и не даёт
конфликтов. `PROJECT_VERSION` идёт в ресурс версии `.exe`, в `Info.plist` и в установщик: все три
принимают только числовую версию.
Канал (dev / rc / релиз) в коде не записан — его на каждой сборке вычисляет
`cmake/LauncherVersion.cmake` и пишет в сгенерированный `<build>/generated/launcherversion.h`
(`LAUNCHER_VERSION`, `LAUNCHER_CHANNEL`). Порядок определения:
1. `-DLAUNCHER_BUILD_VERSION=1.0.1-rc2` — полная версия от CI; база обязана совпадать с
`project()`, иначе configure падает;
2. тег на HEAD: `vx.y.z` — релиз, `vx.y.z-rcN` — rc;
3. ветка (из `GITHUB_HEAD_REF`/`GITHUB_REF_NAME` или git): `master` — релиз, `rc` и `beta` — rc;
4. любая другая ветка или сборка без git — dev.
| Сборка | `LAUNCHER_VERSION` | В окне |
|---------------------------|----------------------|-----------------|
| релиз (master, `vx.y.z`) | `1.0.1` | `Версия: 1.0.1` |
| rc из CI (`vx.y.z-rcN`) | `1.0.1-rc2` | + бейдж RC |
| rc локально | `1.0.1-rc` | + бейдж RC |
| dev | `1.0.1-dev+a1b2c3d` | + бейдж DEV |
У dev-сборки после `+` — короткий хэш коммита, `.dirty` — есть незакоммиченные правки в
отслеживаемых файлах. Генерация идёт на каждой сборке, так что после `git checkout` другой
ветки reconfigure не нужен.
Полная строка уходит в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в
подпись «Версия: …» в левом верхнем углу окна; бейдж канала QML берёт из её суффикса. Почему так,
см. [ADR-0002](adr/0002-launcher-version-display.md).
**Когда поднимать версию.** Номер поднимается в `dev` в начале цикла (сразу после релиза) и
дальше приходит в `rc` и `master` через merge. Хотфикс прямо в `rc` или `master` — правка и bump
patch там же, затем обратный merge `master` → `rc` → `dev`.
## Что собирается ## Что собирается
@@ -103,14 +136,22 @@ QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIREC
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md). поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`. 1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза. 2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает 3. Запустить релиз одним из способов:
релиз pre-release). - **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
прогон ничего не собирает: сначала поднимите версию.
- **Пуш в `rc`** — пре-релиз `vx.y.z-rcN`, где `N` — следующий свободный номер для этой версии
(`rc1`, `rc2`, ...).
- **Тег `vx.y.z`** — полноценный релиз, **тег `vx.y.z-rcN`** — пре-релиз. Пре-релизом
становится только тег с суффиксом `-rc`, любой другой суффикс даёт обычный релиз.
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает. Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
Для веток тег создаёт сам Gitea при публикации релиза, на коммите прогона. Этот тег снова
запускает `release.yml`, но прогон видит, что релиз уже есть, и ничего не собирает.
### Что происходит ### Что происходит
1. **create-release** (Linux) — сверяет тег с версией, вырезает раздел из `CHANGELOG.md` и 1. **create-release** (Linux) — определяет тег (из пуша тега или по ветке), сверяет его с версией, вырезает раздел из `CHANGELOG.md` и
создаёт **черновик** релиза. создаёт **черновик** релиза.
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику: 2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка - **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
+21 -3
View File
@@ -46,16 +46,34 @@
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно 3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
случиться до следующего шага, иначе поверх старых данных появится пустой файл. случиться до следующего шага, иначе поверх старых данных появится пустой файл.
4. `loadData()` читает профили и сборки и сортирует их по `id`. 4. `loadData()` читает профили и сборки и сортирует их по `id`.
5. `ensureDataFiles()` создаёт недостающие файлы. 5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через (см. ниже).
6. `ensureDataFiles()` создаёт недостающие файлы.
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново, `resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки. оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
7. Подписка на сигналы сервисов. 8. Подписка на сигналы сервисов.
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок), Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
моменту QML уже подписан на сигнал. моменту QML уже подписан на сигнал.
### Сборка из папки игры
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
пользователя «сборок нет», и импорта тогда нет.
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
## Файлы на диске ## Файлы на диске
Все пути берутся из [launcherpaths.h](launcherpaths.md). Все пути берутся из [launcherpaths.h](launcherpaths.md).
+57
View File
@@ -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
View File
@@ -10,7 +10,8 @@
приложения общий контекст OpenGL уже не выставить. приложения общий контекст OpenGL уже не выставить.
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет. 2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION` 3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
(берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На (в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её
через `Qt.application.version`), организация `GaleonDev`, домен `galeonworx.com`. На
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md). пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/` 4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла. (16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
+1
View File
@@ -122,6 +122,7 @@ LauncherBackend ──────────────── Loc (Localizati
|---|---| |---|---|
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения | | [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке | | [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат | | [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки | | [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера | | [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
+7 -2
View File
@@ -25,6 +25,10 @@
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
правом нижнем углу, потому что остальные углы заняты. правом нижнем углу, потому что остальные углы заняты.
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
(см. [main.cpp](../cpp/main.md)).
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях. Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
наоборот. Панель смены сборки отменить нельзя. наоборот. Панель смены сборки отменить нельзя.
@@ -63,8 +67,9 @@
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» | | `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
| `javaRuntimeInstalled` | обновляет строку Java в настройках | | `javaRuntimeInstalled` | обновляет строку Java в настройках |
Кнопка запуска выключена, пока `busy` или `gameRunning`. Кнопка активной сборки выключена во время Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`;
игры и смены сборки. кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
числе сезонной сборки) доступна только отмена на панели загрузки.
## Профили ## Профили
+3 -2
View File
@@ -48,8 +48,9 @@
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
без сети. без сети.
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только
главного окна, и случайный щелчок мимо окна не должен её закрыть. на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна
продолжается.
--- ---
+1
View File
@@ -49,6 +49,7 @@ CMake, тесты появятся в панели «Тесты».
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux | | unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена | | unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак | | unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
| unit | `tst_gamedirimport` | выбор версии по `launcher_profiles.json` и `versions/`, модлоадер по библиотекам и id, папка не меняется |
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) | | net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы | | net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу | | net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
+244
View File
@@ -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;
}
}
+38
View File
@@ -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
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "launcherversion.h"
#include "minecraftversion.h" #include "minecraftversion.h"
#include <QObject> #include <QObject>
+12
View File
@@ -31,6 +31,10 @@
} }
}, },
"strings": { "strings": {
"app.label.version": {
"ru": "Версия: %1",
"en": "Version: %1"
},
"app.title": { "app.title": {
"ru": "BiLauncher", "ru": "BiLauncher",
"en": "BiLauncher" "en": "BiLauncher"
@@ -379,6 +383,10 @@
"ru": "Активна сборка «%1»", "ru": "Активна сборка «%1»",
"en": "Build \"%1\" is active" "en": "Build \"%1\" is active"
}, },
"build.status.imported": {
"ru": "В папке игры уже были файлы Minecraft, под них создана сборка «%1». Проверьте её версию и модлоадер в окне сборок.",
"en": "The game folder already had Minecraft files, so the build \"%1\" was created for them. Check its version and mod loader in the builds window."
},
"build.status.named": { "build.status.named": {
"ru": "Сборка «%1»: %2", "ru": "Сборка «%1»: %2",
"en": "Build \"%1\": %2" "en": "Build \"%1\": %2"
@@ -387,6 +395,10 @@
"ru": "Готова к запуску", "ru": "Готова к запуску",
"en": "Ready to launch" "en": "Ready to launch"
}, },
"build.title.fromGameDir": {
"ru": "Сборка из папки игры",
"en": "Build from the game folder"
},
"build.title.numbered": { "build.title.numbered": {
"ru": "Сборка %1", "ru": "Сборка %1",
"en": "Build %1" "en": "Build %1"
+44
View File
@@ -15,6 +15,7 @@
#include "msaauthservice.h" #include "msaauthservice.h"
#include "modloaderversionservice.h" #include "modloaderversionservice.h"
#include "buildswitcher.h" #include "buildswitcher.h"
#include "gamedirimport.h"
#include "seasonalbuildservice.h" #include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h" #include "seasonalpackdownloader.h"
@@ -97,11 +98,20 @@ LauncherBackend::LauncherBackend(QObject *parent)
if (!LauncherPaths::ensureRootExists(&storageError)) if (!LauncherPaths::ensureRootExists(&storageError))
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError); m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
// Первый запуск — ни настроек, ни списка сборок, ни старого versions.json.
// Смотреть надо до loadData(): битый файл она отводит в .bak, а
// ensureDataFiles() создаёт пустые. Пустой customBuilds.json — это уже
// решение пользователя «сборок нет», и сборку из папки игры мы не заводим.
const bool firstRun = !QFile::exists(LauncherPaths::settingsFile())
&& !QFile::exists(LauncherPaths::customBuildsFile())
&& !QFile::exists(LauncherPaths::legacyCustomBuildsFile());
loadSettings(); loadSettings();
// Порядок важен: миграция должна успеть до того, как ensureDataFiles() // Порядок важен: миграция должна успеть до того, как ensureDataFiles()
// создаст пустой customBuilds.json поверх старых данных. // создаст пустой customBuilds.json поверх старых данных.
migrateLegacyBuildsFile(); migrateLegacyBuildsFile();
loadData(); loadData();
const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString();
ensureDataFiles(); ensureDataFiles();
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не // Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
@@ -370,6 +380,10 @@ LauncherBackend::LauncherBackend(QObject *parent)
}); });
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно. // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
// Ошибки хранилища идут следом: плашка одна, и важнее показать их.
if (!importedBuild.isEmpty()) {
QTimer::singleShot(0, this, [this, importedBuild] { emit gameDirImported(importedBuild); });
}
if (!m_storageIssues.isEmpty()) { if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] { QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n')); emit launchError(m_storageIssues.join(u'\n'));
@@ -2483,6 +2497,36 @@ bool LauncherBackend::migrateLegacyBuildsFile()
return true; return true;
} }
// Сборка описывает то, что уже разложено в .minecraft, поэтому BuildSwitcher не
// нужен: активная сборка без архива — штатное состояние. Через addCustomBuild()
// не идём, чтобы при старте не началась установка.
QString LauncherBackend::importGameDir()
{
const std::optional<GameDirImport::Detected> found = GameDirImport::detect(gameDir());
if (!found)
return {};
CustomBuild build;
build.id = m_nextBuildId++;
build.minecraftVersion = found->minecraftVersion;
build.resolvedVersionId = found->resolvedVersionId;
build.loader = found->loader;
build.loaderVersion = found->loaderVersion;
if (build.minecraftVersion.isEmpty()) {
build.name = Loc::text("build.title.fromGameDir");
} else if (const auto loader = loaderFromKey(build.loader)) {
build.name = loaderTitle(*loader) + u' ' + build.minecraftVersion;
} else {
build.name = QStringLiteral("Minecraft ") + build.minecraftVersion;
}
m_builds.append(build);
m_settings.insert(QStringLiteral("activeBuildId"), build.id);
saveSettings();
saveCustomBuilds();
return build.name;
}
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak — // Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована. // создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles() void LauncherBackend::ensureDataFiles()
+6
View File
@@ -232,6 +232,9 @@ signals:
// Сезонная сборка установлена и активна — можно запускать игру. // Сезонная сборка установлена и активна — можно запускать игру.
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName); void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
// Первый запуск: по содержимому папки игры заведена активная сборка.
void gameDirImported(const QString &buildName);
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь. // Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
void javaRuntimeInstalled(const QString &runtimeId); void javaRuntimeInstalled(const QString &runtimeId);
@@ -313,6 +316,9 @@ private:
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске // Перекладывает <root>/versions.json в customBuilds.json при первом запуске
// после переименования. true — миграция прошла, читать нужно новый файл. // после переименования. true — миграция прошла, читать нужно новый файл.
bool migrateLegacyBuildsFile(); bool migrateLegacyBuildsFile();
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
// Папку игры только читает. Возвращает имя сборки или пустую строку.
QString importGameDir();
void invalidateCatalog(); void invalidateCatalog();
void rebuildCatalog() const; void rebuildCatalog() const;
+1
View File
@@ -1,4 +1,5 @@
#include "launcherpaths.h" #include "launcherpaths.h"
#include "launcherversion.h"
#include "localization.h" #include "localization.h"
#include <QDebug> #include <QDebug>
+49 -49
View File
@@ -1,65 +1,65 @@
; Установщик BiLauncher для Windows (Inno Setup 6). #ifndef AppVersion
; #error Передайте версию: /DAppVersion=X.Y.Z
; Собирается в CI после `cmake --install`:
; iscc /DAppVersion=1.0.0 /DSourceDir=C:\...\stage /DOutputDir=C:\...\dist packaging\windows\BiLauncher.iss
; SourceDir — префикс установки: bin\ с BiLauncher.exe и развёрнутым windeployqt Qt,
; share\doc\BiLauncher\ с лицензиями.
#ifndef AppVersion
#error Передайте версию: /DAppVersion=X.Y.Z
#endif #endif
#ifndef SourceDir #ifndef SourceDir
#error Передайте префикс установки: /DSourceDir=... #error Передайте префикс установки: /DSourceDir=...
#endif #endif
#ifndef OutputDir #ifndef OutputDir
#define OutputDir "." #define OutputDir "."
#endif
#if Pos("-", AppVersion) > 0
#define NumericVersion Copy(AppVersion, 1, Pos("-", AppVersion) - 1)
#else
#define NumericVersion AppVersion
#endif #endif
[Setup] [Setup]
; AppId не менять между версиями — по нему обновление находит прежнюю установку. AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90} AppName=BiLauncher
AppName=BiLauncher AppVersion={#AppVersion}
AppVersion={#AppVersion} AppVerName=BiLauncher {#AppVersion}
AppVerName=BiLauncher {#AppVersion} AppPublisher=GaleonDev
AppPublisher=GaleonDev AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues VersionInfoVersion={#NumericVersion}
VersionInfoVersion={#AppVersion} VersionInfoTextVersion={#AppVersion}
DefaultDirName={autopf}\BiLauncher VersionInfoProductTextVersion={#AppVersion}
DefaultGroupName=BiLauncher DefaultDirName={autopf}\BiLauncher
DisableProgramGroupPage=yes DefaultGroupName=BiLauncher
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE DisableProgramGroupPage=yes
OutputDir={#OutputDir} LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup OutputDir={#OutputDir}
SetupIconFile=..\..\icons\icon.ico OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
UninstallDisplayIcon={app}\bin\BiLauncher.exe SetupIconFile=..\..\icons\icon.ico
Compression=lzma2/ultra64 UninstallDisplayIcon={app}\bin\BiLauncher.exe
SolidCompression=yes Compression=lzma2/ultra64
LZMANumBlockThreads=4 SolidCompression=yes
ArchitecturesAllowed=x64compatible LZMANumBlockThreads=4
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesAllowed=x64compatible
PrivilegesRequiredOverridesAllowed=dialog ArchitecturesInstallIn64BitMode=x64compatible
WizardStyle=modern PrivilegesRequiredOverridesAllowed=dialog
WizardStyle=modern
[Languages] [Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks] [Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[Files] [Files]
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe" Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
#ifexist SourceDir + "\bin\vc_redist.x64.exe" #ifexist SourceDir + "\bin\vc_redist.x64.exe"
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
#endif #endif
[Icons] [Icons]
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe" Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
[Run] [Run]
#ifexist SourceDir + "\bin\vc_redist.x64.exe" #ifexist SourceDir + "\bin\vc_redist.x64.exe"
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
#endif #endif
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
+2 -4
View File
@@ -58,10 +58,8 @@ qt_add_qml_module(bilauncher_testlib
RESOURCES ${_module_res} RESOURCES ${_module_res}
NO_LINT NO_LINT
) )
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}") target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}" "${LAUNCHER_GENERATED_DIR}")
target_compile_definitions(bilauncher_testlib PUBLIC add_dependencies(bilauncher_testlib launcher_version)
LAUNCHER_VERSION="${PROJECT_VERSION}"
)
target_link_libraries(bilauncher_testlib PUBLIC target_link_libraries(bilauncher_testlib PUBLIC
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
) )
+101
View File
@@ -76,6 +76,24 @@ class TestLauncherBackend : public QObject
return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config); return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config);
} }
// Папка игры, в которой до лаунчера играли с Fabric. Возвращает её состав.
static QStringList populateGameDir(const QString &dir)
{
QDir(dir).removeRecursively();
const QString fabric = QStringLiteral("fabric-loader-0.15.0-1.20.1");
Fixtures::write(dir + "/versions/1.20.1/1.20.1.json", R"({"id": "1.20.1", "type": "release"})");
Fixtures::write(dir + "/versions/" + fabric + "/" + fabric + ".json",
Fixtures::read("versions/" + fabric + ".json"));
Fixtures::writeJson(dir + "/launcher_profiles.json",
{{"profiles", QJsonObject{{"fabric", QJsonObject{
{"type", "custom"}, {"lastVersionId", fabric},
{"lastUsed", "2026-03-01T10:00:00.000Z"}}}}}});
Fixtures::write(dir + "/mods/sodium.jar", "jar");
Fixtures::write(dir + "/saves/World/level.dat", "world");
Fixtures::write(dir + "/options.txt", "fov:90");
return Fixtures::listTree(dir);
}
private slots: private slots:
void initTestCase() void initTestCase()
{ {
@@ -116,6 +134,89 @@ private slots:
Localization::instance().setLanguage(QStringLiteral("en")); Localization::instance().setLanguage(QStringLiteral("en"));
} }
void firstRunImportsGameDir()
{
// Настоящий первый запуск: ни одного файла лаунчера, папка игры по умолчанию.
QFile::remove(LauncherPaths::settingsFile());
const QString game = LauncherPaths::defaultMinecraftDir();
const QStringList before = populateGameDir(game);
const QByteArray profilesBefore = Fixtures::readFile(game + "/launcher_profiles.json");
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
QVERIFY(imported.wait(2000)); // сообщение откладывается до подключения интерфейса
QCOMPARE(imported.constFirst().constFirst().toString(), QStringLiteral("Fabric Loader 1.20.1"));
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
QCOMPARE(backend->activeBuildIndex(), 0);
const QVariantMap build = backend->customBuildAt(0);
QCOMPARE(build.value("minecraftVersion").toString(), QStringLiteral("1.20.1"));
QCOMPARE(build.value("loader").toString(), QStringLiteral("fabric"));
QCOMPARE(build.value("loaderVersion").toString(), QStringLiteral("0.15.0"));
QCOMPARE(build.value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.15.0-1.20.1"));
const int id = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile()))
.array().at(0).toObject().value("id").toInt();
QCOMPARE(id, 1);
QCOMPARE(backend->settings().value("activeBuildId").toInt(), id);
QCOMPARE(Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(), id);
// Ничего не качается, ничего не переключается, папка игры та же.
QVERIFY(!backend->downloading());
QVERIFY(!backend->switching());
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
}
QCOMPARE(Fixtures::listTree(game), before);
QCOMPARE(Fixtures::readFile(game + "/launcher_profiles.json"), profilesBefore);
QVERIFY(!QFile::exists(LauncherPaths::buildStorageDir() + "/index.json"));
// Второй запуск — уже не первый: новой сборки нет.
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
}
QDir(game).removeRecursively();
}
void importOnlyOnFirstRun()
{
// Настройки уже есть (init() их пишет), а списка сборок нет — это не первый запуск.
populateGameDir(m_game);
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QVERIFY(backend->customBuildNames().isEmpty());
}
// Пустой список сборок — пользователь сам всё удалил; воскрешать не надо.
TestEnv::resetLauncherRoot();
QVERIFY(LauncherPaths::ensureRootExists());
const QString game = LauncherPaths::defaultMinecraftDir();
populateGameDir(game);
Fixtures::write(LauncherPaths::customBuildsFile(), "[]");
{
auto backend = make();
QTest::qWait(50);
QVERIFY(backend->customBuildNames().isEmpty());
}
QDir(game).removeRecursively();
}
void firstRunWithEmptyGameDirCreatesNothing()
{
QFile::remove(LauncherPaths::settingsFile());
QDir(LauncherPaths::defaultMinecraftDir()).removeRecursively();
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QCOMPARE(backend->activeBuildIndex(), -1);
}
void profilesCrudAndPersistence() void profilesCrudAndPersistence()
{ {
{ {
+2 -1
View File
@@ -11,6 +11,7 @@ foreach(test IN ITEMS
tst_javalocator tst_javalocator
tst_zlibreference tst_zlibreference
tst_buildarchiveworker tst_buildarchiveworker
tst_buildswitcher) tst_buildswitcher
tst_gamedirimport)
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp) bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
endforeach() endforeach()
+243
View File
@@ -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"