Compare commits
12
Commits
v1.0.0
...
4ceaf000fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ceaf000fd | ||
|
|
2575aae5c6 | ||
|
|
6442514d4f | ||
|
|
ec60df859a | ||
|
|
0cacbc3ef3 | ||
|
|
5508b9a936 | ||
|
|
24115b4ce6 | ||
|
|
1d781b5324 | ||
|
|
bc205fa2e7 | ||
|
|
aa021269be | ||
|
|
92531c6e9a | ||
|
|
acd9334c16 |
No files matched your search
@@ -1,10 +1,18 @@
|
|||||||
name: release
|
name: release
|
||||||
|
|
||||||
|
# master -> релиз v<версия>, если такого ещё нет
|
||||||
|
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
|
||||||
|
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
|
||||||
|
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master, rc]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
|
||||||
@@ -14,31 +22,63 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.meta.outputs.version }}
|
version: ${{ steps.meta.outputs.version }}
|
||||||
tag: ${{ steps.meta.outputs.tag }}
|
tag: ${{ steps.meta.outputs.tag }}
|
||||||
|
skip: ${{ steps.meta.outputs.skip }}
|
||||||
release_id: ${{ steps.release.outputs.id }}
|
release_id: ${{ steps.release.outputs.id }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- id: meta
|
- id: meta
|
||||||
name: Версия из CMakeLists.txt и сверка с тегом
|
name: Версия из CMakeLists.txt, тег и тип релиза
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
|
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
|
||||||
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
|
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
|
||||||
tag=""
|
|
||||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
|
||||||
tag="${GITHUB_REF_NAME}"
|
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
|
||||||
base="${tag#v}"; base="${base%%-*}"
|
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
|
||||||
if [ "$base" != "$version" ]; then
|
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
|
||||||
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
|
||||||
fi
|
known() {
|
||||||
version="${tag#v}"
|
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
|
||||||
fi
|
printf '%s\n' "$release_tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
tag=""; skip=false
|
||||||
|
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
|
||||||
|
tag:*)
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
base="${tag#v}"; base="${base%%-*}"
|
||||||
|
if [ "$base" != "$version" ]; then
|
||||||
|
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||||
|
fi
|
||||||
|
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
|
||||||
|
if has_release "$tag"; then
|
||||||
|
echo "Релиз $tag уже есть"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:master)
|
||||||
|
tag="v$version"
|
||||||
|
if known | grep -xF "$tag" > /dev/null; then
|
||||||
|
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
branch:rc)
|
||||||
|
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
|
||||||
|
tag="v$version-rc$(( ${n:-0} + 1 ))"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ -n "$tag" ] && version="${tag#v}"
|
||||||
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
echo "Версия: $version, тег: ${tag:-нет}"
|
echo "skip=$skip" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
|
||||||
|
|
||||||
- id: release
|
- id: release
|
||||||
name: Черновик релиза
|
name: Черновик релиза
|
||||||
if: steps.meta.outputs.tag != ''
|
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
TAG: ${{ steps.meta.outputs.tag }}
|
TAG: ${{ steps.meta.outputs.tag }}
|
||||||
@@ -47,17 +87,19 @@ jobs:
|
|||||||
notes=$(awk -v v="$base" '
|
notes=$(awk -v v="$base" '
|
||||||
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||||
on' CHANGELOG.md)
|
on' CHANGELOG.md)
|
||||||
prerelease=false; case "$TAG" in *-*) prerelease=true;; esac
|
prerelease=false; case "$TAG" in *-rc*) prerelease=true;; esac
|
||||||
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
# target_commitish: у новой ветки тег появится на этом коммите при публикации
|
||||||
"$TAG" "$notes" "$prerelease")
|
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"target_commitish":sys.argv[4],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
||||||
|
"$TAG" "$notes" "$prerelease" "$GITHUB_SHA")
|
||||||
id=$(curl -fsS -X POST "$API/releases" \
|
id=$(curl -fsS -X POST "$API/releases" \
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||||
echo "Создан черновик релиза $TAG (id $id)"
|
echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
needs: create-release
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
@@ -111,6 +153,7 @@ jobs:
|
|||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
needs: create-release
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
runs-on: windows
|
runs-on: windows
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
@@ -181,6 +224,7 @@ jobs:
|
|||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
needs: create-release
|
needs: create-release
|
||||||
|
if: needs.create-release.outputs.skip != 'true'
|
||||||
runs-on: macos
|
runs-on: macos
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ replay_pid*
|
|||||||
build/
|
build/
|
||||||
.qtcreator/
|
.qtcreator/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/doc/adr/
|
||||||
CMakeLists.txt.user*
|
CMakeLists.txt.user*
|
||||||
stage/
|
stage/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
+5
-3
@@ -15,8 +15,9 @@ Dialog {
|
|||||||
padding: 0
|
padding: 0
|
||||||
width: 880
|
width: 880
|
||||||
height: 600
|
height: 600
|
||||||
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||||
|
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: "#1e1e1e"
|
color: "#1e1e1e"
|
||||||
@@ -314,7 +315,8 @@ Dialog {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||||
text: Loc.t.build.label.active
|
text: Loc.t.build.label.active
|
||||||
color: "#91B315"
|
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||||
|
? "#ffffff" : "#91B315"
|
||||||
font.pixelSize: 10
|
font.pixelSize: 10
|
||||||
}
|
}
|
||||||
Image {
|
Image {
|
||||||
|
|||||||
+31
-27
@@ -1,6 +1,27 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
|
project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C)
|
||||||
|
|
||||||
|
# Канал сборки (dev / rc / релиз) не хранится в репозитории, а вычисляется
|
||||||
|
# при сборке — см. cmake/LauncherVersion.cmake. CI передаёт полную версию:
|
||||||
|
# -DLAUNCHER_BUILD_VERSION=1.0.1-rc2.
|
||||||
|
set(LAUNCHER_BUILD_VERSION "" CACHE STRING "Полная версия от CI (X.Y.Z[-rcN]); пусто — по тегу и ветке git")
|
||||||
|
set(LAUNCHER_VERSION_NUMERIC "${PROJECT_VERSION}")
|
||||||
|
set(LAUNCHER_SOURCE_DIR "${PROJECT_SOURCE_DIR}")
|
||||||
|
set(LAUNCHER_GENERATED_DIR "${PROJECT_BINARY_DIR}/generated")
|
||||||
|
set(LAUNCHER_VERSION_HEADER "${LAUNCHER_GENERATED_DIR}/launcherversion.h")
|
||||||
|
include(cmake/LauncherVersion.cmake)
|
||||||
|
message(STATUS "Версия лаунчера: ${LAUNCHER_VERSION} (канал ${LAUNCHER_CHANNEL})")
|
||||||
|
add_custom_target(launcher_version
|
||||||
|
COMMAND "${CMAKE_COMMAND}"
|
||||||
|
"-DLAUNCHER_VERSION_NUMERIC=${LAUNCHER_VERSION_NUMERIC}"
|
||||||
|
"-DLAUNCHER_SOURCE_DIR=${LAUNCHER_SOURCE_DIR}"
|
||||||
|
"-DLAUNCHER_VERSION_HEADER=${LAUNCHER_VERSION_HEADER}"
|
||||||
|
"-DLAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION}"
|
||||||
|
-P "${PROJECT_SOURCE_DIR}/cmake/LauncherVersion.cmake"
|
||||||
|
BYPRODUCTS "${LAUNCHER_VERSION_HEADER}"
|
||||||
|
COMMENT "Версия лаунчера из git"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||||
@@ -19,15 +40,6 @@ endif()
|
|||||||
|
|
||||||
qt_standard_project_setup(REQUIRES 6.8)
|
qt_standard_project_setup(REQUIRES 6.8)
|
||||||
|
|
||||||
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
|
|
||||||
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
|
|
||||||
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
|
|
||||||
# который ставится ниже вместе с темой hicolor.
|
|
||||||
#
|
|
||||||
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
|
|
||||||
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
|
|
||||||
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
|
|
||||||
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
|
|
||||||
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
@@ -47,10 +59,6 @@ qt_add_executable(BiLauncher
|
|||||||
${LAUNCHER_APP_ICON}
|
${LAUNCHER_APP_ICON}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
|
|
||||||
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
|
|
||||||
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
|
|
||||||
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
|
|
||||||
qt_add_resources(BiLauncher "appicon"
|
qt_add_resources(BiLauncher "appicon"
|
||||||
PREFIX "/icons"
|
PREFIX "/icons"
|
||||||
BASE "icons"
|
BASE "icons"
|
||||||
@@ -70,9 +78,11 @@ qt_add_resources(BiLauncher "appicon"
|
|||||||
icons/icon_512x512@2x.png
|
icons/icon_512x512@2x.png
|
||||||
)
|
)
|
||||||
|
|
||||||
# OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в
|
# Путь импорта для QML code model Qt Creator / qmlls: модуль лежит в
|
||||||
# ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым
|
# ${CMAKE_CURRENT_BINARY_DIR}/qml, а не в корне каталога сборки.
|
||||||
# файлом (у него нет расширения), и линковка падает с «Is a directory».
|
set(QML_IMPORT_PATH "${CMAKE_CURRENT_BINARY_DIR}/qml"
|
||||||
|
CACHE STRING "Пути импорта QML для Qt Creator" FORCE)
|
||||||
|
|
||||||
qt_add_qml_module(BiLauncher
|
qt_add_qml_module(BiLauncher
|
||||||
URI BiLauncher
|
URI BiLauncher
|
||||||
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
||||||
@@ -114,6 +124,8 @@ qt_add_qml_module(BiLauncher
|
|||||||
buildarchiveworker.cpp
|
buildarchiveworker.cpp
|
||||||
buildswitcher.h
|
buildswitcher.h
|
||||||
buildswitcher.cpp
|
buildswitcher.cpp
|
||||||
|
gamedirimport.h
|
||||||
|
gamedirimport.cpp
|
||||||
seasonalbuildservice.h
|
seasonalbuildservice.h
|
||||||
seasonalbuildservice.cpp
|
seasonalbuildservice.cpp
|
||||||
seasonalpackdownloader.h
|
seasonalpackdownloader.h
|
||||||
@@ -144,8 +156,6 @@ set_target_properties(BiLauncher PROPERTIES
|
|||||||
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
||||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||||
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
||||||
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
|
|
||||||
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
|
|
||||||
MACOSX_BUNDLE_ICON_FILE icon.icns
|
MACOSX_BUNDLE_ICON_FILE icon.icns
|
||||||
MACOSX_BUNDLE TRUE
|
MACOSX_BUNDLE TRUE
|
||||||
WIN32_EXECUTABLE TRUE
|
WIN32_EXECUTABLE TRUE
|
||||||
@@ -155,9 +165,8 @@ target_link_libraries(BiLauncher
|
|||||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(BiLauncher PRIVATE
|
target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
|
||||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
add_dependencies(BiLauncher launcher_version)
|
||||||
)
|
|
||||||
|
|
||||||
if(Qt6WebEngineQuick_FOUND)
|
if(Qt6WebEngineQuick_FOUND)
|
||||||
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
||||||
@@ -206,8 +215,6 @@ if(UNIX AND NOT APPLE)
|
|||||||
)
|
)
|
||||||
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
||||||
|
|
||||||
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
|
|
||||||
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
|
|
||||||
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
||||||
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
||||||
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
||||||
@@ -243,9 +250,6 @@ qt_generate_deploy_qml_app_script(
|
|||||||
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
||||||
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
||||||
install(SCRIPT ${deploy_script})
|
install(SCRIPT ${deploy_script})
|
||||||
# Автотесты: собираются только по запросу и в установку не попадают.
|
|
||||||
# Код лаунчера при этом не меняется — тесты компилируют те же исходники
|
|
||||||
# в отдельную статическую библиотеку, см. tests/CMakeLists.txt и doc/testing.md.
|
|
||||||
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
|
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
|
||||||
if(BILAUNCHER_BUILD_TESTS)
|
if(BILAUNCHER_BUILD_TESTS)
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ Window {
|
|||||||
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
|
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
|
||||||
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
|
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
|
||||||
onGameOutput: (line) => console.log(line)
|
onGameOutput: (line) => console.log(line)
|
||||||
|
onGameDirImported: (buildName) => {
|
||||||
|
window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000)
|
||||||
|
}
|
||||||
onSeasonalInstallFinished: (seasonalId, buildName) => {
|
onSeasonalInstallFinished: (seasonalId, buildName) => {
|
||||||
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
|
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
|
||||||
"#4b7a1f", 8000)
|
"#4b7a1f", 8000)
|
||||||
@@ -89,6 +92,15 @@ Window {
|
|||||||
id: image
|
id: image
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
source: "images/launcherBackground.png"
|
source: "images/launcherBackground.png"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: launcherVersion
|
||||||
|
x: 8
|
||||||
|
y: 8
|
||||||
|
color: "#ffffff"
|
||||||
|
text: Loc.t.app.label.version.arg(Qt.application.version)
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status toast ───────────────────────────────────────────────────────
|
// ── Status toast ───────────────────────────────────────────────────────
|
||||||
@@ -192,6 +204,10 @@ Window {
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
editable: false
|
editable: false
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
// Пока идёт загрузка или установка, профиль и сборку не меняем:
|
||||||
|
// единственное доступное действие — отмена на панели загрузки.
|
||||||
|
enabled: !backend.busy && !backend.gameRunning
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
|
||||||
model: backend.profileNames
|
model: backend.profileNames
|
||||||
|
|
||||||
@@ -377,8 +393,6 @@ Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Активная сборка ────────────────────────────────────────────────────
|
// ── Активная сборка ────────────────────────────────────────────────────
|
||||||
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
|
|
||||||
// и её содержимое правятся теперь в модальном окне.
|
|
||||||
Button {
|
Button {
|
||||||
id: buildButton
|
id: buildButton
|
||||||
x: 624
|
x: 624
|
||||||
@@ -391,6 +405,7 @@ Window {
|
|||||||
anchors.bottomMargin: -218
|
anchors.bottomMargin: -218
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
enabled: !backend.gameRunning && !backend.switching
|
enabled: !backend.gameRunning && !backend.switching
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
|
||||||
contentItem: Text {
|
contentItem: Text {
|
||||||
text: backend.activeBuildName !== "" ? backend.activeBuildName
|
text: backend.activeBuildName !== "" ? backend.activeBuildName
|
||||||
@@ -464,6 +479,7 @@ Window {
|
|||||||
anchors.verticalCenter: folderButton.verticalCenter
|
anchors.verticalCenter: folderButton.verticalCenter
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: 8
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
enabled: !backend.busy && !backend.gameRunning
|
||||||
|
|
||||||
background: Image {
|
background: Image {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ Dialog {
|
|||||||
padding: 0
|
padding: 0
|
||||||
width: 960
|
width: 960
|
||||||
height: 560
|
height: 560
|
||||||
closePolicy: seasons.backend.seasonalInstalling
|
// Закрывается и во время установки: отменить её можно только на панели
|
||||||
? Popup.NoAutoClose
|
// загрузки главного окна, а модальное окно эту панель перекрывает.
|
||||||
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: "#1e1e1e"
|
color: "#1e1e1e"
|
||||||
|
|||||||
@@ -40,8 +40,15 @@ cmake --build build
|
|||||||
|
|
||||||
Из Qt Creator проект открывается как обычный CMake-проект.
|
Из Qt Creator проект открывается как обычный CMake-проект.
|
||||||
|
|
||||||
Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в
|
Версия приложения задаётся в начале `CMakeLists.txt` двумя значениями:
|
||||||
`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза.
|
|
||||||
|
- `project(BiLauncher VERSION x.y.z)` — только числа. Отсюда `PROJECT_VERSION` идёт в ресурс версии
|
||||||
|
`.exe`, в `Info.plist` и в установщик: все три принимают только числовую версию.
|
||||||
|
- `LAUNCHER_VERSION_SUFFIX` — метка пре-релиза, например `-dev` или `-rc1`. Для релиза пустая.
|
||||||
|
|
||||||
|
Полная строка `LAUNCHER_VERSION` = `PROJECT_VERSION` + суффикс (`1.0.1-dev`) уходит дефайном в код:
|
||||||
|
в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в подпись «Версия: …» в левом
|
||||||
|
верхнем углу окна. Почему так, см. [ADR-0002](adr/0002-launcher-version-display.md).
|
||||||
|
|
||||||
## Что собирается
|
## Что собирается
|
||||||
|
|
||||||
@@ -103,14 +110,22 @@ QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIREC
|
|||||||
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
|
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
|
||||||
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
|
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
|
||||||
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
|
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
|
||||||
3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает
|
3. Запустить релиз одним из способов:
|
||||||
релиз pre-release).
|
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
|
||||||
|
прогон ничего не собирает: сначала поднимите версию.
|
||||||
|
- **Пуш в `rc`** — пре-релиз `vx.y.z-rcN`, где `N` — следующий свободный номер для этой версии
|
||||||
|
(`rc1`, `rc2`, ...).
|
||||||
|
- **Тег `vx.y.z`** — полноценный релиз, **тег `vx.y.z-rcN`** — пре-релиз. Пре-релизом
|
||||||
|
становится только тег с суффиксом `-rc`, любой другой суффикс даёт обычный релиз.
|
||||||
|
|
||||||
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
|
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
|
||||||
|
|
||||||
|
Для веток тег создаёт сам Gitea при публикации релиза, на коммите прогона. Этот тег снова
|
||||||
|
запускает `release.yml`, но прогон видит, что релиз уже есть, и ничего не собирает.
|
||||||
|
|
||||||
### Что происходит
|
### Что происходит
|
||||||
|
|
||||||
1. **create-release** (Linux) — сверяет тег с версией, вырезает раздел из `CHANGELOG.md` и
|
1. **create-release** (Linux) — определяет тег (из пуша тега или по ветке), сверяет его с версией, вырезает раздел из `CHANGELOG.md` и
|
||||||
создаёт **черновик** релиза.
|
создаёт **черновик** релиза.
|
||||||
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
|
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
|
||||||
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
|
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
|
||||||
|
|||||||
@@ -46,16 +46,34 @@
|
|||||||
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
|
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
|
||||||
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
|
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
|
||||||
4. `loadData()` читает профили и сборки и сортирует их по `id`.
|
4. `loadData()` читает профили и сборки и сортирует их по `id`.
|
||||||
5. `ensureDataFiles()` создаёт недостающие файлы.
|
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
|
||||||
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
(см. ниже).
|
||||||
|
6. `ensureDataFiles()` создаёт недостающие файлы.
|
||||||
|
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
||||||
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
|
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
|
||||||
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
|
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
|
||||||
7. Подписка на сигналы сервисов.
|
8. Подписка на сигналы сервисов.
|
||||||
|
|
||||||
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
||||||
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
||||||
моменту QML уже подписан на сигнал.
|
моменту QML уже подписан на сигнал.
|
||||||
|
|
||||||
|
### Сборка из папки игры
|
||||||
|
|
||||||
|
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
|
||||||
|
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
|
||||||
|
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
|
||||||
|
пользователя «сборок нет», и импорта тогда нет.
|
||||||
|
|
||||||
|
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
|
||||||
|
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
|
||||||
|
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
|
||||||
|
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
|
||||||
|
|
||||||
|
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
|
||||||
|
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
|
||||||
|
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
|
||||||
|
|
||||||
## Файлы на диске
|
## Файлы на диске
|
||||||
|
|
||||||
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
||||||
|
|||||||
@@ -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 уже не выставить.
|
приложения общий контекст OpenGL уже не выставить.
|
||||||
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
||||||
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
|
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
|
||||||
(берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На
|
(в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её
|
||||||
|
через `Qt.application.version`), организация `GaleonDev`, домен `galeonworx.com`. На
|
||||||
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
|
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
|
||||||
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
||||||
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ LauncherBackend ──────────────── Loc (Localizati
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
|
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
|
||||||
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
|
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
|
||||||
|
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
|
||||||
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
|
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
|
||||||
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||||
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
|
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
|
||||||
|
|||||||
+7
-2
@@ -25,6 +25,10 @@
|
|||||||
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
|
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
|
||||||
правом нижнем углу, потому что остальные углы заняты.
|
правом нижнем углу, потому что остальные углы заняты.
|
||||||
|
|
||||||
|
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
|
||||||
|
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
|
||||||
|
(см. [main.cpp](../cpp/main.md)).
|
||||||
|
|
||||||
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
|
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
|
||||||
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
|
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
|
||||||
наоборот. Панель смены сборки отменить нельзя.
|
наоборот. Панель смены сборки отменить нельзя.
|
||||||
@@ -63,8 +67,9 @@
|
|||||||
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
|
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
|
||||||
| `javaRuntimeInstalled` | обновляет строку Java в настройках |
|
| `javaRuntimeInstalled` | обновляет строку Java в настройках |
|
||||||
|
|
||||||
Кнопка запуска выключена, пока `busy` или `gameRunning`. Кнопка активной сборки выключена во время
|
Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`;
|
||||||
игры и смены сборки.
|
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
|
||||||
|
числе сезонной сборки) доступна только отмена на панели загрузки.
|
||||||
|
|
||||||
## Профили
|
## Профили
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,9 @@
|
|||||||
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
|
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
|
||||||
без сети.
|
без сети.
|
||||||
|
|
||||||
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
|
Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только
|
||||||
главного окна, и случайный щелчок мимо окна не должен её закрыть.
|
на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна
|
||||||
|
продолжается.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ CMake, тесты появятся в панели «Тесты».
|
|||||||
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
|
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
|
||||||
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
|
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
|
||||||
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
|
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
|
||||||
|
| unit | `tst_gamedirimport` | выбор версии по `launcher_profiles.json` и `versions/`, модлоадер по библиотекам и id, папка не меняется |
|
||||||
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
|
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
|
||||||
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
|
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
|
||||||
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
|
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"strings": {
|
"strings": {
|
||||||
|
"app.label.version": {
|
||||||
|
"ru": "Версия: %1",
|
||||||
|
"en": "Version: %1"
|
||||||
|
},
|
||||||
"app.title": {
|
"app.title": {
|
||||||
"ru": "BiLauncher",
|
"ru": "BiLauncher",
|
||||||
"en": "BiLauncher"
|
"en": "BiLauncher"
|
||||||
@@ -379,6 +383,10 @@
|
|||||||
"ru": "Активна сборка «%1»",
|
"ru": "Активна сборка «%1»",
|
||||||
"en": "Build \"%1\" is active"
|
"en": "Build \"%1\" is active"
|
||||||
},
|
},
|
||||||
|
"build.status.imported": {
|
||||||
|
"ru": "В папке игры уже были файлы Minecraft, под них создана сборка «%1». Проверьте её версию и модлоадер в окне сборок.",
|
||||||
|
"en": "The game folder already had Minecraft files, so the build \"%1\" was created for them. Check its version and mod loader in the builds window."
|
||||||
|
},
|
||||||
"build.status.named": {
|
"build.status.named": {
|
||||||
"ru": "Сборка «%1»: %2",
|
"ru": "Сборка «%1»: %2",
|
||||||
"en": "Build \"%1\": %2"
|
"en": "Build \"%1\": %2"
|
||||||
@@ -387,6 +395,10 @@
|
|||||||
"ru": "Готова к запуску",
|
"ru": "Готова к запуску",
|
||||||
"en": "Ready to launch"
|
"en": "Ready to launch"
|
||||||
},
|
},
|
||||||
|
"build.title.fromGameDir": {
|
||||||
|
"ru": "Сборка из папки игры",
|
||||||
|
"en": "Build from the game folder"
|
||||||
|
},
|
||||||
"build.title.numbered": {
|
"build.title.numbered": {
|
||||||
"ru": "Сборка %1",
|
"ru": "Сборка %1",
|
||||||
"en": "Build %1"
|
"en": "Build %1"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "msaauthservice.h"
|
#include "msaauthservice.h"
|
||||||
#include "modloaderversionservice.h"
|
#include "modloaderversionservice.h"
|
||||||
#include "buildswitcher.h"
|
#include "buildswitcher.h"
|
||||||
|
#include "gamedirimport.h"
|
||||||
#include "seasonalbuildservice.h"
|
#include "seasonalbuildservice.h"
|
||||||
#include "seasonalpackdownloader.h"
|
#include "seasonalpackdownloader.h"
|
||||||
|
|
||||||
@@ -97,11 +98,20 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
|||||||
if (!LauncherPaths::ensureRootExists(&storageError))
|
if (!LauncherPaths::ensureRootExists(&storageError))
|
||||||
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
|
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
|
||||||
|
|
||||||
|
// Первый запуск — ни настроек, ни списка сборок, ни старого versions.json.
|
||||||
|
// Смотреть надо до loadData(): битый файл она отводит в .bak, а
|
||||||
|
// ensureDataFiles() создаёт пустые. Пустой customBuilds.json — это уже
|
||||||
|
// решение пользователя «сборок нет», и сборку из папки игры мы не заводим.
|
||||||
|
const bool firstRun = !QFile::exists(LauncherPaths::settingsFile())
|
||||||
|
&& !QFile::exists(LauncherPaths::customBuildsFile())
|
||||||
|
&& !QFile::exists(LauncherPaths::legacyCustomBuildsFile());
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
||||||
// создаст пустой customBuilds.json поверх старых данных.
|
// создаст пустой customBuilds.json поверх старых данных.
|
||||||
migrateLegacyBuildsFile();
|
migrateLegacyBuildsFile();
|
||||||
loadData();
|
loadData();
|
||||||
|
const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString();
|
||||||
ensureDataFiles();
|
ensureDataFiles();
|
||||||
|
|
||||||
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
||||||
@@ -370,6 +380,10 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
||||||
|
// Ошибки хранилища идут следом: плашка одна, и важнее показать их.
|
||||||
|
if (!importedBuild.isEmpty()) {
|
||||||
|
QTimer::singleShot(0, this, [this, importedBuild] { emit gameDirImported(importedBuild); });
|
||||||
|
}
|
||||||
if (!m_storageIssues.isEmpty()) {
|
if (!m_storageIssues.isEmpty()) {
|
||||||
QTimer::singleShot(0, this, [this] {
|
QTimer::singleShot(0, this, [this] {
|
||||||
emit launchError(m_storageIssues.join(u'\n'));
|
emit launchError(m_storageIssues.join(u'\n'));
|
||||||
@@ -2483,6 +2497,36 @@ bool LauncherBackend::migrateLegacyBuildsFile()
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сборка описывает то, что уже разложено в .minecraft, поэтому BuildSwitcher не
|
||||||
|
// нужен: активная сборка без архива — штатное состояние. Через addCustomBuild()
|
||||||
|
// не идём, чтобы при старте не началась установка.
|
||||||
|
QString LauncherBackend::importGameDir()
|
||||||
|
{
|
||||||
|
const std::optional<GameDirImport::Detected> found = GameDirImport::detect(gameDir());
|
||||||
|
if (!found)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
CustomBuild build;
|
||||||
|
build.id = m_nextBuildId++;
|
||||||
|
build.minecraftVersion = found->minecraftVersion;
|
||||||
|
build.resolvedVersionId = found->resolvedVersionId;
|
||||||
|
build.loader = found->loader;
|
||||||
|
build.loaderVersion = found->loaderVersion;
|
||||||
|
if (build.minecraftVersion.isEmpty()) {
|
||||||
|
build.name = Loc::text("build.title.fromGameDir");
|
||||||
|
} else if (const auto loader = loaderFromKey(build.loader)) {
|
||||||
|
build.name = loaderTitle(*loader) + u' ' + build.minecraftVersion;
|
||||||
|
} else {
|
||||||
|
build.name = QStringLiteral("Minecraft ") + build.minecraftVersion;
|
||||||
|
}
|
||||||
|
m_builds.append(build);
|
||||||
|
|
||||||
|
m_settings.insert(QStringLiteral("activeBuildId"), build.id);
|
||||||
|
saveSettings();
|
||||||
|
saveCustomBuilds();
|
||||||
|
return build.name;
|
||||||
|
}
|
||||||
|
|
||||||
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
|
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
|
||||||
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
|
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
|
||||||
void LauncherBackend::ensureDataFiles()
|
void LauncherBackend::ensureDataFiles()
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ signals:
|
|||||||
// Сезонная сборка установлена и активна — можно запускать игру.
|
// Сезонная сборка установлена и активна — можно запускать игру.
|
||||||
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
||||||
|
|
||||||
|
// Первый запуск: по содержимому папки игры заведена активная сборка.
|
||||||
|
void gameDirImported(const QString &buildName);
|
||||||
|
|
||||||
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
||||||
void javaRuntimeInstalled(const QString &runtimeId);
|
void javaRuntimeInstalled(const QString &runtimeId);
|
||||||
|
|
||||||
@@ -313,6 +316,9 @@ private:
|
|||||||
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
||||||
// после переименования. true — миграция прошла, читать нужно новый файл.
|
// после переименования. true — миграция прошла, читать нужно новый файл.
|
||||||
bool migrateLegacyBuildsFile();
|
bool migrateLegacyBuildsFile();
|
||||||
|
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
|
||||||
|
// Папку игры только читает. Возвращает имя сборки или пустую строку.
|
||||||
|
QString importGameDir();
|
||||||
|
|
||||||
void invalidateCatalog();
|
void invalidateCatalog();
|
||||||
void rebuildCatalog() const;
|
void rebuildCatalog() const;
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
; Установщик BiLauncher для Windows (Inno Setup 6).
|
#ifndef AppVersion
|
||||||
;
|
#error Передайте версию: /DAppVersion=X.Y.Z
|
||||||
; Собирается в CI после `cmake --install`:
|
|
||||||
; iscc /DAppVersion=1.0.0 /DSourceDir=C:\...\stage /DOutputDir=C:\...\dist packaging\windows\BiLauncher.iss
|
|
||||||
; SourceDir — префикс установки: bin\ с BiLauncher.exe и развёрнутым windeployqt Qt,
|
|
||||||
; share\doc\BiLauncher\ с лицензиями.
|
|
||||||
|
|
||||||
#ifndef AppVersion
|
|
||||||
#error Передайте версию: /DAppVersion=X.Y.Z
|
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceDir
|
#ifndef SourceDir
|
||||||
#error Передайте префикс установки: /DSourceDir=...
|
#error Передайте префикс установки: /DSourceDir=...
|
||||||
#endif
|
#endif
|
||||||
#ifndef OutputDir
|
#ifndef OutputDir
|
||||||
#define OutputDir "."
|
#define OutputDir "."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if Pos("-", AppVersion) > 0
|
||||||
|
#define NumericVersion Copy(AppVersion, 1, Pos("-", AppVersion) - 1)
|
||||||
|
#else
|
||||||
|
#define NumericVersion AppVersion
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
; AppId не менять между версиями — по нему обновление находит прежнюю установку.
|
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
||||||
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
AppName=BiLauncher
|
||||||
AppName=BiLauncher
|
AppVersion={#AppVersion}
|
||||||
AppVersion={#AppVersion}
|
AppVerName=BiLauncher {#AppVersion}
|
||||||
AppVerName=BiLauncher {#AppVersion}
|
AppPublisher=GaleonDev
|
||||||
AppPublisher=GaleonDev
|
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
||||||
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
||||||
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
VersionInfoVersion={#NumericVersion}
|
||||||
VersionInfoVersion={#AppVersion}
|
VersionInfoTextVersion={#AppVersion}
|
||||||
DefaultDirName={autopf}\BiLauncher
|
VersionInfoProductTextVersion={#AppVersion}
|
||||||
DefaultGroupName=BiLauncher
|
DefaultDirName={autopf}\BiLauncher
|
||||||
DisableProgramGroupPage=yes
|
DefaultGroupName=BiLauncher
|
||||||
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
DisableProgramGroupPage=yes
|
||||||
OutputDir={#OutputDir}
|
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
||||||
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
OutputDir={#OutputDir}
|
||||||
SetupIconFile=..\..\icons\icon.ico
|
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
||||||
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
SetupIconFile=..\..\icons\icon.ico
|
||||||
Compression=lzma2/ultra64
|
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
||||||
SolidCompression=yes
|
Compression=lzma2/ultra64
|
||||||
LZMANumBlockThreads=4
|
SolidCompression=yes
|
||||||
ArchitecturesAllowed=x64compatible
|
LZMANumBlockThreads=4
|
||||||
ArchitecturesInstallIn64BitMode=x64compatible
|
ArchitecturesAllowed=x64compatible
|
||||||
PrivilegesRequiredOverridesAllowed=dialog
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
WizardStyle=modern
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
|
WizardStyle=modern
|
||||||
|
|
||||||
[Languages]
|
[Languages]
|
||||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
||||||
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||||
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
||||||
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
|
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
|
||||||
@@ -60,7 +60,7 @@ qt_add_qml_module(bilauncher_testlib
|
|||||||
)
|
)
|
||||||
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
|
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
|
||||||
target_compile_definitions(bilauncher_testlib PUBLIC
|
target_compile_definitions(bilauncher_testlib PUBLIC
|
||||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
LAUNCHER_VERSION="${LAUNCHER_VERSION}"
|
||||||
)
|
)
|
||||||
target_link_libraries(bilauncher_testlib PUBLIC
|
target_link_libraries(bilauncher_testlib PUBLIC
|
||||||
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ class TestLauncherBackend : public QObject
|
|||||||
return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config);
|
return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Папка игры, в которой до лаунчера играли с Fabric. Возвращает её состав.
|
||||||
|
static QStringList populateGameDir(const QString &dir)
|
||||||
|
{
|
||||||
|
QDir(dir).removeRecursively();
|
||||||
|
const QString fabric = QStringLiteral("fabric-loader-0.15.0-1.20.1");
|
||||||
|
Fixtures::write(dir + "/versions/1.20.1/1.20.1.json", R"({"id": "1.20.1", "type": "release"})");
|
||||||
|
Fixtures::write(dir + "/versions/" + fabric + "/" + fabric + ".json",
|
||||||
|
Fixtures::read("versions/" + fabric + ".json"));
|
||||||
|
Fixtures::writeJson(dir + "/launcher_profiles.json",
|
||||||
|
{{"profiles", QJsonObject{{"fabric", QJsonObject{
|
||||||
|
{"type", "custom"}, {"lastVersionId", fabric},
|
||||||
|
{"lastUsed", "2026-03-01T10:00:00.000Z"}}}}}});
|
||||||
|
Fixtures::write(dir + "/mods/sodium.jar", "jar");
|
||||||
|
Fixtures::write(dir + "/saves/World/level.dat", "world");
|
||||||
|
Fixtures::write(dir + "/options.txt", "fov:90");
|
||||||
|
return Fixtures::listTree(dir);
|
||||||
|
}
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void initTestCase()
|
void initTestCase()
|
||||||
{
|
{
|
||||||
@@ -116,6 +134,89 @@ private slots:
|
|||||||
Localization::instance().setLanguage(QStringLiteral("en"));
|
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void firstRunImportsGameDir()
|
||||||
|
{
|
||||||
|
// Настоящий первый запуск: ни одного файла лаунчера, папка игры по умолчанию.
|
||||||
|
QFile::remove(LauncherPaths::settingsFile());
|
||||||
|
const QString game = LauncherPaths::defaultMinecraftDir();
|
||||||
|
const QStringList before = populateGameDir(game);
|
||||||
|
const QByteArray profilesBefore = Fixtures::readFile(game + "/launcher_profiles.json");
|
||||||
|
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||||
|
QVERIFY(imported.wait(2000)); // сообщение откладывается до подключения интерфейса
|
||||||
|
QCOMPARE(imported.constFirst().constFirst().toString(), QStringLiteral("Fabric Loader 1.20.1"));
|
||||||
|
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
|
||||||
|
QCOMPARE(backend->activeBuildIndex(), 0);
|
||||||
|
const QVariantMap build = backend->customBuildAt(0);
|
||||||
|
QCOMPARE(build.value("minecraftVersion").toString(), QStringLiteral("1.20.1"));
|
||||||
|
QCOMPARE(build.value("loader").toString(), QStringLiteral("fabric"));
|
||||||
|
QCOMPARE(build.value("loaderVersion").toString(), QStringLiteral("0.15.0"));
|
||||||
|
QCOMPARE(build.value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.15.0-1.20.1"));
|
||||||
|
const int id = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile()))
|
||||||
|
.array().at(0).toObject().value("id").toInt();
|
||||||
|
QCOMPARE(id, 1);
|
||||||
|
QCOMPARE(backend->settings().value("activeBuildId").toInt(), id);
|
||||||
|
QCOMPARE(Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(), id);
|
||||||
|
// Ничего не качается, ничего не переключается, папка игры та же.
|
||||||
|
QVERIFY(!backend->downloading());
|
||||||
|
QVERIFY(!backend->switching());
|
||||||
|
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||||
|
}
|
||||||
|
QCOMPARE(Fixtures::listTree(game), before);
|
||||||
|
QCOMPARE(Fixtures::readFile(game + "/launcher_profiles.json"), profilesBefore);
|
||||||
|
QVERIFY(!QFile::exists(LauncherPaths::buildStorageDir() + "/index.json"));
|
||||||
|
|
||||||
|
// Второй запуск — уже не первый: новой сборки нет.
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(imported.isEmpty());
|
||||||
|
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
|
||||||
|
}
|
||||||
|
QDir(game).removeRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
void importOnlyOnFirstRun()
|
||||||
|
{
|
||||||
|
// Настройки уже есть (init() их пишет), а списка сборок нет — это не первый запуск.
|
||||||
|
populateGameDir(m_game);
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(imported.isEmpty());
|
||||||
|
QVERIFY(backend->customBuildNames().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой список сборок — пользователь сам всё удалил; воскрешать не надо.
|
||||||
|
TestEnv::resetLauncherRoot();
|
||||||
|
QVERIFY(LauncherPaths::ensureRootExists());
|
||||||
|
const QString game = LauncherPaths::defaultMinecraftDir();
|
||||||
|
populateGameDir(game);
|
||||||
|
Fixtures::write(LauncherPaths::customBuildsFile(), "[]");
|
||||||
|
{
|
||||||
|
auto backend = make();
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(backend->customBuildNames().isEmpty());
|
||||||
|
}
|
||||||
|
QDir(game).removeRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
void firstRunWithEmptyGameDirCreatesNothing()
|
||||||
|
{
|
||||||
|
QFile::remove(LauncherPaths::settingsFile());
|
||||||
|
QDir(LauncherPaths::defaultMinecraftDir()).removeRecursively();
|
||||||
|
auto backend = make();
|
||||||
|
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||||
|
QTest::qWait(50);
|
||||||
|
QVERIFY(imported.isEmpty());
|
||||||
|
QCOMPARE(backend->activeBuildIndex(), -1);
|
||||||
|
}
|
||||||
|
|
||||||
void profilesCrudAndPersistence()
|
void profilesCrudAndPersistence()
|
||||||
{
|
{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -402,6 +402,13 @@ void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &resp
|
|||||||
socket->write(response.body.left(response.truncateTo));
|
socket->write(response.body.left(response.truncateTo));
|
||||||
socket->flush();
|
socket->flush();
|
||||||
socket->disconnectFromHost();
|
socket->disconnectFromHost();
|
||||||
|
// Schannel (Windows) на disconnectFromHost шлёт только TLS close_notify и держит
|
||||||
|
// TCP открытым, ожидая ответного close_notify, — клиент тогда висит до таймаута.
|
||||||
|
// Обрыв на то и обрыв: если сокет не закрылся сам, рвём соединение.
|
||||||
|
QTimer::singleShot(200, socket, [socket] {
|
||||||
|
if (socket && socket->state() != QAbstractSocket::UnconnectedState)
|
||||||
|
socket->abort();
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socket->write(response.body);
|
socket->write(response.body);
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ int main(int argc, char *argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!argsFile.empty()) {
|
if (!argsFile.empty()) {
|
||||||
std::ofstream out(argsFile, std::ios::trunc);
|
// binary: в текстовом режиме Windows превратит '\n' в "\r\n", и тесты увидят «-jar\r».
|
||||||
|
std::ofstream out(argsFile, std::ios::trunc | std::ios::binary);
|
||||||
for (int i = 1; i < argc; ++i)
|
for (int i = 1; i < argc; ++i)
|
||||||
out << argv[i] << '\n';
|
out << argv[i] << '\n';
|
||||||
for (const std::string &name : dumpEnv) {
|
for (const std::string &name : dumpEnv) {
|
||||||
|
|||||||
@@ -112,8 +112,11 @@ private slots:
|
|||||||
QCOMPARE(c.result.userType, QStringLiteral("msa"));
|
QCOMPARE(c.result.userType, QStringLiteral("msa"));
|
||||||
QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
|
QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
|
||||||
QVERIFY(!c.result.licenseMissing);
|
QVERIFY(!c.result.licenseMissing);
|
||||||
const qint64 lifetime = before.secsTo(c.result.expiresAt);
|
// expiresAt = момент ответа login_with_xbox + expires_in; на медленном раннере
|
||||||
QVERIFY(lifetime > 3500 && lifetime <= 3600);
|
// вся цепочка может идти дольше секунды, поэтому границы — до и после логина.
|
||||||
|
const QDateTime after = QDateTime::currentDateTimeUtc();
|
||||||
|
QVERIFY(c.result.expiresAt >= before.addSecs(3600));
|
||||||
|
QVERIFY(c.result.expiresAt <= after.addSecs(3600));
|
||||||
QVERIFY(progress.count() >= 5);
|
QVERIFY(progress.count() >= 5);
|
||||||
|
|
||||||
// Порядок шагов и формат запроса токена.
|
// Порядок шагов и формат запроса токена.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Дымовая проверка установленного дерева (результат cmake --install) на Windows.
|
# Дымовая проверка установленного дерева (результат cmake --install) на Windows.
|
||||||
#
|
#
|
||||||
# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage
|
# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
опустить планку, чтобы достигнутое не откатилось.
|
опустить планку, чтобы достигнутое не откатилось.
|
||||||
3. CHANGELOG.md содержит раздел для версии из project(BiLauncher VERSION …):
|
3. CHANGELOG.md содержит раздел для версии из project(BiLauncher VERSION …):
|
||||||
без него release.yml создаст релиз с пустым описанием (только предупреждение).
|
без него release.yml создаст релиз с пустым описанием (только предупреждение).
|
||||||
|
4. Скрипты .ps1 с не-ASCII символами сохранены в UTF-8 с BOM: без BOM Windows
|
||||||
|
PowerShell 5.1 читает их в ANSI-кодировке и падает на разборе кириллицы.
|
||||||
"""
|
"""
|
||||||
import os, re, subprocess, sys
|
import glob, os, re, subprocess, sys
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
BASELINE = os.path.join(ROOT, "tests", "qmllint-baseline.txt")
|
BASELINE = os.path.join(ROOT, "tests", "qmllint-baseline.txt")
|
||||||
@@ -75,12 +77,27 @@ def check_changelog():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_ps1_bom():
|
||||||
|
step("Кодировка .ps1")
|
||||||
|
ok = True
|
||||||
|
for path in glob.glob(os.path.join(ROOT, "**", "*.ps1"), recursive=True):
|
||||||
|
if os.path.relpath(path, ROOT).startswith("build"): # папки сборки
|
||||||
|
continue
|
||||||
|
data = open(path, "rb").read()
|
||||||
|
if not data.startswith(b"\xef\xbb\xbf") and any(b > 0x7F for b in data):
|
||||||
|
print(f"ОШИБКА: {os.path.relpath(path, ROOT)} содержит не-ASCII, но сохранён без UTF-8 BOM")
|
||||||
|
ok = False
|
||||||
|
if ok:
|
||||||
|
print("все .ps1 читаются Windows PowerShell 5.1")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
return 2
|
return 2
|
||||||
build_dir = os.path.abspath(sys.argv[1])
|
build_dir = os.path.abspath(sys.argv[1])
|
||||||
results = [check_translations(), check_qmllint(build_dir), check_changelog()]
|
results = [check_translations(), check_qmllint(build_dir), check_changelog(), check_ps1_bom()]
|
||||||
print("\nИТОГ:", "OK" if all(results) else "ЕСТЬ ОШИБКИ")
|
print("\nИТОГ:", "OK" if all(results) else "ЕСТЬ ОШИБКИ")
|
||||||
return 0 if all(results) else 1
|
return 0 if all(results) else 1
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ foreach(test IN ITEMS
|
|||||||
tst_javalocator
|
tst_javalocator
|
||||||
tst_zlibreference
|
tst_zlibreference
|
||||||
tst_buildarchiveworker
|
tst_buildarchiveworker
|
||||||
tst_buildswitcher)
|
tst_buildswitcher
|
||||||
|
tst_gamedirimport)
|
||||||
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
|
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
|
||||||
endforeach()
|
endforeach()
|
||||||
@@ -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