1 Commits
Author SHA1 Message Date
galeon 7ec4528292 Обновить README.md
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m21s
tests / linux (push) Successful in 7m35s
tests / windows (push) Successful in 7m35s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-26 07:11:00 +00:00
25 changed files with 110 additions and 965 deletions

No files matched your search

+10 -54
View File
@@ -1,18 +1,10 @@
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 }}
@@ -22,63 +14,31 @@ 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=""
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их) if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
known() {
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
printf '%s\n' "$release_tags"
}
tag=""; skip=false
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
tag:*)
tag="${GITHUB_REF_NAME}" tag="${GITHUB_REF_NAME}"
base="${tag#v}"; base="${base%%-*}" base="${tag#v}"; base="${base%%-*}"
if [ "$base" != "$version" ]; then if [ "$base" != "$version" ]; then
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1 echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
fi fi
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем version="${tag#v}"
if has_release "$tag"; then
echo "Релиз $tag уже есть"; skip=true
fi 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 "skip=$skip" >> "$GITHUB_OUTPUT" echo "Версия: $version, тег: ${tag:-нет}"
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
- id: release - id: release
name: Черновик релиза name: Черновик релиза
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true' if: steps.meta.outputs.tag != ''
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }} TAG: ${{ steps.meta.outputs.tag }}
@@ -87,19 +47,17 @@ 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 *-rc*) prerelease=true;; esac prerelease=false; case "$TAG" in *-*) prerelease=true;; esac
# target_commitish: у новой ветки тег появится на этом коммите при публикации 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"}))' \
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")
"$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 "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)" 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 }}
@@ -153,7 +111,6 @@ 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 }}
@@ -224,7 +181,6 @@ 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,7 +61,6 @@ replay_pid*
build/ build/
.qtcreator/ .qtcreator/
.DS_Store .DS_Store
/doc/adr/
CMakeLists.txt.user* CMakeLists.txt.user*
stage/ stage/
dist/ dist/
+3 -5
View File
@@ -15,9 +15,8 @@ Dialog {
padding: 0 padding: 0
width: 880 width: 880
height: 600 height: 600
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle { background: Rectangle {
color: "#1e1e1e" color: "#1e1e1e"
@@ -315,8 +314,7 @@ 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: (buildsDialog.editIndex === buildRow.index || buildRow.hovered) color: "#91B315"
? "#ffffff" : "#91B315"
font.pixelSize: 10 font.pixelSize: 10
} }
Image { Image {
+27 -31
View File
@@ -1,27 +1,6 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C) project(BiLauncher VERSION 1.0.0 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)
@@ -40,6 +19,15 @@ 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)
@@ -59,6 +47,10 @@ 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"
@@ -78,11 +70,9 @@ qt_add_resources(BiLauncher "appicon"
icons/icon_512x512@2x.png icons/icon_512x512@2x.png
) )
# Путь импорта для QML code model Qt Creator / qmlls: модуль лежит в # OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в
# ${CMAKE_CURRENT_BINARY_DIR}/qml, а не в корне каталога сборки. # ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым
set(QML_IMPORT_PATH "${CMAKE_CURRENT_BINARY_DIR}/qml" # файлом (у него нет расширения), и линковка падает с «Is a directory».
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"
@@ -124,8 +114,6 @@ 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
@@ -156,6 +144,8 @@ 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
@@ -165,8 +155,9 @@ 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_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}") target_compile_definitions(BiLauncher PRIVATE
add_dependencies(BiLauncher launcher_version) LAUNCHER_VERSION="${PROJECT_VERSION}"
)
if(Qt6WebEngineQuick_FOUND) if(Qt6WebEngineQuick_FOUND)
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick) target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
@@ -215,6 +206,8 @@ 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"
@@ -250,6 +243,9 @@ 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()
+2 -18
View File
@@ -39,9 +39,6 @@ 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)
@@ -92,15 +89,6 @@ 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 ───────────────────────────────────────────────────────
@@ -204,10 +192,6 @@ 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
@@ -393,6 +377,8 @@ Window {
} }
// ── Активная сборка ──────────────────────────────────────────────────── // ── Активная сборка ────────────────────────────────────────────────────
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
// и её содержимое правятся теперь в модальном окне.
Button { Button {
id: buildButton id: buildButton
x: 624 x: 624
@@ -405,7 +391,6 @@ 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
@@ -479,7 +464,6 @@ 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
+1 -1
View File
@@ -27,7 +27,7 @@
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/> <img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
</p> </p>
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависиомстей для конечных пользователей</h3> <h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависимостей для конечных пользователей</h3>
<p float="left"> <p float="left">
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/> <img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
+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
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside : (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
background: Rectangle { background: Rectangle {
color: "#1e1e1e" color: "#1e1e1e"
+5 -20
View File
@@ -40,15 +40,8 @@ cmake --build build
Из Qt Creator проект открывается как обычный CMake-проект. Из Qt Creator проект открывается как обычный CMake-проект.
Версия приложения задаётся в начале `CMakeLists.txt` двумя значениями: Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в
`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).
## Что собирается ## Что собирается
@@ -110,22 +103,14 @@ 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. Запустить релиз одним из способов: 3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть, релиз pre-release).
прогон ничего не собирает: сначала поднимите версию.
- **Пуш в `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`. Сборка
+3 -21
View File
@@ -46,34 +46,16 @@
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно 3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
случиться до следующего шага, иначе поверх старых данных появится пустой файл. случиться до следующего шага, иначе поверх старых данных появится пустой файл.
4. `loadData()` читает профили и сборки и сортирует их по `id`. 4. `loadData()` читает профили и сборки и сортирует их по `id`.
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры 5. `ensureDataFiles()` создаёт недостающие файлы.
(см. ниже). 6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
6. `ensureDataFiles()` создаёт недостающие файлы.
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново, `resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки. оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
8. Подписка на сигналы сервисов. 7. Подписка на сигналы сервисов.
Все проблемы, найденные по пути (битый 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
@@ -1,57 +0,0 @@
# 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`.
---
При создании этого документа использовался ИИ.
+1 -2
View File
@@ -10,8 +10,7 @@
приложения общий контекст OpenGL уже не выставить. приложения общий контекст OpenGL уже не выставить.
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет. 2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION` 3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
(в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её (берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На
через `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,7 +122,6 @@ 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) | все пути к данным лаунчера |
+2 -7
View File
@@ -25,10 +25,6 @@
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
правом нижнем углу, потому что остальные углы заняты. правом нижнем углу, потому что остальные углы заняты.
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
(см. [main.cpp](../cpp/main.md)).
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях. Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
наоборот. Панель смены сборки отменить нельзя. наоборот. Панель смены сборки отменить нельзя.
@@ -67,9 +63,8 @@
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» | | `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
| `javaRuntimeInstalled` | обновляет строку Java в настройках | | `javaRuntimeInstalled` | обновляет строку Java в настройках |
Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`; Кнопка запуска выключена, пока `busy` или `gameRunning`. Кнопка активной сборки выключена во время
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том игры и смены сборки.
числе сезонной сборки) доступна только отмена на панели загрузки.
## Профили ## Профили
+2 -3
View File
@@ -48,9 +48,8 @@
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
без сети. без сети.
Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна главного окна, и случайный щелчок мимо окна не должен её закрыть.
продолжается.
--- ---
-1
View File
@@ -49,7 +49,6 @@ 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
@@ -1,244 +0,0 @@
#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
@@ -1,38 +0,0 @@
#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,10 +31,6 @@
} }
}, },
"strings": { "strings": {
"app.label.version": {
"ru": "Версия: %1",
"en": "Version: %1"
},
"app.title": { "app.title": {
"ru": "BiLauncher", "ru": "BiLauncher",
"en": "BiLauncher" "en": "BiLauncher"
@@ -383,10 +379,6 @@
"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"
@@ -395,10 +387,6 @@
"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,7 +15,6 @@
#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"
@@ -98,20 +97,11 @@ 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();
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не // Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
@@ -380,10 +370,6 @@ 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'));
@@ -2497,36 +2483,6 @@ 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,9 +232,6 @@ 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);
@@ -316,9 +313,6 @@ 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;
+49 -49
View File
@@ -1,65 +1,65 @@
#ifndef AppVersion ; Установщик BiLauncher для Windows (Inno Setup 6).
#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={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90} ; AppId не менять между версиями — по нему обновление находит прежнюю установку.
AppName=BiLauncher AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
AppVersion={#AppVersion} AppName=BiLauncher
AppVerName=BiLauncher {#AppVersion} AppVersion={#AppVersion}
AppPublisher=GaleonDev AppVerName=BiLauncher {#AppVersion}
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher AppPublisher=GaleonDev
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
VersionInfoVersion={#NumericVersion} AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
VersionInfoTextVersion={#AppVersion} VersionInfoVersion={#AppVersion}
VersionInfoProductTextVersion={#AppVersion} DefaultDirName={autopf}\BiLauncher
DefaultDirName={autopf}\BiLauncher DefaultGroupName=BiLauncher
DefaultGroupName=BiLauncher DisableProgramGroupPage=yes
DisableProgramGroupPage=yes LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE OutputDir={#OutputDir}
OutputDir={#OutputDir} OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup SetupIconFile=..\..\icons\icon.ico
SetupIconFile=..\..\icons\icon.ico UninstallDisplayIcon={app}\bin\BiLauncher.exe
UninstallDisplayIcon={app}\bin\BiLauncher.exe Compression=lzma2/ultra64
Compression=lzma2/ultra64 SolidCompression=yes
SolidCompression=yes LZMANumBlockThreads=4
LZMANumBlockThreads=4 ArchitecturesAllowed=x64compatible
ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequiredOverridesAllowed=dialog
PrivilegesRequiredOverridesAllowed=dialog WizardStyle=modern
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
+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="${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,24 +76,6 @@ 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()
{ {
@@ -134,89 +116,6 @@ 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()
{ {
{ {
+1 -2
View File
@@ -11,7 +11,6 @@ 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
@@ -1,243 +0,0 @@
#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"