Compare commits
11
Commits
4ceaf000fd
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5635dcc558 | ||
|
|
6a2b596f32 | ||
|
|
b94d852d7c | ||
|
|
47d30b1594 | ||
|
|
56dd58b8d1 | ||
|
|
50e0cd2aab | ||
|
|
d82a9d0729 | ||
|
|
1817157e4d | ||
|
|
4463d4e5da | ||
|
|
7dd89db508 | ||
|
|
a89bf20486 |
No files matched your search
@@ -6,7 +6,7 @@ name: release
|
|||||||
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, rc]
|
#branches: [master, rc]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -83,10 +83,16 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
TAG: ${{ steps.meta.outputs.tag }}
|
TAG: ${{ steps.meta.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
base="${TAG#v}"; base="${base%%-*}"
|
full="${TAG#v}"; base="${full%%-*}"
|
||||||
notes=$(awk -v v="$base" '
|
section() {
|
||||||
|
awk -v v="$1" '
|
||||||
/^## \[/ { 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
|
||||||
|
}
|
||||||
|
# Сначала раздел ровно под тег ([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
|
prerelease=false; case "$TAG" in *-rc*) prerelease=true;; esac
|
||||||
# target_commitish: у новой ветки тег появится на этом коммите при публикации
|
# 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"}))' \
|
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"}))' \
|
||||||
@@ -103,6 +109,8 @@ jobs:
|
|||||||
runs-on: linux
|
runs-on: linux
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
outputs:
|
outputs:
|
||||||
sums: ${{ steps.pack.outputs.sums }}
|
sums: ${{ steps.pack.outputs.sums }}
|
||||||
@@ -114,7 +122,7 @@ jobs:
|
|||||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
rm -rf build AppDir dist
|
rm -rf build AppDir dist
|
||||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||||
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
|
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
|
||||||
cmake --build build -j 8
|
cmake --build build -j 8
|
||||||
cmake --install build --prefix "$PWD/AppDir/usr"
|
cmake --install build --prefix "$PWD/AppDir/usr"
|
||||||
@@ -157,6 +165,8 @@ jobs:
|
|||||||
runs-on: windows
|
runs-on: windows
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
outputs:
|
outputs:
|
||||||
sums: ${{ steps.build.outputs.sums }}
|
sums: ${{ steps.build.outputs.sums }}
|
||||||
@@ -177,7 +187,7 @@ jobs:
|
|||||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||||
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||||
|
|
||||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT" "-DLAUNCHER_BUILD_VERSION=$env:BUILD_VERSION"
|
||||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||||
cmake --build $build
|
cmake --build $build
|
||||||
if ($LASTEXITCODE) { throw "cmake build" }
|
if ($LASTEXITCODE) { throw "cmake build" }
|
||||||
@@ -228,6 +238,8 @@ jobs:
|
|||||||
runs-on: macos
|
runs-on: macos
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.create-release.outputs.version }}
|
VERSION: ${{ needs.create-release.outputs.version }}
|
||||||
|
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||||
|
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
outputs:
|
outputs:
|
||||||
sums: ${{ steps.pack.outputs.sums }}
|
sums: ${{ steps.pack.outputs.sums }}
|
||||||
@@ -239,7 +251,7 @@ jobs:
|
|||||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||||
rm -rf build stage dist dmgroot
|
rm -rf build stage dist dmgroot
|
||||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
||||||
cmake --build build
|
cmake --build build
|
||||||
cmake --install build --prefix "$PWD/stage"
|
cmake --install build --prefix "$PWD/stage"
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ concurrency:
|
|||||||
group: tests-${{ github.ref }}
|
group: tests-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux:
|
linux:
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
|
||||||
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -81,7 +82,9 @@ jobs:
|
|||||||
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
||||||
$code = $LASTEXITCODE
|
$code = $LASTEXITCODE
|
||||||
New-Item -ItemType Directory -Force test-results | Out-Null
|
New-Item -ItemType Directory -Force test-results | Out-Null
|
||||||
|
git rev-parse HEAD | Set-Content -Encoding UTF8 test-results\commit.txt
|
||||||
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
|
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
|
||||||
|
Copy-Item "$build\Testing\Temporary\LastTest.log" test-results\ -ErrorAction SilentlyContinue
|
||||||
if ($code) { throw "ctest" }
|
if ($code) { throw "ctest" }
|
||||||
|
|
||||||
- name: Отчёты
|
- name: Отчёты
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Dialog {
|
|||||||
id: buildsDialog
|
id: buildsDialog
|
||||||
|
|
||||||
required property var backend
|
required property var backend
|
||||||
|
enabled: !buildsDialog.backend.seasonalInstalling
|
||||||
property int editIndex: -1
|
property int editIndex: -1
|
||||||
property bool loading: false
|
property bool loading: false
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,12 @@
|
|||||||
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
||||||
### Исправлено
|
### Исправлено
|
||||||
- UI фиксы
|
- UI фиксы
|
||||||
|
|
||||||
|
## [1.0.1-rc1] — 2026-10-23
|
||||||
|
### Добавлено
|
||||||
|
- Отображене текущей версии сборки лаунчера на главном окне
|
||||||
|
### Исправлено
|
||||||
|
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
|
||||||
|
- Исправлен баг отображения статуса активной сборки на зелёном фоне
|
||||||
|
- Исправлен баг, что во время игры можно было потенциально сменить сборку
|
||||||
|
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
|
||||||
@@ -101,6 +101,29 @@ Window {
|
|||||||
text: Loc.t.app.label.version.arg(Qt.application.version)
|
text: Loc.t.app.label.version.arg(Qt.application.version)
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Канал сборки из суффикса версии (cmake/LauncherVersion.cmake): у релиза бейджа нет
|
||||||
|
Rectangle {
|
||||||
|
id: channelBadge
|
||||||
|
readonly property var channel: /-(dev|rc)/.exec(Qt.application.version)
|
||||||
|
visible: channel !== null
|
||||||
|
anchors.left: launcherVersion.right
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.verticalCenter: launcherVersion.verticalCenter
|
||||||
|
width: channelLabel.implicitWidth + 8
|
||||||
|
height: channelLabel.implicitHeight + 2
|
||||||
|
radius: 3
|
||||||
|
color: channel && channel[1] === "rc" ? "#c08a1e" : "#b03a3a"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: channelLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: channelBadge.channel ? channelBadge.channel[1].toUpperCase() : ""
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status toast ───────────────────────────────────────────────────────
|
// ── Status toast ───────────────────────────────────────────────────────
|
||||||
@@ -404,7 +427,7 @@ Window {
|
|||||||
anchors.rightMargin: 0
|
anchors.rightMargin: 0
|
||||||
anchors.bottomMargin: -218
|
anchors.bottomMargin: -218
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
enabled: !backend.gameRunning && !backend.switching
|
enabled: !backend.gameRunning && !backend.switching && !backend.seasonalInstalling
|
||||||
opacity: enabled ? 1.0 : 0.45
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
|
||||||
contentItem: Text {
|
contentItem: Text {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависиомстей для конечных пользователей</h3>
|
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависимостей для конечных пользователей</h3>
|
||||||
|
|
||||||
<p float="left">
|
<p float="left">
|
||||||
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
|
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
|
||||||
|
|||||||
@@ -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,15 +40,41 @@ cmake --build build
|
|||||||
|
|
||||||
Из Qt Creator проект открывается как обычный CMake-проект.
|
Из Qt Creator проект открывается как обычный CMake-проект.
|
||||||
|
|
||||||
Версия приложения задаётся в начале `CMakeLists.txt` двумя значениями:
|
### Версия и канал сборки
|
||||||
|
|
||||||
- `project(BiLauncher VERSION x.y.z)` — только числа. Отсюда `PROJECT_VERSION` идёт в ресурс версии
|
В репозитории хранится только число: `project(BiLauncher VERSION x.y.z)` в `CMakeLists.txt`. Оно
|
||||||
`.exe`, в `Info.plist` и в установщик: все три принимают только числовую версию.
|
одинаковое во всех ветках, поэтому merge `dev` → `rc` → `master` версию не перетирает и не даёт
|
||||||
- `LAUNCHER_VERSION_SUFFIX` — метка пре-релиза, например `-dev` или `-rc1`. Для релиза пустая.
|
конфликтов. `PROJECT_VERSION` идёт в ресурс версии `.exe`, в `Info.plist` и в установщик: все три
|
||||||
|
принимают только числовую версию.
|
||||||
|
|
||||||
Полная строка `LAUNCHER_VERSION` = `PROJECT_VERSION` + суффикс (`1.0.1-dev`) уходит дефайном в код:
|
Канал (dev / rc / релиз) в коде не записан — его на каждой сборке вычисляет
|
||||||
в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в подпись «Версия: …» в левом
|
`cmake/LauncherVersion.cmake` и пишет в сгенерированный `<build>/generated/launcherversion.h`
|
||||||
верхнем углу окна. Почему так, см. [ADR-0002](adr/0002-launcher-version-display.md).
|
(`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`.
|
||||||
|
|
||||||
## Что собирается
|
## Что собирается
|
||||||
|
|
||||||
@@ -110,6 +136,8 @@ 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] — дата`. Его текст станет описанием релиза.
|
||||||
|
Для пре-релиза можно завести отдельный раздел `## [x.y.z-rcN]`: он берётся в первую очередь,
|
||||||
|
а если его нет — используется `## [x.y.z]`.
|
||||||
3. Запустить релиз одним из способов:
|
3. Запустить релиз одним из способов:
|
||||||
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
|
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
|
||||||
прогон ничего не собирает: сначала поднимите версию.
|
прогон ничего не собирает: сначала поднимите версию.
|
||||||
|
|||||||
+4
-3
@@ -87,7 +87,8 @@ CMake, тесты появятся в панели «Тесты».
|
|||||||
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
|
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
|
||||||
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
|
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
|
||||||
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
|
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
|
||||||
сообщением, а в CI на Linux (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
|
сообщением, а в CI на всех ОС (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
|
||||||
|
QML-тесты также завершаются ошибкой, если стенд не поднялся.
|
||||||
|
|
||||||
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
|
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
|
||||||
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
|
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
|
||||||
@@ -112,7 +113,6 @@ CMake, тесты появятся в панели «Тесты».
|
|||||||
| Тест | Проблема |
|
| Тест | Проблема |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» |
|
| `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» |
|
||||||
| `tst_modloaderversionservice::neoForgeLegacyArtifactArrivesFirst` | если ответ старого артефакта `net.neoforged:forge` приходит раньше основного, основной затирает слитые записи (`state->merged = parsed`), и версии NeoForge для 1.20.1 пропадают |
|
|
||||||
| `tst_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` |
|
| `tst_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` |
|
||||||
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
|
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
|
||||||
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
|
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
|
||||||
@@ -157,7 +157,8 @@ WebEngine задерживала бы релизы. Перед выпуском
|
|||||||
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
|
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
|
||||||
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
|
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
|
||||||
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
|
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
|
||||||
`hang`, `drop`, `truncateTo`.
|
`hang`, `drop`, `truncateTo`. Для заданного порядка ответов установите `deferred = true`
|
||||||
|
и вызовите `releaseResponse(url)` после обработки другого ответа клиентом.
|
||||||
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
|
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
|
||||||
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
|
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
|
||||||
`Fixtures::readTemplate()`.
|
`Fixtures::readTemplate()`.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "launcherversion.h"
|
||||||
#include "minecraftversion.h"
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "launcherpaths.h"
|
#include "launcherpaths.h"
|
||||||
|
#include "launcherversion.h"
|
||||||
#include "localization.h"
|
#include "localization.h"
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
|||||||
{
|
{
|
||||||
int outstanding = 2;
|
int outstanding = 2;
|
||||||
QHash<QString, QList<LoaderVersionEntry>> merged;
|
QHash<QString, QList<LoaderVersionEntry>> merged;
|
||||||
|
QHash<QString, QList<LoaderVersionEntry>> legacy;
|
||||||
QHash<QString, QString> recommended;
|
QHash<QString, QString> recommended;
|
||||||
bool primaryOk = false;
|
bool primaryOk = false;
|
||||||
QString warning;
|
QString warning;
|
||||||
@@ -258,6 +259,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (auto it = state->legacy.cbegin(); it != state->legacy.cend(); ++it)
|
||||||
|
state->merged[it.key()] += it.value();
|
||||||
for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
|
for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
|
||||||
const QString promoted = state->recommended.value(it.key());
|
const QString promoted = state->recommended.value(it.key());
|
||||||
if (promoted.isEmpty())
|
if (promoted.isEmpty())
|
||||||
@@ -317,10 +320,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
|||||||
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
|
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
|
||||||
legacy->deleteLater();
|
legacy->deleteLater();
|
||||||
if (legacy->error() == QNetworkReply::NoError) {
|
if (legacy->error() == QNetworkReply::NoError) {
|
||||||
const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
state->legacy = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
||||||
/*legacyNeoForge=*/true);
|
/*legacyNeoForge=*/true);
|
||||||
for (auto it = extra.constBegin(); it != extra.constEnd(); ++it)
|
|
||||||
state->merged[it.key()] += it.value();
|
|
||||||
}
|
}
|
||||||
settle();
|
settle();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,10 +58,8 @@ qt_add_qml_module(bilauncher_testlib
|
|||||||
RESOURCES ${_module_res}
|
RESOURCES ${_module_res}
|
||||||
NO_LINT
|
NO_LINT
|
||||||
)
|
)
|
||||||
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
|
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}" "${LAUNCHER_GENERATED_DIR}")
|
||||||
target_compile_definitions(bilauncher_testlib PUBLIC
|
add_dependencies(bilauncher_testlib launcher_version)
|
||||||
LAUNCHER_VERSION="${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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -201,12 +201,32 @@ void FakeInternet::unroute(const QString &pattern)
|
|||||||
|
|
||||||
void FakeInternet::reset()
|
void FakeInternet::reset()
|
||||||
{
|
{
|
||||||
|
m_deferred.clear();
|
||||||
|
m_diagnostics.clear();
|
||||||
m_routes.clear();
|
m_routes.clear();
|
||||||
m_requests.clear();
|
m_requests.clear();
|
||||||
m_unmatched.clear();
|
m_unmatched.clear();
|
||||||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool FakeInternet::releaseResponse(const QString &url)
|
||||||
|
{
|
||||||
|
for (qsizetype i = 0; i < m_deferred.size(); ++i) {
|
||||||
|
if (m_deferred.at(i).url == url) {
|
||||||
|
const auto pending = m_deferred.takeAt(i);
|
||||||
|
respond(pending.socket, pending.response, pending.keepAlive);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FakeInternet::diagnostics() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("TLS backend: %1\nRequests: %2\n%3")
|
||||||
|
.arg(QSslSocket::activeBackend(), requestedUrls().join(u' '), m_diagnostics.join(u'\n'));
|
||||||
|
}
|
||||||
|
|
||||||
QList<FakeRequest> FakeInternet::requests() const
|
QList<FakeRequest> FakeInternet::requests() const
|
||||||
{
|
{
|
||||||
return m_requests;
|
return m_requests;
|
||||||
@@ -366,9 +386,13 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bool keepAlive = request.header("connection").toLower() != "close";
|
const bool keepAlive = request.header("connection").toLower() != "close";
|
||||||
QPointer<QTcpSocket> socket(connection->socket);
|
QPointer<QSslSocket> socket(connection->socket);
|
||||||
if (response.hang)
|
if (response.hang)
|
||||||
return;
|
return;
|
||||||
|
if (response.deferred) {
|
||||||
|
m_deferred.append({url, socket, response, keepAlive});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.delayMs > 0) {
|
if (response.delayMs > 0) {
|
||||||
QTimer::singleShot(response.delayMs, this,
|
QTimer::singleShot(response.delayMs, this,
|
||||||
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
||||||
@@ -378,7 +402,7 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
|||||||
emit requestServed(url);
|
emit requestServed(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive)
|
void FakeInternet::respond(QPointer<QSslSocket> socket, const FakeResponse &response, bool keepAlive)
|
||||||
{
|
{
|
||||||
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||||
return;
|
return;
|
||||||
@@ -397,20 +421,28 @@ void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &resp
|
|||||||
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
||||||
head += "\r\n";
|
head += "\r\n";
|
||||||
|
|
||||||
socket->write(head);
|
|
||||||
if (response.truncateTo >= 0) {
|
if (response.truncateTo >= 0) {
|
||||||
socket->write(response.body.left(response.truncateTo));
|
m_diagnostics << QStringLiteral("truncate: declared=%1, sent=%2")
|
||||||
socket->flush();
|
.arg(response.body.size()).arg(response.body.left(response.truncateTo).size());
|
||||||
socket->disconnectFromHost();
|
// Сначала отправляем TLS-записи с неполным телом, затем рвём TCP.
|
||||||
// Schannel (Windows) на disconnectFromHost шлёт только TLS close_notify и держит
|
// disconnectFromHost() выполняет штатное завершение TLS, а здесь
|
||||||
// TCP открытым, ожидая ответного close_notify, — клиент тогда висит до таймаута.
|
// моделируется обрыв. Готовность определяется буферами, не задержкой.
|
||||||
// Обрыв на то и обрыв: если сокет не закрылся сам, рвём соединение.
|
connect(socket, &QSslSocket::encryptedBytesWritten, socket, [this, socket](qint64) {
|
||||||
QTimer::singleShot(200, socket, [socket] {
|
QTimer::singleShot(0, socket, [this, socket] {
|
||||||
if (socket && socket->state() != QAbstractSocket::UnconnectedState)
|
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||||
|
return;
|
||||||
|
if (socket->bytesToWrite() || socket->encryptedBytesToWrite())
|
||||||
|
return;
|
||||||
|
m_diagnostics << QStringLiteral("truncate: TLS buffers drained, aborting TCP");
|
||||||
socket->abort();
|
socket->abort();
|
||||||
|
m_diagnostics << QStringLiteral("truncate: state after abort=%1").arg(socket->state());
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
socket->write(head);
|
||||||
|
socket->write(response.body.left(response.truncateTo));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
socket->write(head);
|
||||||
socket->write(response.body);
|
socket->write(response.body);
|
||||||
if (close)
|
if (close)
|
||||||
socket->disconnectFromHost();
|
socket->disconnectFromHost();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ struct FakeResponse
|
|||||||
QList<QPair<QByteArray, QByteArray>> headers;
|
QList<QPair<QByteArray, QByteArray>> headers;
|
||||||
int delayMs = 0; // ответить с задержкой
|
int delayMs = 0; // ответить с задержкой
|
||||||
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
||||||
|
bool deferred = false; // ждать явного releaseResponse(), без таймера
|
||||||
bool drop = false; // оборвать соединение без ответа
|
bool drop = false; // оборвать соединение без ответа
|
||||||
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ public:
|
|||||||
|
|
||||||
// Сбросить маршруты и журнал.
|
// Сбросить маршруты и журнал.
|
||||||
void reset();
|
void reset();
|
||||||
|
bool releaseResponse(const QString &url);
|
||||||
|
QString diagnostics() const;
|
||||||
|
|
||||||
QList<FakeRequest> requests() const;
|
QList<FakeRequest> requests() const;
|
||||||
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
||||||
@@ -122,7 +125,7 @@ private:
|
|||||||
void onReadyRead(Connection *connection);
|
void onReadyRead(Connection *connection);
|
||||||
void processBuffer(Connection *connection);
|
void processBuffer(Connection *connection);
|
||||||
void dispatch(Connection *connection, FakeRequest request);
|
void dispatch(Connection *connection, FakeRequest request);
|
||||||
void respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive);
|
void respond(QPointer<QSslSocket> socket, const FakeResponse &response, bool keepAlive);
|
||||||
void startTls(Connection *connection);
|
void startTls(Connection *connection);
|
||||||
const Route *match(const FakeRequest &request) const;
|
const Route *match(const FakeRequest &request) const;
|
||||||
|
|
||||||
@@ -130,6 +133,15 @@ private:
|
|||||||
FakeResponse m_fallback;
|
FakeResponse m_fallback;
|
||||||
QList<FakeRequest> m_requests;
|
QList<FakeRequest> m_requests;
|
||||||
QStringList m_unmatched;
|
QStringList m_unmatched;
|
||||||
|
struct DeferredResponse
|
||||||
|
{
|
||||||
|
QString url;
|
||||||
|
QPointer<QSslSocket> socket;
|
||||||
|
FakeResponse response;
|
||||||
|
bool keepAlive;
|
||||||
|
};
|
||||||
|
QList<DeferredResponse> m_deferred;
|
||||||
|
QStringList m_diagnostics;
|
||||||
QByteArray m_certificate;
|
QByteArray m_certificate;
|
||||||
QByteArray m_key;
|
QByteArray m_key;
|
||||||
bool m_started = false;
|
bool m_started = false;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#include <QtTest/QTest>
|
#include <QtTest/QTest>
|
||||||
|
|
||||||
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
|
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
|
||||||
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на Linux) недоступность —
|
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на всех ОС) недоступность —
|
||||||
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
||||||
#define BILAUNCHER_START_FAKE_INTERNET(net) \
|
#define BILAUNCHER_START_FAKE_INTERNET(net) \
|
||||||
do { \
|
do { \
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "testenv.h"
|
#include "testenv.h"
|
||||||
#include "fakeinternet.h"
|
#include "fakeinternet.h"
|
||||||
|
#include "nettest.h"
|
||||||
|
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
@@ -30,9 +31,7 @@ class TestFakeInternet : public QObject
|
|||||||
private slots:
|
private slots:
|
||||||
void initTestCase()
|
void initTestCase()
|
||||||
{
|
{
|
||||||
QString error;
|
BILAUNCHER_START_FAKE_INTERNET(m_net);
|
||||||
if (!m_net.start(&error))
|
|
||||||
QSKIP(qPrintable(QStringLiteral("FakeInternet недоступен: ") + error));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void init() { m_net.reset(); }
|
void init() { m_net.reset(); }
|
||||||
@@ -100,12 +99,6 @@ private slots:
|
|||||||
response.drop = true;
|
response.drop = true;
|
||||||
return response;
|
return response;
|
||||||
}());
|
}());
|
||||||
m_net.route(QStringLiteral("https://a.test/short"), [] {
|
|
||||||
FakeResponse response = FakeResponse::bytes(QByteArray(1000, 'x'));
|
|
||||||
response.truncateTo = 10;
|
|
||||||
return response;
|
|
||||||
}());
|
|
||||||
|
|
||||||
QNetworkAccessManager manager;
|
QNetworkAccessManager manager;
|
||||||
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
|
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
|
||||||
QVERIFY(waitFinished(r500.data()));
|
QVERIFY(waitFinished(r500.data()));
|
||||||
@@ -114,10 +107,31 @@ private slots:
|
|||||||
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
|
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
|
||||||
QVERIFY(waitFinished(drop.data()));
|
QVERIFY(waitFinished(drop.data()));
|
||||||
QVERIFY(drop->error() != QNetworkReply::NoError);
|
QVERIFY(drop->error() != QNetworkReply::NoError);
|
||||||
|
}
|
||||||
|
|
||||||
|
void truncatedResponse_data()
|
||||||
|
{
|
||||||
|
QTest::addColumn<int>("bytes");
|
||||||
|
QTest::newRow("empty") << 0;
|
||||||
|
QTest::newRow("short") << 10;
|
||||||
|
QTest::newRow("multiple-tls-records") << 65536;
|
||||||
|
QTest::newRow("last-byte-missing") << 256 * 1024 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void truncatedResponse()
|
||||||
|
{
|
||||||
|
QFETCH(int, bytes);
|
||||||
|
const QByteArray body(256 * 1024, 'x');
|
||||||
|
FakeResponse cut = FakeResponse::bytes(body);
|
||||||
|
cut.truncateTo = bytes;
|
||||||
|
m_net.route(QStringLiteral("https://a.test/short"), cut);
|
||||||
|
QNetworkAccessManager manager;
|
||||||
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
|
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
|
||||||
QVERIFY(waitFinished(shortReply.data()));
|
QVERIFY2(waitFinished(shortReply.data()),
|
||||||
|
qPrintable(m_net.diagnostics() + "\nClient: " + shortReply->errorString()));
|
||||||
QVERIFY(shortReply->error() != QNetworkReply::NoError);
|
QVERIFY(shortReply->error() != QNetworkReply::NoError);
|
||||||
|
QCOMPARE(shortReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
|
||||||
|
QCOMPARE(shortReply->readAll(), body.left(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
void plainHttpThroughProxy()
|
void plainHttpThroughProxy()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
#include "modloaderversionservice.h"
|
#include "modloaderversionservice.h"
|
||||||
|
|
||||||
#include <QSignalSpy>
|
#include <QSignalSpy>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
||||||
@@ -55,6 +57,25 @@ class TestModLoaderVersionService : public QObject
|
|||||||
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
|
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool loadNeoForgeInOrder(ModLoaderVersionService &service, CallResult &result,
|
||||||
|
const QString &first, const QString &second)
|
||||||
|
{
|
||||||
|
QStringList completed;
|
||||||
|
QObject observer;
|
||||||
|
auto *network = service.findChild<QNetworkAccessManager *>();
|
||||||
|
if (!network)
|
||||||
|
qFatal("ModLoaderVersionService has no network manager");
|
||||||
|
connect(network, &QNetworkAccessManager::finished, &observer, [&](QNetworkReply *reply) {
|
||||||
|
completed << reply->url().toString();
|
||||||
|
});
|
||||||
|
service.ensureLoaded(ModLoader::NeoForge, "1.20.1", result.callback());
|
||||||
|
if (!QTest::qWaitFor([&] { return completed.contains(first) && m_net.hits(second) == 1; }, 15000))
|
||||||
|
return false;
|
||||||
|
if (!m_net.releaseResponse(second))
|
||||||
|
return false;
|
||||||
|
return QTest::qWaitFor([&] { return result.calls == 1; }, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||||
|
|
||||||
@@ -167,33 +188,30 @@ private slots:
|
|||||||
void neoForgeLegacyArtifactArrivesLast()
|
void neoForgeLegacyArtifactArrivesLast()
|
||||||
{
|
{
|
||||||
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
|
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
|
||||||
FakeResponse legacySlow = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
FakeResponse legacy = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
||||||
legacySlow.delayMs = 400;
|
legacy.deferred = true;
|
||||||
m_net.route(kNeoLegacyMeta, legacySlow);
|
m_net.route(kNeoLegacyMeta, legacy);
|
||||||
ModLoaderVersionService service;
|
ModLoaderVersionService service;
|
||||||
CallResult r;
|
CallResult r;
|
||||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
QVERIFY(loadNeoForgeInOrder(service, r, kNeoMeta, kNeoLegacyMeta));
|
||||||
QVERIFY(r.ok);
|
QVERIFY(r.ok);
|
||||||
const QList<LoaderVersionEntry> legacy = service.versions(ModLoader::NeoForge, "1.20.1");
|
const QList<LoaderVersionEntry> versions = service.versions(ModLoader::NeoForge, "1.20.1");
|
||||||
QCOMPARE(loaderVersions(legacy), QStringList({"47.1.106", "47.1.79"}));
|
QCOMPARE(loaderVersions(versions), QStringList({"47.1.106", "47.1.79"}));
|
||||||
QCOMPARE(legacy.at(0).installerUrl,
|
QCOMPARE(versions.at(0).installerUrl,
|
||||||
QUrl("https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"));
|
QUrl("https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void neoForgeLegacyArtifactArrivesFirst()
|
void neoForgeLegacyArtifactArrivesFirst()
|
||||||
{
|
{
|
||||||
// Тот же каталог, но маленький legacy-ответ приходит раньше основного.
|
// Основной ответ выпускается только после обработки legacy клиентом.
|
||||||
FakeResponse primarySlow = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
FakeResponse primary = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
||||||
primarySlow.delayMs = 400;
|
primary.deferred = true;
|
||||||
m_net.route(kNeoMeta, primarySlow);
|
m_net.route(kNeoMeta, primary);
|
||||||
ModLoaderVersionService service;
|
ModLoaderVersionService service;
|
||||||
CallResult r;
|
CallResult r;
|
||||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
QVERIFY(loadNeoForgeInOrder(service, r, kNeoLegacyMeta, kNeoMeta));
|
||||||
QVERIFY(r.ok);
|
QVERIFY(r.ok);
|
||||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
|
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
|
||||||
// В startRefresh() основной ответ делает state->merged = parsed и затирает
|
|
||||||
// уже слитые записи legacy — версии NeoForge для 1.20.1 пропадают.
|
|
||||||
QEXPECT_FAIL("", "гонка: основной maven-metadata NeoForge затирает уже пришедший legacy", Continue);
|
|
||||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
|
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ private slots:
|
|||||||
m_net.route(kPack, cut);
|
m_net.route(kPack, cut);
|
||||||
Run run(downloader);
|
Run run(downloader);
|
||||||
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
|
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
|
||||||
QVERIFY(run.waitEnd());
|
QVERIFY2(run.waitEnd(), qPrintable(m_net.diagnostics()));
|
||||||
QCOMPARE(run.failed.count(), 1);
|
QCOMPARE(run.failed.count(), 1);
|
||||||
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
|
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ public slots:
|
|||||||
seedData();
|
seedData();
|
||||||
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
|
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
|
||||||
QString error;
|
QString error;
|
||||||
if (m_net.start(&error))
|
if (!m_net.start(&error))
|
||||||
|
qFatal("FakeInternet: %s", qPrintable(error));
|
||||||
m_net.setFallback(FakeResponse::error(503));
|
m_net.setFallback(FakeResponse::error(503));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ private slots:
|
|||||||
void initTestCase()
|
void initTestCase()
|
||||||
{
|
{
|
||||||
QString error;
|
QString error;
|
||||||
if (m_net.start(&error))
|
QVERIFY2(m_net.start(&error), qPrintable(error));
|
||||||
m_net.setFallback(FakeResponse::error(503));
|
m_net.setFallback(FakeResponse::error(503));
|
||||||
const QString game = TestEnv::freshDir(QStringLiteral("game"));
|
const QString game = TestEnv::freshDir(QStringLiteral("game"));
|
||||||
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
|
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ def check_changelog():
|
|||||||
return False
|
return False
|
||||||
version = match.group(1)
|
version = match.group(1)
|
||||||
changelog = open(os.path.join(ROOT, "CHANGELOG.md"), encoding="utf-8").read()
|
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 есть")
|
print(f"версия {version}: раздел в CHANGELOG.md есть")
|
||||||
else:
|
else:
|
||||||
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
|
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
|
||||||
|
|||||||
Reference in new issue
Block a user