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