7 Commits
Author SHA1 Message Date
galeon 5508b9a936 Merge pull request 'Хотфиксы багов и небольшие изменения' (#31) from dev into rc
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / linux (push) Successful in 2m15s
tests / windows (push) Failing after 27m44s
tests / macos (push) Successful in 1m23s
release / create-release (push) Successful in 4s
release / build-linux (push) Successful in 3m45s
release / build-macos (push) Successful in 4m45s
release / publish (push) Canceled after 0s
release / build-windows (push) Canceled after 6m50s
Reviewed-on: #31
2026-10-23 09:56:20 +00: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
galeon acd9334c16 tests hotfix
tests / windows (push) Failing after 14m37s
tests / linux (push) Successful in 2m11s
tests / macos (push) Successful in 1m14s
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
2026-10-17 23:38:49 +03:00
28 changed files with 935 additions and 70 deletions

No files matched your search

+60 -16
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,17 +87,19 @@ 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 }}
@@ -111,6 +153,7 @@ 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 }}
@@ -181,6 +224,7 @@ 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 }}
+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 {
+11 -25
View File
@@ -1,6 +1,8 @@
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)
set(LAUNCHER_VERSION_SUFFIX "-dev")
set(LAUNCHER_VERSION "${PROJECT_VERSION}${LAUNCHER_VERSION_SUFFIX}")
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 +21,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 +40,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 +59,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 +105,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 +137,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
@@ -156,7 +147,7 @@ target_link_libraries(BiLauncher
) )
target_compile_definitions(BiLauncher PRIVATE target_compile_definitions(BiLauncher PRIVATE
LAUNCHER_VERSION="${PROJECT_VERSION}" LAUNCHER_VERSION="${LAUNCHER_VERSION}"
) )
if(Qt6WebEngineQuick_FOUND) if(Qt6WebEngineQuick_FOUND)
@@ -206,8 +197,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 +232,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()
+18 -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,15 @@ 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
}
} }
// ── Status toast ─────────────────────────────────────────────────────── // ── Status toast ───────────────────────────────────────────────────────
@@ -192,6 +204,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 +393,6 @@ Window {
} }
// ── Активная сборка ──────────────────────────────────────────────────── // ── Активная сборка ────────────────────────────────────────────────────
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
// и её содержимое правятся теперь в модальном окне.
Button { Button {
id: buildButton id: buildButton
x: 624 x: 624
@@ -391,6 +405,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 +479,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"
+20 -5
View File
@@ -40,8 +40,15 @@ cmake --build build
Из Qt Creator проект открывается как обычный CMake-проект. Из Qt Creator проект открывается как обычный CMake-проект.
Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в Версия приложения задаётся в начале `CMakeLists.txt` двумя значениями:
`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза.
- `project(BiLauncher VERSION x.y.z)` — только числа. Отсюда `PROJECT_VERSION` идёт в ресурс версии
`.exe`, в `Info.plist` и в установщик: все три принимают только числовую версию.
- `LAUNCHER_VERSION_SUFFIX` — метка пре-релиза, например `-dev` или `-rc1`. Для релиза пустая.
Полная строка `LAUNCHER_VERSION` = `PROJECT_VERSION` + суффикс (`1.0.1-dev`) уходит дефайном в код:
в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в подпись «Версия: …» в левом
верхнем углу окна. Почему так, см. [ADR-0002](adr/0002-launcher-version-display.md).
## Что собирается ## Что собирается
@@ -103,14 +110,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);
}
+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 -1
View File
@@ -60,7 +60,7 @@ qt_add_qml_module(bilauncher_testlib
) )
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}") target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
target_compile_definitions(bilauncher_testlib PUBLIC target_compile_definitions(bilauncher_testlib PUBLIC
LAUNCHER_VERSION="${PROJECT_VERSION}" LAUNCHER_VERSION="${LAUNCHER_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()
{ {
{ {
+7
View File
@@ -402,6 +402,13 @@ void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &resp
socket->write(response.body.left(response.truncateTo)); socket->write(response.body.left(response.truncateTo));
socket->flush(); socket->flush();
socket->disconnectFromHost(); socket->disconnectFromHost();
// Schannel (Windows) на disconnectFromHost шлёт только TLS close_notify и держит
// TCP открытым, ожидая ответного close_notify, — клиент тогда висит до таймаута.
// Обрыв на то и обрыв: если сокет не закрылся сам, рвём соединение.
QTimer::singleShot(200, socket, [socket] {
if (socket && socket->state() != QAbstractSocket::UnconnectedState)
socket->abort();
});
return; return;
} }
socket->write(response.body); socket->write(response.body);
+2 -1
View File
@@ -103,7 +103,8 @@ int main(int argc, char *argv[])
} }
if (!argsFile.empty()) { 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) for (int i = 1; i < argc; ++i)
out << argv[i] << '\n'; out << argv[i] << '\n';
for (const std::string &name : dumpEnv) { for (const std::string &name : dumpEnv) {
+5 -2
View File
@@ -112,8 +112,11 @@ private slots:
QCOMPARE(c.result.userType, QStringLiteral("msa")); QCOMPARE(c.result.userType, QStringLiteral("msa"));
QCOMPARE(c.result.clientToken, MsaAuthService::clientId()); QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
QVERIFY(!c.result.licenseMissing); QVERIFY(!c.result.licenseMissing);
const qint64 lifetime = before.secsTo(c.result.expiresAt); // expiresAt = момент ответа login_with_xbox + expires_in; на медленном раннере
QVERIFY(lifetime > 3500 && lifetime <= 3600); // вся цепочка может идти дольше секунды, поэтому границы — до и после логина.
const QDateTime after = QDateTime::currentDateTimeUtc();
QVERIFY(c.result.expiresAt >= before.addSecs(3600));
QVERIFY(c.result.expiresAt <= after.addSecs(3600));
QVERIFY(progress.count() >= 5); QVERIFY(progress.count() >= 5);
// Порядок шагов и формат запроса токена. // Порядок шагов и формат запроса токена.
+1 -1
View File
@@ -1,4 +1,4 @@
# Дымовая проверка установленного дерева (результат cmake --install) на Windows. # Дымовая проверка установленного дерева (результат cmake --install) на Windows.
# #
# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage # powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage
# #
+19 -2
View File
@@ -11,8 +11,10 @@
опустить планку, чтобы достигнутое не откатилось. опустить планку, чтобы достигнутое не откатилось.
3. CHANGELOG.md содержит раздел для версии из project(BiLauncher VERSION …): 3. CHANGELOG.md содержит раздел для версии из project(BiLauncher VERSION …):
без него release.yml создаст релиз с пустым описанием (только предупреждение). без него 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__)))) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BASELINE = os.path.join(ROOT, "tests", "qmllint-baseline.txt") BASELINE = os.path.join(ROOT, "tests", "qmllint-baseline.txt")
@@ -75,12 +77,27 @@ def check_changelog():
return True 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(): def main():
if len(sys.argv) != 2: if len(sys.argv) != 2:
print(__doc__) print(__doc__)
return 2 return 2
build_dir = os.path.abspath(sys.argv[1]) 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 "ЕСТЬ ОШИБКИ") print("\nИТОГ:", "OK" if all(results) else "ЕСТЬ ОШИБКИ")
return 0 if all(results) else 1 return 0 if all(results) else 1
+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"