seasonal builds
This commit is contained in:
@@ -50,6 +50,10 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
buildarchiveworker.cpp
|
||||
buildswitcher.h
|
||||
buildswitcher.cpp
|
||||
seasonalbuildservice.h
|
||||
seasonalbuildservice.cpp
|
||||
seasonalpackdownloader.h
|
||||
seasonalpackdownloader.cpp
|
||||
QML_FILES
|
||||
Main.qml
|
||||
DarkCombo.qml
|
||||
@@ -57,6 +61,7 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
JavaPickerDialog.qml
|
||||
LabelledField.qml
|
||||
BuildsDialog.qml
|
||||
SeasonalBuildsDialog.qml
|
||||
LoaderRow.qml
|
||||
ProgressPanel.qml
|
||||
RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg
|
||||
|
||||
@@ -32,6 +32,10 @@ Window {
|
||||
crashed || exitCode !== 0 ? "#cc3333" : "#555555")
|
||||
}
|
||||
onGameOutput: (line) => console.log(line)
|
||||
onSeasonalInstallFinished: (seasonalId, buildName) => {
|
||||
window.showToast(qsTr("Сборка «%1» установлена — можно запускать").arg(buildName),
|
||||
"#4b7a1f", 8000)
|
||||
}
|
||||
}
|
||||
|
||||
// Единая всплывающая плашка: сообщения о ходе запуска, ошибки, статус игры.
|
||||
@@ -451,6 +455,48 @@ Window {
|
||||
onClicked: settingsDialog.load()
|
||||
}
|
||||
|
||||
// ── Seasonal builds button ─────────────────────────────────────────────
|
||||
// Правый нижний угол — единственная свободная часть окна: панели хода работ
|
||||
// висят слева, а всё остальное собрано вокруг кнопки запуска.
|
||||
Button {
|
||||
id: seasonsButton
|
||||
width: 183
|
||||
height: 42
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 24
|
||||
anchors.bottomMargin: 24
|
||||
hoverEnabled: true
|
||||
|
||||
contentItem: Text {
|
||||
text: qsTr("Сезонные сборки")
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: seasonsButton.pressed ? "#2d2d2d" : "#232323"
|
||||
radius: 6
|
||||
border.color: "#91B315"
|
||||
border.width: seasonsButton.hovered ? 2 : 1
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Готовые сборки с сервера: моды, конфиги и Java одной кнопкой")
|
||||
|
||||
onClicked: seasonalDialog.openCatalog()
|
||||
}
|
||||
|
||||
SeasonalBuildsDialog {
|
||||
id: seasonalDialog
|
||||
backend: backend
|
||||
x: (window.width - width) / 2
|
||||
y: (window.height - height) / 2
|
||||
}
|
||||
|
||||
// ── Add Profile Dialog ─────────────────────────────────────────────────
|
||||
// Bug fix: header/footer must be Item (not Rectangle) with explicit
|
||||
// implicitHeight so Dialog correctly computes its own total height.
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Готовые сезонные сборки с сервера лаунчера: таблица со всем, что нужно
|
||||
// решить, ставить её или нет, и одна кнопка, которая делает всё остальное —
|
||||
// заводит сборку, ставит версию игры, модлоадер, Java и раскладывает файлы.
|
||||
//
|
||||
// Таблица собрана из Row фиксированных колонок, а не из TableView: в проекте
|
||||
// нет ни одной модели QAbstractItemModel, а строки приходят готовыми
|
||||
// QVariantMap — заводить ради семи колонок отдельную модель незачем.
|
||||
Dialog {
|
||||
id: seasons
|
||||
|
||||
required property var backend
|
||||
|
||||
// Выбранная строка. Хранится по id, а не по индексу: список обновляется
|
||||
// под руками, и индекс после обновления указывал бы на другую сборку.
|
||||
property string selectedId: ""
|
||||
|
||||
// Ширины колонок в одном месте: их повторяют и шапка, и делегат.
|
||||
readonly property var columns: [
|
||||
{ key: "name", title: qsTr("Название"), width: 250, align: Text.AlignLeft },
|
||||
{ key: "minecraftVersion", title: qsTr("Версия"), width: 90, align: Text.AlignLeft },
|
||||
{ key: "loaderLabel", title: qsTr("Загрузчик"), width: 150, align: Text.AlignLeft },
|
||||
{ key: "modCount", title: qsTr("Модов"), width: 70, align: Text.AlignRight },
|
||||
{ key: "seasonStart", title: qsTr("Начало"), width: 110, align: Text.AlignLeft },
|
||||
{ key: "seasonEnd", title: qsTr("Конец"), width: 110, align: Text.AlignLeft },
|
||||
{ key: "status", title: qsTr("Статус"), width: 140, align: Text.AlignLeft }
|
||||
]
|
||||
|
||||
modal: true
|
||||
padding: 0
|
||||
width: 960
|
||||
height: 560
|
||||
// Пока идёт установка, окно закрывается только кнопкой: случайный щелчок
|
||||
// мимо не должен спрятать единственную видимую отмену.
|
||||
closePolicy: seasons.backend.seasonalInstalling
|
||||
? Popup.NoAutoClose
|
||||
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
// Строки приходят из C++ уже отсортированными и сведёнными с локальными
|
||||
// записями — здесь только показ.
|
||||
readonly property var entries: seasons.backend.seasonalCatalog
|
||||
|
||||
readonly property var selectedEntry: {
|
||||
const list = seasons.entries
|
||||
for (var i = 0; i < list.length; ++i) {
|
||||
if (list[i].id === seasons.selectedId)
|
||||
return list[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function openCatalog() {
|
||||
seasons.open()
|
||||
}
|
||||
|
||||
function formatMb(bytes) {
|
||||
if (!bytes || bytes <= 0)
|
||||
return ""
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
}
|
||||
|
||||
function cellText(entry, key) {
|
||||
if (key === "loaderLabel") {
|
||||
return entry.loaderVersion === ""
|
||||
? entry.loaderTitle
|
||||
: entry.loaderTitle + " " + entry.loaderVersion
|
||||
}
|
||||
return String(entry[key])
|
||||
}
|
||||
|
||||
// Свежий кэш отвечает без сети, поэтому дёргаем при каждом открытии.
|
||||
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("Сезонные сборки")
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
|
||||
// ── Шапка таблицы ───────────────────────────────────────────────
|
||||
Row {
|
||||
id: tableHeader
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 18
|
||||
height: 30
|
||||
|
||||
Repeater {
|
||||
model: seasons.columns
|
||||
|
||||
delegate: Text {
|
||||
required property var modelData
|
||||
|
||||
width: modelData.width
|
||||
height: tableHeader.height
|
||||
text: modelData.title
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: modelData.align
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
rightPadding: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: headerLine
|
||||
anchors.top: tableHeader.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// ── Сообщение вместо таблицы ────────────────────────────────────
|
||||
// Пустой список и ошибка выглядят одинаково пустыми, поэтому причина
|
||||
// показывается прямо на месте строк.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 80
|
||||
visible: seasons.entries.length === 0
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
color: seasons.backend.seasonalCatalogError === "" ? "#888888" : "#cc6666"
|
||||
font.pixelSize: 13
|
||||
text: seasons.backend.seasonalCatalogLoading
|
||||
? qsTr("Загрузка списка сборок…")
|
||||
: (seasons.backend.seasonalCatalogError === ""
|
||||
? qsTr("Сборок пока нет")
|
||||
: seasons.backend.seasonalCatalogError)
|
||||
}
|
||||
|
||||
// ── Строки ──────────────────────────────────────────────────────
|
||||
ListView {
|
||||
id: buildList
|
||||
anchors.top: headerLine.bottom
|
||||
anchors.topMargin: 4
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
clip: true
|
||||
spacing: 1
|
||||
model: seasons.entries
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: buildRow
|
||||
required property var modelData
|
||||
|
||||
width: buildList.width
|
||||
height: 36
|
||||
radius: 4
|
||||
color: seasons.selectedId === buildRow.modelData.id ? "#91B315"
|
||||
: rowArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||
|
||||
readonly property bool highlighted: seasons.selectedId === buildRow.modelData.id
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Repeater {
|
||||
model: seasons.columns
|
||||
|
||||
delegate: Text {
|
||||
required property var modelData
|
||||
|
||||
width: modelData.width
|
||||
text: seasons.cellText(buildRow.modelData, modelData.key)
|
||||
// Подсвеченная строка залита зелёным — тёмный текст
|
||||
// на ней читается, светлый нет.
|
||||
color: buildRow.highlighted
|
||||
? "#1e1e1e"
|
||||
: (modelData.key === "status"
|
||||
&& buildRow.modelData.updateAvailable
|
||||
? "#91B315" : "#ffffff")
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: modelData.align
|
||||
rightPadding: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: seasons.selectedId = buildRow.modelData.id
|
||||
onDoubleClicked: {
|
||||
seasons.selectedId = buildRow.modelData.id
|
||||
seasons.installSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installSelected() {
|
||||
const entry = seasons.selectedEntry
|
||||
if (!entry || seasons.backend.busy)
|
||||
return
|
||||
seasons.backend.installSeasonalBuild(entry.id)
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 76
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// Описание и размер выбранной сборки: они длинные и в таблицу не
|
||||
// помещаются, а решение принимается по ним.
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 18
|
||||
anchors.right: footerButtons.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 12
|
||||
text: seasons.selectedEntry ? seasons.selectedEntry.description : ""
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
color: "#888888"
|
||||
font.pixelSize: 11
|
||||
text: {
|
||||
if (!seasons.selectedEntry)
|
||||
return qsTr("Выберите сборку в списке")
|
||||
const entry = seasons.selectedEntry
|
||||
var line = qsTr("Ревизия %1").arg(entry.revision)
|
||||
const size = seasons.formatMb(entry.sizeBytes)
|
||||
if (size !== "")
|
||||
line += qsTr(" · %1 МБ").arg(size)
|
||||
if (entry.serverUrl !== "")
|
||||
line += " · " + entry.serverUrl
|
||||
if (entry.updateAvailable)
|
||||
line += qsTr(" · установлена ревизия %1").arg(entry.installedRevision)
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: footerButtons
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
id: refreshButton
|
||||
width: 150
|
||||
height: 36
|
||||
enabled: !seasons.backend.seasonalCatalogLoading
|
||||
contentItem: Text {
|
||||
text: seasons.backend.seasonalCatalogLoading ? qsTr("Обновление…")
|
||||
: qsTr("Обновить список")
|
||||
color: refreshButton.enabled ? "#ffffff" : "#888888"
|
||||
font.pixelSize: 13
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: refreshButton.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: seasons.backend.refreshSeasonalCatalog(true)
|
||||
}
|
||||
|
||||
Button {
|
||||
id: installButton
|
||||
width: 150
|
||||
height: 36
|
||||
// Установка занимает и панель загрузки, и .minecraft целиком:
|
||||
// пока лаунчер занят чем угодно, вторую начинать нельзя.
|
||||
enabled: seasons.selectedEntry !== null && !seasons.backend.busy
|
||||
contentItem: Text {
|
||||
text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable
|
||||
? qsTr("Обновить")
|
||||
: qsTr("Установить")
|
||||
color: installButton.enabled ? "#ffffff" : "#888888"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: !installButton.enabled ? "#2a2a2a"
|
||||
: installButton.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: seasons.installSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QtCore/private/qzipreader_p.h>
|
||||
#include <QtCore/private/qzipwriter_p.h>
|
||||
|
||||
@@ -25,6 +27,40 @@ bool isSafeRelativePath(const QString &path)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Последняя проверка перед записью: куда бы ни указывало имя из архива после
|
||||
// всех преобразований, файл обязан оказаться внутри папки игры. Правило имён
|
||||
// выше это уже гарантирует, но проверка по готовому пути не зависит от того,
|
||||
// как именно QZipReader разбирает записи.
|
||||
bool isInside(const QDir &root, const QString &absolutePath)
|
||||
{
|
||||
const QString base = QDir::cleanPath(root.absolutePath()) + u'/';
|
||||
return QDir::cleanPath(absolutePath).startsWith(base);
|
||||
}
|
||||
|
||||
// Верхний сегмент относительного пути: по нему проверяется, не лезет ли пак в
|
||||
// общие каталоги лаунчера.
|
||||
QString topSegment(const QString &path)
|
||||
{
|
||||
const int slash = path.indexOf(u'/');
|
||||
return slash < 0 ? path : path.left(slash);
|
||||
}
|
||||
|
||||
// После удаления файлов остаются пустые папки: пустой mods/ вместо удалённого
|
||||
// набора модов выглядит как незавершённая установка.
|
||||
void pruneEmptyDirs(const QDir &root, QStringList dirs)
|
||||
{
|
||||
// Сначала самые глубокие: удаление вложенной может опустошить родителя.
|
||||
std::sort(dirs.begin(), dirs.end(),
|
||||
[](const QString &a, const QString &b) { return a.size() > b.size(); });
|
||||
for (const QString &relative : std::as_const(dirs)) {
|
||||
if (relative.isEmpty() || relative == QLatin1String("."))
|
||||
continue;
|
||||
QDir dir(root.absoluteFilePath(relative));
|
||||
if (dir.exists() && dir.isEmpty())
|
||||
root.rmdir(relative);
|
||||
}
|
||||
}
|
||||
|
||||
// Файлы, которые попадут в архив, — относительными путями от gameDir.
|
||||
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
|
||||
{
|
||||
@@ -218,3 +254,119 @@ void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
|
||||
|
||||
emit finished(true, {});
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::applyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &removeRelative,
|
||||
const QStringList &forbiddenTop)
|
||||
{
|
||||
QZipReader reader(zipPath);
|
||||
if (!reader.isReadable()) {
|
||||
emit finished(false,
|
||||
tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Весь архив проверяется до первой записи на диск: наполовину раскатанный
|
||||
// пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии.
|
||||
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||
QStringList applied;
|
||||
applied.reserve(entries.size());
|
||||
for (const QZipReader::FileInfo &entry : entries) {
|
||||
if (!entry.isFile)
|
||||
continue;
|
||||
if (!isSafeRelativePath(entry.filePath)) {
|
||||
emit finished(false,
|
||||
tr("Архив сборки повреждён: недопустимый путь «%1»").arg(entry.filePath));
|
||||
return;
|
||||
}
|
||||
if (forbiddenTop.contains(topSegment(entry.filePath))) {
|
||||
emit finished(false,
|
||||
tr("Архив сборки пытается изменить общие файлы лаунчера: «%1»")
|
||||
.arg(entry.filePath));
|
||||
return;
|
||||
}
|
||||
applied << entry.filePath;
|
||||
}
|
||||
|
||||
const QDir root(gameDir);
|
||||
if (!QDir().mkpath(gameDir)) {
|
||||
emit finished(false, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir)));
|
||||
return;
|
||||
}
|
||||
|
||||
const int total = removeRelative.size() + applied.size();
|
||||
emit progress(0, total, {});
|
||||
int done = 0;
|
||||
|
||||
// ── Убираем то, чего в новой ревизии больше нет ──────────────────────
|
||||
QStringList touchedDirs;
|
||||
for (const QString &relative : removeRelative) {
|
||||
if (canceled()) {
|
||||
emit finished(false, tr("Установка сборки отменена"));
|
||||
return;
|
||||
}
|
||||
// Список пришёл из нашего же файла состояния, но он лежит на диске
|
||||
// рядом с игрой и мог быть отредактирован — правила те же, что у архива.
|
||||
if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) {
|
||||
emit progress(++done, total, relative);
|
||||
continue;
|
||||
}
|
||||
// Файл мог быть удалён игроком или прийти заново в новой ревизии —
|
||||
// и то и другое нормально.
|
||||
const QString target = root.absoluteFilePath(relative);
|
||||
if (!isInside(root, target)) {
|
||||
emit progress(++done, total, relative);
|
||||
continue;
|
||||
}
|
||||
if (QFileInfo::exists(target) && !QFile::remove(target)) {
|
||||
emit finished(false,
|
||||
tr("Не удалось удалить %1").arg(QDir::toNativeSeparators(target)));
|
||||
return;
|
||||
}
|
||||
const QString parent = QFileInfo(relative).path();
|
||||
if (!touchedDirs.contains(parent))
|
||||
touchedDirs << parent;
|
||||
emit progress(++done, total, relative);
|
||||
}
|
||||
pruneEmptyDirs(root, touchedDirs);
|
||||
|
||||
// ── Раскладываем новую ревизию поверх ────────────────────────────────
|
||||
for (const QString &relative : std::as_const(applied)) {
|
||||
if (canceled()) {
|
||||
emit finished(false, tr("Установка сборки отменена"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString target = root.absoluteFilePath(relative);
|
||||
if (!isInside(root, target)) {
|
||||
emit finished(false,
|
||||
tr("Архив сборки повреждён: путь «%1» ведёт за пределы папки игры")
|
||||
.arg(relative));
|
||||
return;
|
||||
}
|
||||
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||
emit finished(false,
|
||||
tr("Не удалось создать %1")
|
||||
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(target);
|
||||
const QByteArray data = reader.fileData(relative);
|
||||
// QIODevice::Truncate обязателен: поверх более длинного старого файла
|
||||
// иначе остался бы его хвост.
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||
|| file.write(data) != data.size()) {
|
||||
emit finished(false,
|
||||
tr("Не удалось записать %1: %2")
|
||||
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
emit progress(++done, total, relative);
|
||||
}
|
||||
|
||||
emit packEntries(applied);
|
||||
emit finished(true, {});
|
||||
}
|
||||
|
||||
@@ -34,9 +34,25 @@ public slots:
|
||||
// Распаковывает архив в gameDir.
|
||||
void restore(const QString &zipPath, const QString &gameDir);
|
||||
|
||||
// Докатывает пак сезонной сборки поверх уже разложенного содержимого:
|
||||
// сначала удаляет removeRelative (файлы, ушедшие из сборки в новой
|
||||
// ревизии), затем распаковывает архив с перезаписью. Того, чего нет ни в
|
||||
// списке, ни в архиве, операция не касается — миры и скриншоты остаются.
|
||||
//
|
||||
// forbiddenTop — элементы верхнего уровня, которые паку трогать нельзя
|
||||
// (общие каталоги лаунчера). Проверяется здесь, а не только у издателя:
|
||||
// архив приезжает из сети.
|
||||
void applyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &removeRelative,
|
||||
const QStringList &forbiddenTop);
|
||||
|
||||
signals:
|
||||
void progress(int done, int total, const QString ¤tPath);
|
||||
void finished(bool ok, const QString &error);
|
||||
// Что именно принёс пак — относительными путями. Пригодится следующему
|
||||
// обновлению, чтобы вычислить, какие файлы из сборки ушли.
|
||||
void packEntries(const QStringList &entries);
|
||||
|
||||
private:
|
||||
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
|
||||
|
||||
+188
-6
@@ -8,6 +8,7 @@
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
@@ -18,6 +19,7 @@ namespace {
|
||||
|
||||
const QString kIndexFile = QStringLiteral("index.json");
|
||||
const QString kStateSwitching = QStringLiteral("switching");
|
||||
const QString kStateApplying = QStringLiteral("applying");
|
||||
const QString kStateIdle = QStringLiteral("idle");
|
||||
|
||||
// Запас на архив: точный размер заранее неизвестен, но упасть на середине
|
||||
@@ -89,6 +91,8 @@ BuildSwitcher::BuildSwitcher(QObject *parent)
|
||||
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
|
||||
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
|
||||
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
|
||||
connect(m_worker, &BuildArchiveWorker::packEntries, this,
|
||||
[this](const QStringList &entries) { m_lastPackEntries = entries; });
|
||||
m_thread->start();
|
||||
|
||||
sweepTempArchives();
|
||||
@@ -110,7 +114,7 @@ double BuildSwitcher::fraction() const
|
||||
|
||||
QString BuildSwitcher::buildDir(int buildId) const
|
||||
{
|
||||
return LauncherPaths::buildStorageDir() + u'/' + QString::number(buildId);
|
||||
return LauncherPaths::buildDir(buildId);
|
||||
}
|
||||
|
||||
// На сборку — ровно один архив: имя с датой меняется от переключения к
|
||||
@@ -118,8 +122,14 @@ QString BuildSwitcher::buildDir(int buildId) const
|
||||
QString BuildSwitcher::archivePathOf(int buildId) const
|
||||
{
|
||||
const QDir dir(buildDir(buildId));
|
||||
// Рядом лежат служебные .tmp-*.zip и .pack-*.zip. На Unix они скрытые и в
|
||||
// выборку не попадают, на Windows — попадают, поэтому отсекаем по имени.
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
|
||||
return zips.isEmpty() ? QString() : dir.absoluteFilePath(zips.first());
|
||||
for (const QString &name : zips) {
|
||||
if (!name.startsWith(u'.'))
|
||||
return dir.absoluteFilePath(name);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void BuildSwitcher::setStage(const QString &stage)
|
||||
@@ -217,6 +227,15 @@ void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDi
|
||||
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestApplyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &remove)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "applyPack", Qt::QueuedConnection,
|
||||
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir),
|
||||
Q_ARG(QStringList, remove), Q_ARG(QStringList, sharedTopLevel()));
|
||||
}
|
||||
|
||||
void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath)
|
||||
{
|
||||
m_done = done;
|
||||
@@ -254,6 +273,17 @@ void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
case Applying:
|
||||
if (!ok) {
|
||||
// Архив проверяется целиком до первой записи, поэтому нулевой
|
||||
// счётчик означает, что в папке игры ничего не тронуто. Иначе
|
||||
// часть файлов уже заменена: отметка в index.json остаётся, и
|
||||
// следующий запуск доиграет раскатку из сохранённого пака.
|
||||
fail(error, /*gameDirIntact=*/m_done == 0);
|
||||
return;
|
||||
}
|
||||
finishApplying();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +308,9 @@ void BuildSwitcher::commitArchive()
|
||||
const QDir dir(buildDir(m_fromId));
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
|
||||
for (const QString &name : zips) {
|
||||
if (dir.absoluteFilePath(name) != target)
|
||||
// Служебные файлы под удаление не попадают: в .pack-*.zip может лежать
|
||||
// пак недоигранной раскатки, без которого её нечем довести до конца.
|
||||
if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target)
|
||||
QFile::remove(dir.absoluteFilePath(name));
|
||||
}
|
||||
|
||||
@@ -323,6 +355,98 @@ void BuildSwitcher::beginRestoring()
|
||||
requestRestore(archive, m_gameDir);
|
||||
}
|
||||
|
||||
// ── Раскатка пака сезонной сборки ──────────────────────────────────────────
|
||||
|
||||
void BuildSwitcher::applyPack(int buildId,
|
||||
const QString &buildName,
|
||||
const QString &packZipPath,
|
||||
const QStringList &removeRelative,
|
||||
const QJsonObject ¬e,
|
||||
const QString &gameDir)
|
||||
{
|
||||
if (m_state != Idle) {
|
||||
emit failed(buildId, tr("Операция со сборками уже выполняется"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
QString rootError;
|
||||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||
emit failed(buildId, rootError, true);
|
||||
return;
|
||||
}
|
||||
if (!QFile::exists(packZipPath)) {
|
||||
emit failed(buildId,
|
||||
tr("Архив сборки не найден: %1")
|
||||
.arg(QDir::toNativeSeparators(packZipPath)),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_fromId = -1;
|
||||
m_toId = buildId;
|
||||
m_toName = buildName;
|
||||
m_gameDir = gameDir;
|
||||
m_packPath = packZipPath;
|
||||
m_packRemove = removeRelative;
|
||||
m_packNote = note;
|
||||
m_lastPackEntries.clear();
|
||||
m_lastPackNote = {};
|
||||
m_done = 0;
|
||||
m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
|
||||
// Отметка ставится до первого разрушающего действия — как и при смене
|
||||
// сборки. Пак лежит рядом и переживёт перезапуск вместе с ней.
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateApplying);
|
||||
index.insert(QStringLiteral("to"), buildId);
|
||||
index.insert(QStringLiteral("pack"), QFileInfo(packZipPath).fileName());
|
||||
index.insert(QStringLiteral("packName"), buildName);
|
||||
index.insert(QStringLiteral("packRemove"), QJsonArray::fromStringList(removeRelative));
|
||||
index.insert(QStringLiteral("packNote"), note);
|
||||
writeIndex(index);
|
||||
|
||||
m_state = Applying;
|
||||
beginApplying();
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginApplying()
|
||||
{
|
||||
setStage(tr("Установка сборки «%1»").arg(m_toName));
|
||||
requestApplyPack(m_packPath, m_gameDir, m_packRemove);
|
||||
}
|
||||
|
||||
void BuildSwitcher::finishApplying()
|
||||
{
|
||||
// Пак сделал своё дело: держать сотни мегабайт рядом с архивом сборки не
|
||||
// за чем, а его наличие означало бы недоигранную раскатку.
|
||||
QFile::remove(m_packPath);
|
||||
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateIdle);
|
||||
index.insert(QStringLiteral("active"), m_toId);
|
||||
index.remove(QStringLiteral("from"));
|
||||
index.remove(QStringLiteral("to"));
|
||||
index.remove(QStringLiteral("pack"));
|
||||
index.remove(QStringLiteral("packName"));
|
||||
index.remove(QStringLiteral("packRemove"));
|
||||
index.remove(QStringLiteral("packNote"));
|
||||
writeIndex(index);
|
||||
|
||||
m_lastPackNote = m_packNote;
|
||||
m_packPath.clear();
|
||||
m_packRemove.clear();
|
||||
m_packNote = {};
|
||||
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit finished(toId);
|
||||
}
|
||||
|
||||
void BuildSwitcher::finish()
|
||||
{
|
||||
QJsonObject index = readIndex();
|
||||
@@ -379,7 +503,14 @@ bool BuildSwitcher::forgetBuild(int buildId)
|
||||
QString BuildSwitcher::interruptedSwitchWarning() const
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
|
||||
const QString state = index.value(QStringLiteral("state")).toString();
|
||||
|
||||
if (state == kStateApplying) {
|
||||
const QString name = index.value(QStringLiteral("packName")).toString();
|
||||
return tr("Установка сборки%1 не завершилась — докатываем её файлы.")
|
||||
.arg(name.isEmpty() ? QString() : tr(" «%1»").arg(name));
|
||||
}
|
||||
if (state != kStateSwitching)
|
||||
return {};
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
@@ -395,7 +526,13 @@ QString BuildSwitcher::interruptedSwitchWarning() const
|
||||
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
|
||||
const QString state = index.value(QStringLiteral("state")).toString();
|
||||
|
||||
if (state == kStateApplying) {
|
||||
resumeApplying(index, gameDir);
|
||||
return;
|
||||
}
|
||||
if (state != kStateSwitching)
|
||||
return;
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
@@ -419,6 +556,50 @@ void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||||
beginClearing();
|
||||
}
|
||||
|
||||
void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &gameDir)
|
||||
{
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
if (toId < 0 || m_state != Idle)
|
||||
return;
|
||||
|
||||
const QString packName = index.value(QStringLiteral("pack")).toString();
|
||||
const QString packPath = buildDir(toId) + u'/' + packName;
|
||||
if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) {
|
||||
// Пак не сохранился — доигрывать нечем. Отметку снимаем, иначе
|
||||
// предупреждение о незавершённой установке будет всплывать вечно;
|
||||
// сборка при этом остаётся наполовину обновлённой, и лаунчер покажет
|
||||
// её как требующую обновления.
|
||||
QJsonObject cleaned = index;
|
||||
cleaned.insert(QStringLiteral("state"), kStateIdle);
|
||||
cleaned.remove(QStringLiteral("pack"));
|
||||
cleaned.remove(QStringLiteral("packName"));
|
||||
cleaned.remove(QStringLiteral("packRemove"));
|
||||
cleaned.remove(QStringLiteral("packNote"));
|
||||
cleaned.remove(QStringLiteral("to"));
|
||||
writeIndex(cleaned);
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList remove;
|
||||
const QJsonArray removeArray = index.value(QStringLiteral("packRemove")).toArray();
|
||||
for (const QJsonValue &value : removeArray)
|
||||
remove << value.toString();
|
||||
|
||||
m_fromId = -1;
|
||||
m_toId = toId;
|
||||
m_toName = index.value(QStringLiteral("packName")).toString();
|
||||
m_gameDir = gameDir;
|
||||
m_packPath = packPath;
|
||||
m_packRemove = remove;
|
||||
m_packNote = index.value(QStringLiteral("packNote")).toObject();
|
||||
m_lastPackEntries.clear();
|
||||
m_lastPackNote = {};
|
||||
m_done = m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
m_state = Applying;
|
||||
beginApplying();
|
||||
}
|
||||
|
||||
// ── Реестр архивов ─────────────────────────────────────────────────────────
|
||||
|
||||
QJsonObject BuildSwitcher::readIndex() const
|
||||
@@ -445,7 +626,8 @@ void BuildSwitcher::writeIndex(const QJsonObject &index) const
|
||||
}
|
||||
|
||||
// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может
|
||||
// занимать гигабайты.
|
||||
// занимать гигабайты. Пакеты .pack-*.zip не трогаем: по ним доигрывается
|
||||
// прерванная раскатка сезонной сборки, а лишний тут удаляет resumeApplying.
|
||||
void BuildSwitcher::sweepTempArchives() const
|
||||
{
|
||||
QDir root(LauncherPaths::buildStorageDir());
|
||||
|
||||
+35
-1
@@ -37,6 +37,26 @@ public:
|
||||
int toBuildId,
|
||||
const QString &toName,
|
||||
const QString &gameDir);
|
||||
|
||||
// Докатывает пак сезонной сборки поверх содержимого .minecraft. Вызывается
|
||||
// только для уже активной сборки: пак ложится на то, что сейчас разложено,
|
||||
// а не внутрь чужого архива.
|
||||
//
|
||||
// note — непрозрачные данные вызывающей стороны: они переживают перезапуск
|
||||
// вместе с отметкой о незавершённой операции и возвращаются через
|
||||
// lastPackNote(), когда раскатка доиграна. Сам переключатель в них не
|
||||
// заглядывает.
|
||||
void applyPack(int buildId,
|
||||
const QString &buildName,
|
||||
const QString &packZipPath,
|
||||
const QStringList &removeRelative,
|
||||
const QJsonObject ¬e,
|
||||
const QString &gameDir);
|
||||
|
||||
// Что принёс последний успешно раскатанный пак и с чем он пришёл.
|
||||
QStringList lastPackEntries() const { return m_lastPackEntries; }
|
||||
QJsonObject lastPackNote() const { return m_lastPackNote; }
|
||||
|
||||
void cancel();
|
||||
|
||||
// Сборку удалили: убираем её архив и запись о нём. Возвращает false, если
|
||||
@@ -58,17 +78,23 @@ signals:
|
||||
void failed(int toBuildId, const QString &message, bool gameDirIntact);
|
||||
|
||||
private:
|
||||
enum State { Idle, Archiving, Clearing, Restoring };
|
||||
enum State { Idle, Archiving, Clearing, Restoring, Applying };
|
||||
|
||||
// Сигналы к рабочему объекту в другом потоке.
|
||||
void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude);
|
||||
void requestClear(const QString &gameDir, const QStringList &keep);
|
||||
void requestRestore(const QString &zipPath, const QString &gameDir);
|
||||
void requestApplyPack(const QString &zipPath,
|
||||
const QString &gameDir,
|
||||
const QStringList &remove);
|
||||
|
||||
void onWorkerFinished(bool ok, const QString &error);
|
||||
void onWorkerProgress(int done, int total, const QString ¤tPath);
|
||||
|
||||
void commitArchive();
|
||||
void beginApplying();
|
||||
void finishApplying();
|
||||
void resumeApplying(const QJsonObject &index, const QString &gameDir);
|
||||
void beginClearing();
|
||||
void beginRestoring();
|
||||
void finish();
|
||||
@@ -97,4 +123,12 @@ private:
|
||||
QString m_toName;
|
||||
QString m_gameDir;
|
||||
QString m_tempZip;
|
||||
|
||||
// Раскатка пака: путь к архиву, список уходящих файлов и данные вызывающей
|
||||
// стороны, которые надо вернуть по завершении.
|
||||
QString m_packPath;
|
||||
QStringList m_packRemove;
|
||||
QJsonObject m_packNote;
|
||||
QStringList m_lastPackEntries;
|
||||
QJsonObject m_lastPackNote;
|
||||
};
|
||||
|
||||
@@ -132,6 +132,60 @@ QList<JavaRuntimeEntry> JavaRuntimeService::entries(JavaRuntimeKind kind) const
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<JavaRuntimeEntry> JavaRuntimeService::bestFor(int major) const
|
||||
{
|
||||
if (major <= 0)
|
||||
return std::nullopt;
|
||||
|
||||
// Каталог уже отфильтрован по текущей ОС и архитектуре, поэтому здесь
|
||||
// остаётся выбрать из заведомо запускаемого.
|
||||
const auto rank = [](JavaRuntimeKind kind) {
|
||||
switch (kind) {
|
||||
case JavaRuntimeKind::Jdk:
|
||||
return 0;
|
||||
case JavaRuntimeKind::Mojang:
|
||||
return 1;
|
||||
case JavaRuntimeKind::Jre:
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
};
|
||||
|
||||
const JavaRuntimeEntry *best = nullptr;
|
||||
for (const JavaRuntimeEntry &entry : m_entries) {
|
||||
if (!entry.isValid() || entry.major < major)
|
||||
continue;
|
||||
if (!best) {
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
// Чем ближе мажорная версия к нужной, тем меньше шансов на сюрприз:
|
||||
// игра на Java 8 под Java 21 просто не стартует.
|
||||
if (entry.major != best->major) {
|
||||
if (entry.major < best->major)
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
if (rank(entry.kind) != rank(best->kind)) {
|
||||
if (rank(entry.kind) < rank(best->kind))
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
if (entry.released.isValid() && best->released.isValid()
|
||||
&& entry.released != best->released) {
|
||||
if (entry.released > best->released)
|
||||
best = &entry;
|
||||
continue;
|
||||
}
|
||||
if (entry.version > best->version)
|
||||
best = &entry;
|
||||
}
|
||||
|
||||
if (!best)
|
||||
return std::nullopt;
|
||||
return *best;
|
||||
}
|
||||
|
||||
std::optional<JavaRuntimeEntry> JavaRuntimeService::find(const QString &id) const
|
||||
{
|
||||
const auto it = m_indexById.constFind(id);
|
||||
|
||||
@@ -43,6 +43,12 @@ public:
|
||||
QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const;
|
||||
std::optional<JavaRuntimeEntry> find(const QString &id) const;
|
||||
|
||||
// Что скачать, если для версии игры нужна Java major, а подходящей в
|
||||
// системе нет. Точное совпадение мажорной версии предпочтительнее более
|
||||
// новой, JDK — предпочтительнее JRE: установщики Forge и NeoForge иногда
|
||||
// требуют инструментов из полного комплекта. Пусто — предложить нечего.
|
||||
std::optional<JavaRuntimeEntry> bestFor(int major) const;
|
||||
|
||||
bool isRefreshing() const { return m_outstanding > 0; }
|
||||
bool hasData() const { return !m_entries.isEmpty(); }
|
||||
|
||||
|
||||
+612
-11
@@ -12,7 +12,11 @@
|
||||
#include "modloaderinstaller.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "buildswitcher.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
#include "seasonalpackdownloader.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
@@ -21,6 +25,7 @@
|
||||
#include <QJsonObject>
|
||||
#include <QHash>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
@@ -31,6 +36,17 @@ namespace {
|
||||
const QString kOffline = QStringLiteral("offline");
|
||||
const QString kElyBy = QStringLiteral("elyby");
|
||||
|
||||
// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl
|
||||
// в settings.json — это нужно, чтобы прогонять установку против локального
|
||||
// http-сервера, не трогая боевой.
|
||||
const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com");
|
||||
|
||||
// Дата сезона в таблице: год нужен, время — нет.
|
||||
QString formatSeasonDate(const QDate &date)
|
||||
{
|
||||
return date.isValid() ? date.toString(QStringLiteral("dd.MM.yyyy")) : QStringLiteral("—");
|
||||
}
|
||||
|
||||
int settingInt(const QVariantMap &settings, const QString &key, int fallback)
|
||||
{
|
||||
const QVariant value = settings.value(key);
|
||||
@@ -52,6 +68,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
, m_switcher(new BuildSwitcher(this))
|
||||
, m_javaMeta(new JavaRuntimeService(this))
|
||||
, m_javaInstaller(new JavaInstaller(this))
|
||||
, m_seasonalMeta(new SeasonalBuildService(this))
|
||||
, m_packDownloader(new SeasonalPackDownloader(this))
|
||||
{
|
||||
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
|
||||
// следующем — если её удалили, она появится снова.
|
||||
@@ -113,6 +131,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit javaRuntimeInstalled(runtimeId);
|
||||
emit launchProgress(tr("Java установлена: %1")
|
||||
.arg(QDir::toNativeSeparators(javaPath)));
|
||||
if (m_season.stage == SeasonStage::Java)
|
||||
seasonAdvance();
|
||||
});
|
||||
connect(m_javaInstaller, &JavaInstaller::failed, this,
|
||||
[this](const QString &label, const QString &message) {
|
||||
@@ -120,12 +140,16 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message));
|
||||
if (m_season.stage == SeasonStage::Java)
|
||||
seasonFail({});
|
||||
});
|
||||
connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
|
||||
invalidateJavaCatalog();
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label));
|
||||
if (m_season.stage == SeasonStage::Java)
|
||||
seasonFail({});
|
||||
});
|
||||
|
||||
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
|
||||
@@ -143,6 +167,11 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Версия «%1» загружена").arg(id));
|
||||
// У ванильной сезонной сборки этот установщик и есть весь шаг Loader.
|
||||
// Внутри установки модлоадера он тоже отчитывается — тогда ход
|
||||
// передаст ModLoaderInstaller, а не он.
|
||||
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
|
||||
seasonAdvance();
|
||||
});
|
||||
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
|
||||
const QString &message) {
|
||||
@@ -152,6 +181,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
|
||||
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
|
||||
seasonFail({});
|
||||
});
|
||||
connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
|
||||
emit switchChanged();
|
||||
@@ -170,6 +201,17 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
const int index = indexOfBuildId(toBuildId);
|
||||
emit launchProgress(tr("Активна сборка «%1»")
|
||||
.arg(index >= 0 ? m_builds[index].name : QString()));
|
||||
|
||||
// Раскатка пака могла быть и той, что доигралась после перезапуска —
|
||||
// тогда цепочки установки уже нет, а записать ревизию всё равно надо.
|
||||
const QJsonObject note = m_switcher->lastPackNote();
|
||||
if (!note.isEmpty())
|
||||
recordSeasonalPack(toBuildId, note, m_switcher->lastPackEntries());
|
||||
|
||||
if (m_season.stage == SeasonStage::Applying)
|
||||
seasonFinish();
|
||||
else if (m_season.stage == SeasonStage::Switching)
|
||||
seasonAdvance();
|
||||
});
|
||||
connect(m_switcher, &BuildSwitcher::failed, this,
|
||||
[this](int, const QString &message, bool gameDirIntact) {
|
||||
@@ -177,11 +219,27 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit installedVersionsChanged();
|
||||
emit switchChanged();
|
||||
emit busyChanged();
|
||||
emit launchError(gameDirIntact
|
||||
? tr("Сборка не переключена: %1").arg(message)
|
||||
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
|
||||
"осталось разобранным — повторите переключение.")
|
||||
.arg(message));
|
||||
// Раскатка пака и смена сборки приходят одним сигналом, а
|
||||
// сказать пользователю надо разное: после сорванного
|
||||
// переключения .minecraft лежит разобранной, а сорванная
|
||||
// раскатка означает лишь наполовину обновлённую сборку.
|
||||
if (m_season.stage == SeasonStage::Applying) {
|
||||
emit launchError(gameDirIntact
|
||||
? tr("Файлы сборки не установлены: %1").arg(message)
|
||||
: tr("Файлы сборки установлены не полностью: %1\n"
|
||||
"Повторите установку сборки.")
|
||||
.arg(message));
|
||||
} else {
|
||||
emit launchError(gameDirIntact
|
||||
? tr("Сборка не переключена: %1").arg(message)
|
||||
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
|
||||
"осталось разобранным — повторите переключение.")
|
||||
.arg(message));
|
||||
}
|
||||
if (m_season.stage == SeasonStage::Switching
|
||||
|| m_season.stage == SeasonStage::Applying) {
|
||||
seasonFail({});
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
|
||||
@@ -216,6 +274,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit downloadChanged();
|
||||
emit launchProgress(tr("Готово: %1 для Minecraft %2")
|
||||
.arg(producedVersionId, gameVersion));
|
||||
if (m_season.stage == SeasonStage::Loader)
|
||||
seasonAdvance();
|
||||
});
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
|
||||
[this](const QString &label, const QString &message) {
|
||||
@@ -225,6 +285,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit busyChanged();
|
||||
emit downloadChanged();
|
||||
emit launchError(tr("%1: %2").arg(label, message));
|
||||
if (m_season.stage == SeasonStage::Loader)
|
||||
seasonFail({});
|
||||
});
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
|
||||
m_loaderInstallBuildId = -1;
|
||||
@@ -233,6 +295,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit busyChanged();
|
||||
emit downloadChanged();
|
||||
emit launchProgress(tr("Установка %1 отменена").arg(label));
|
||||
if (m_season.stage == SeasonStage::Loader)
|
||||
seasonFail({});
|
||||
});
|
||||
|
||||
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
|
||||
@@ -241,6 +305,49 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
|
||||
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
|
||||
seasonFail({});
|
||||
});
|
||||
|
||||
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
|
||||
connect(m_seasonalMeta, &SeasonalBuildService::buildsChanged,
|
||||
this, &LauncherBackend::invalidateSeasonalCatalog);
|
||||
connect(m_seasonalMeta, &SeasonalBuildService::refreshingChanged,
|
||||
this, &LauncherBackend::seasonalCatalogLoadingChanged);
|
||||
// Статус строки («установлена», «доступно обновление») считается по
|
||||
// локальным записям — их изменение обновляет и таблицу.
|
||||
connect(this, &LauncherBackend::customBuildsChanged,
|
||||
this, &LauncherBackend::invalidateSeasonalCatalog);
|
||||
|
||||
connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) {
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка файлов сборки «%1»…").arg(label));
|
||||
});
|
||||
connect(m_packDownloader, &SeasonalPackDownloader::progressChanged,
|
||||
this, &LauncherBackend::downloadChanged);
|
||||
connect(m_packDownloader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
if (m_season.stage != SeasonStage::Downloading)
|
||||
return;
|
||||
m_season.packPath = path;
|
||||
seasonAdvance();
|
||||
});
|
||||
connect(m_packDownloader, &SeasonalPackDownloader::failed, this,
|
||||
[this](const QString &label, const QString &message) {
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchError(tr("Сборка «%1»: %2").arg(label, message));
|
||||
if (m_season.stage == SeasonStage::Downloading)
|
||||
seasonFail({});
|
||||
});
|
||||
connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) {
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка сборки «%1» отменена").arg(label));
|
||||
if (m_season.stage == SeasonStage::Downloading)
|
||||
seasonFail({});
|
||||
});
|
||||
|
||||
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
||||
@@ -362,6 +469,9 @@ void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
|
||||
take("loader", build.loader);
|
||||
take("loaderVersion", build.loaderVersion);
|
||||
take("resolvedVersionId", build.resolvedVersionId);
|
||||
take("seasonalId", build.seasonalId);
|
||||
if (fields.contains(QStringLiteral("seasonalRevision")))
|
||||
build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt();
|
||||
|
||||
if (build.minecraftVersion != previousMinecraft
|
||||
&& !fields.contains(QStringLiteral("loaderVersion"))) {
|
||||
@@ -399,6 +509,8 @@ QVariantMap LauncherBackend::customBuildAt(int index) const
|
||||
{"loader", b.loader},
|
||||
{"loaderVersion", b.loaderVersion},
|
||||
{"resolvedVersionId", b.resolvedVersionId},
|
||||
{"seasonalId", b.seasonalId},
|
||||
{"seasonalRevision", b.seasonalRevision},
|
||||
{"launchVersionId", launchVersionIdOf(b)}};
|
||||
}
|
||||
|
||||
@@ -752,8 +864,10 @@ void LauncherBackend::cancelDownload()
|
||||
m_loaderInstaller->cancel();
|
||||
else if (m_installer->isRunning())
|
||||
m_installer->cancel();
|
||||
else
|
||||
else if (m_javaInstaller->isRunning())
|
||||
m_javaInstaller->cancel();
|
||||
else
|
||||
m_packDownloader->cancel();
|
||||
}
|
||||
|
||||
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
|
||||
@@ -762,7 +876,7 @@ void LauncherBackend::cancelDownload()
|
||||
bool LauncherBackend::downloading() const
|
||||
{
|
||||
return m_loaderInstaller->isRunning() || m_installer->isRunning()
|
||||
|| m_javaInstaller->isRunning();
|
||||
|| m_javaInstaller->isRunning() || m_packDownloader->isRunning();
|
||||
}
|
||||
|
||||
// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
|
||||
@@ -772,8 +886,18 @@ bool LauncherBackend::javaDownloadShown() const
|
||||
&& !m_loaderInstaller->isRunning();
|
||||
}
|
||||
|
||||
// Пак качается отдельным шагом цепочки, когда остальные установщики уже
|
||||
// отработали, — за панель они не спорят.
|
||||
bool LauncherBackend::packDownloadShown() const
|
||||
{
|
||||
return m_packDownloader->isRunning() && !m_installer->isRunning()
|
||||
&& !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning();
|
||||
}
|
||||
|
||||
double LauncherBackend::downloadProgress() const
|
||||
{
|
||||
if (packDownloadShown())
|
||||
return m_packDownloader->fraction();
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->fraction();
|
||||
if (!m_loaderInstaller->isRunning())
|
||||
@@ -784,6 +908,8 @@ double LauncherBackend::downloadProgress() const
|
||||
|
||||
QString LauncherBackend::downloadVersion() const
|
||||
{
|
||||
if (packDownloadShown())
|
||||
return m_packDownloader->label();
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->label();
|
||||
if (m_loaderInstaller->isRunning())
|
||||
@@ -795,7 +921,10 @@ QString LauncherBackend::downloadStatus() const
|
||||
{
|
||||
QString stage = m_installer->stage();
|
||||
QString file = m_installer->currentFile();
|
||||
if (javaDownloadShown()) {
|
||||
if (packDownloadShown()) {
|
||||
stage = m_packDownloader->stage();
|
||||
file = m_packDownloader->currentFile();
|
||||
} else if (javaDownloadShown()) {
|
||||
stage = m_javaInstaller->stage();
|
||||
file = m_javaInstaller->currentFile();
|
||||
} else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
|
||||
@@ -811,6 +940,8 @@ QString LauncherBackend::downloadStatus() const
|
||||
|
||||
qint64 LauncherBackend::downloadBytesDone() const
|
||||
{
|
||||
if (packDownloadShown())
|
||||
return m_packDownloader->bytesDone();
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->bytesDone();
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
||||
@@ -820,6 +951,8 @@ qint64 LauncherBackend::downloadBytesDone() const
|
||||
|
||||
qint64 LauncherBackend::downloadBytesTotal() const
|
||||
{
|
||||
if (packDownloadShown())
|
||||
return m_packDownloader->bytesTotal();
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->bytesTotal();
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
||||
@@ -1217,7 +1350,8 @@ QString LauncherBackend::switchStatus() const
|
||||
bool LauncherBackend::busy() const
|
||||
{
|
||||
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
|
||||
|| m_javaInstaller->isRunning() || m_switcher->isRunning();
|
||||
|| m_javaInstaller->isRunning() || m_switcher->isRunning()
|
||||
|| m_packDownloader->isRunning();
|
||||
}
|
||||
|
||||
void LauncherBackend::setBusy(bool busy)
|
||||
@@ -1473,6 +1607,466 @@ QString LauncherBackend::managedJavaPath() const
|
||||
return runtime ? runtime->javaPath : QString();
|
||||
}
|
||||
|
||||
// ── Сезонные сборки ────────────────────────────────────────────────────────
|
||||
|
||||
QUrl LauncherBackend::seasonalBaseUrl() const
|
||||
{
|
||||
const QString configured =
|
||||
m_settings.value(QStringLiteral("seasonalBaseUrl")).toString().trimmed();
|
||||
QUrl url(configured.isEmpty() ? kSeasonalBaseUrl : configured);
|
||||
// Хвостовой слэш превратил бы адрес каталога в «…//index.json».
|
||||
while (url.path().endsWith(u'/'))
|
||||
url.setPath(url.path().chopped(1));
|
||||
return url;
|
||||
}
|
||||
|
||||
void LauncherBackend::invalidateSeasonalCatalog()
|
||||
{
|
||||
m_seasonalCatalogDirty = true;
|
||||
emit seasonalCatalogChanged();
|
||||
}
|
||||
|
||||
int LauncherBackend::indexOfSeasonalBuild(const QString &seasonalId) const
|
||||
{
|
||||
if (seasonalId.isEmpty())
|
||||
return -1;
|
||||
for (int i = 0; i < m_builds.size(); ++i) {
|
||||
if (m_builds[i].seasonalId == seasonalId)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void LauncherBackend::rebuildSeasonalCatalog() const
|
||||
{
|
||||
m_seasonalCatalog.clear();
|
||||
const QList<SeasonalBuildEntry> builds = m_seasonalMeta->builds();
|
||||
m_seasonalCatalog.reserve(builds.size());
|
||||
|
||||
for (const SeasonalBuildEntry &entry : builds) {
|
||||
int installedRevision = 0;
|
||||
bool installed = false;
|
||||
for (const CustomBuild &build : m_builds) {
|
||||
if (build.seasonalId != entry.id)
|
||||
continue;
|
||||
installed = true;
|
||||
installedRevision = build.seasonalRevision;
|
||||
break;
|
||||
}
|
||||
const bool updateAvailable = installed && installedRevision < entry.revision;
|
||||
|
||||
QString status;
|
||||
if (!installed)
|
||||
status = tr("Не установлена");
|
||||
else if (updateAvailable)
|
||||
status = tr("Есть обновление");
|
||||
else
|
||||
status = tr("Установлена");
|
||||
|
||||
const auto loader = loaderFromKey(entry.loader);
|
||||
m_seasonalCatalog.append(QVariantMap{
|
||||
{QStringLiteral("id"), entry.id},
|
||||
{QStringLiteral("name"), entry.name},
|
||||
{QStringLiteral("minecraftVersion"), entry.minecraftVersion},
|
||||
{QStringLiteral("loader"), entry.loader},
|
||||
{QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : tr("Ванильная")},
|
||||
{QStringLiteral("loaderVersion"), entry.loaderVersion},
|
||||
{QStringLiteral("modCount"), entry.modCount},
|
||||
{QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)},
|
||||
{QStringLiteral("seasonEnd"), formatSeasonDate(entry.seasonEnd)},
|
||||
{QStringLiteral("sizeBytes"), entry.archiveSize},
|
||||
{QStringLiteral("revision"), entry.revision},
|
||||
{QStringLiteral("description"), entry.description},
|
||||
{QStringLiteral("serverUrl"), entry.serverUrl},
|
||||
{QStringLiteral("installed"), installed},
|
||||
{QStringLiteral("installedRevision"), installedRevision},
|
||||
{QStringLiteral("updateAvailable"), updateAvailable},
|
||||
{QStringLiteral("status"), status}});
|
||||
}
|
||||
m_seasonalCatalogDirty = false;
|
||||
}
|
||||
|
||||
QVariantList LauncherBackend::seasonalCatalog() const
|
||||
{
|
||||
if (m_seasonalCatalogDirty)
|
||||
rebuildSeasonalCatalog();
|
||||
return m_seasonalCatalog;
|
||||
}
|
||||
|
||||
bool LauncherBackend::seasonalCatalogLoading() const
|
||||
{
|
||||
return m_seasonalMeta->isRefreshing();
|
||||
}
|
||||
|
||||
QString LauncherBackend::seasonalCatalogError() const
|
||||
{
|
||||
return m_seasonalMeta->lastError();
|
||||
}
|
||||
|
||||
bool LauncherBackend::seasonalInstalling() const
|
||||
{
|
||||
return m_season.stage != SeasonStage::Idle;
|
||||
}
|
||||
|
||||
void LauncherBackend::refreshSeasonalCatalog(bool force)
|
||||
{
|
||||
// Адрес мог смениться в settings.json между запусками окна.
|
||||
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
|
||||
m_seasonalMeta->ensureLoaded(nullptr, force);
|
||||
}
|
||||
|
||||
// ── Установка сезонной сборки ──────────────────────────────────────────────
|
||||
|
||||
void LauncherBackend::installSeasonalBuild(const QString &seasonalId)
|
||||
{
|
||||
if (m_season.stage != SeasonStage::Idle) {
|
||||
emit launchError(tr("Установка сборки уже идёт"));
|
||||
return;
|
||||
}
|
||||
if (m_launcher->isRunning()) {
|
||||
emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
|
||||
return;
|
||||
}
|
||||
if (busy()) {
|
||||
emit launchError(tr("Дождитесь окончания текущей операции"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entry = m_seasonalMeta->find(seasonalId);
|
||||
if (!entry) {
|
||||
emit launchError(tr("Сборка «%1» не найдена в списке — обновите его").arg(seasonalId));
|
||||
return;
|
||||
}
|
||||
|
||||
m_season = {};
|
||||
m_season.entry = *entry;
|
||||
|
||||
// Сборка с таким seasonalId уже заведена — обновляем её описание под новую
|
||||
// ревизию, а не плодим вторую запись.
|
||||
int index = indexOfSeasonalBuild(seasonalId);
|
||||
if (index < 0) {
|
||||
CustomBuild build;
|
||||
build.id = m_nextBuildId++;
|
||||
build.name = entry->name;
|
||||
build.serverUrl = entry->serverUrl;
|
||||
build.minecraftVersion = entry->minecraftVersion;
|
||||
build.loader = entry->loader;
|
||||
build.loaderVersion = entry->loaderVersion;
|
||||
build.seasonalId = entry->id;
|
||||
// Ревизия появится только когда файлы лягут на диск: иначе оборванная
|
||||
// установка выглядела бы завершённой.
|
||||
m_builds.append(build);
|
||||
index = m_builds.size() - 1;
|
||||
saveCustomBuilds();
|
||||
emit customBuildsChanged();
|
||||
// maybeInstall() здесь намеренно нет: версию и модлоадер поставит шаг
|
||||
// цепочки, а параллельная загрузка только мешала бы ему.
|
||||
} else {
|
||||
updateCustomBuild(index,
|
||||
{{QStringLiteral("name"), entry->name},
|
||||
{QStringLiteral("serverUrl"), entry->serverUrl},
|
||||
{QStringLiteral("minecraftVersion"), entry->minecraftVersion},
|
||||
{QStringLiteral("loader"), entry->loader},
|
||||
{QStringLiteral("loaderVersion"), entry->loaderVersion}});
|
||||
}
|
||||
|
||||
m_season.buildId = m_builds[index].id;
|
||||
setBusy(true);
|
||||
emit launchProgress(tr("Установка сборки «%1»…").arg(entry->name));
|
||||
// Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его
|
||||
// вызова seasonalInstalling() ещё вернул бы false.
|
||||
seasonAdvance();
|
||||
emit seasonalInstallingChanged();
|
||||
}
|
||||
|
||||
void LauncherBackend::seasonAdvance()
|
||||
{
|
||||
if (indexOfBuildId(m_season.buildId) < 0) {
|
||||
seasonFail(tr("запись сборки исчезла"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Каждый шаг либо начинает свою асинхронную работу и обрывает цепочку до
|
||||
// следующего сигнала, либо уступает следующему — отсюда провалы между
|
||||
// ветками.
|
||||
switch (m_season.stage) {
|
||||
case SeasonStage::Idle:
|
||||
if (seasonBeginSwitch())
|
||||
return;
|
||||
[[fallthrough]];
|
||||
case SeasonStage::Switching:
|
||||
if (seasonBeginJava())
|
||||
return;
|
||||
[[fallthrough]];
|
||||
case SeasonStage::Java:
|
||||
if (seasonBeginLoader())
|
||||
return;
|
||||
[[fallthrough]];
|
||||
case SeasonStage::Loader:
|
||||
if (seasonBeginDownload())
|
||||
return;
|
||||
[[fallthrough]];
|
||||
case SeasonStage::Downloading:
|
||||
if (seasonBeginApply())
|
||||
return;
|
||||
[[fallthrough]];
|
||||
case SeasonStage::Applying:
|
||||
seasonFinish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool LauncherBackend::seasonBeginSwitch()
|
||||
{
|
||||
const int index = indexOfBuildId(m_season.buildId);
|
||||
const int current = activeBuildIndex();
|
||||
if (current == index)
|
||||
return false;
|
||||
|
||||
// Пак ложится на то, что сейчас разложено в .minecraft, поэтому сборка
|
||||
// должна стать активной до всего остального. Содержимое прежней уезжает в
|
||||
// её архив обычным путём.
|
||||
m_season.stage = SeasonStage::Switching;
|
||||
emit launchProgress(tr("Смена сборки на «%1»…").arg(m_builds[index].name));
|
||||
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
|
||||
current >= 0 ? m_builds[current].name : QString(),
|
||||
m_builds[index].id,
|
||||
m_builds[index].name,
|
||||
gameDir());
|
||||
emit switchChanged();
|
||||
emit busyChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LauncherBackend::seasonBeginJava()
|
||||
{
|
||||
// Сервер может назвать нужную Java прямо; иначе спрашиваем таблицу.
|
||||
const int major = m_season.entry.javaMajor > 0
|
||||
? m_season.entry.javaMajor
|
||||
: JavaRequirement::minimumFor(m_season.entry.minecraftVersion);
|
||||
if (major <= 0)
|
||||
return false;
|
||||
|
||||
// Java уже есть — своя, системная или скачанная раньше.
|
||||
QString error;
|
||||
if (!JavaLocator::select(gameDir(), major, QString(), &error).isEmpty())
|
||||
return false;
|
||||
|
||||
m_season.stage = SeasonStage::Java;
|
||||
emit launchProgress(tr("Для сборки нужна Java %1 — устанавливаем").arg(major));
|
||||
|
||||
// Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать.
|
||||
m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) {
|
||||
if (m_season.stage != SeasonStage::Java)
|
||||
return;
|
||||
if (!ok) {
|
||||
seasonFail(warning.isEmpty() ? tr("не удалось получить каталог Java") : warning);
|
||||
return;
|
||||
}
|
||||
const auto entry = m_javaMeta->bestFor(major);
|
||||
if (!entry) {
|
||||
seasonFail(tr("в каталоге нет сборки Java %1 для этой системы").arg(major));
|
||||
return;
|
||||
}
|
||||
m_javaInstaller->install(*entry);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LauncherBackend::seasonBeginLoader()
|
||||
{
|
||||
const int index = indexOfBuildId(m_season.buildId);
|
||||
const CustomBuild &build = m_builds[index];
|
||||
const auto loader = loaderFromKey(build.loader);
|
||||
|
||||
// Ванильная сборка: ставить надо только саму версию игры.
|
||||
if (!loader) {
|
||||
if (checkInstallation(index).isEmpty())
|
||||
return false;
|
||||
m_season.stage = SeasonStage::Loader;
|
||||
m_installer->install(gameDir(), build.minecraftVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Профиль лоадера уже собран и укомплектован — второй раз не ставим.
|
||||
if (!build.resolvedVersionId.isEmpty() && checkInstallation(index).isEmpty())
|
||||
return false;
|
||||
|
||||
m_season.stage = SeasonStage::Loader;
|
||||
m_loaderInstallBuildId = build.id;
|
||||
m_loaderInstaller->install(gameDir(),
|
||||
*loader,
|
||||
build.minecraftVersion,
|
||||
build.loaderVersion,
|
||||
managedJavaPath().isEmpty()
|
||||
? m_settings.value(QStringLiteral("javaPath")).toString()
|
||||
: managedJavaPath());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LauncherBackend::seasonBeginDownload()
|
||||
{
|
||||
const SeasonalBuildEntry &entry = m_season.entry;
|
||||
// Точка в начале имени: файл служебный, и BuildSwitcher по этому признаку
|
||||
// не примет его за архив сборки и не сотрёт при следующем переключении.
|
||||
const QString path = LauncherPaths::buildDir(m_season.buildId)
|
||||
+ QStringLiteral("/.pack-r") + QString::number(entry.revision)
|
||||
+ QStringLiteral(".zip");
|
||||
|
||||
m_season.stage = SeasonStage::Downloading;
|
||||
m_packDownloader->download(entry.archiveUrl,
|
||||
path,
|
||||
entry.archiveSha256,
|
||||
entry.archiveSize,
|
||||
entry.name);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LauncherBackend::seasonBeginApply()
|
||||
{
|
||||
if (m_season.packPath.isEmpty()) {
|
||||
seasonFail(tr("файлы сборки не скачаны"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Под удаление идёт весь список прошлой ревизии, а не разница с новой:
|
||||
// распаковка всё равно кладёт каждый файл пака заново, поэтому разница
|
||||
// сэкономила бы лишь перезапись того, что и так перезаписывается, а вот
|
||||
// ушедшие из сборки моды так убираются наверняка. Миров и скриншотов в
|
||||
// списке нет — их пак не приносил.
|
||||
const QStringList remove = seasonalPackEntries(m_season.buildId);
|
||||
|
||||
const QJsonObject note{{QStringLiteral("seasonalId"), m_season.entry.id},
|
||||
{QStringLiteral("revision"), m_season.entry.revision},
|
||||
{QStringLiteral("sha256"), m_season.entry.archiveSha256}};
|
||||
|
||||
const int index = indexOfBuildId(m_season.buildId);
|
||||
m_season.stage = SeasonStage::Applying;
|
||||
m_switcher->applyPack(m_season.buildId,
|
||||
m_builds[index].name,
|
||||
m_season.packPath,
|
||||
remove,
|
||||
note,
|
||||
gameDir());
|
||||
emit switchChanged();
|
||||
emit busyChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
void LauncherBackend::seasonFinish()
|
||||
{
|
||||
const int index = indexOfBuildId(m_season.buildId);
|
||||
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
|
||||
const QString seasonalId = m_season.entry.id;
|
||||
|
||||
m_season = {};
|
||||
setBusy(false);
|
||||
invalidateSeasonalCatalog();
|
||||
emit seasonalInstallingChanged();
|
||||
emit launchProgress(tr("Сборка «%1» установлена").arg(name));
|
||||
emit seasonalInstallFinished(seasonalId, name);
|
||||
}
|
||||
|
||||
void LauncherBackend::seasonFail(const QString &message)
|
||||
{
|
||||
const int index = indexOfBuildId(m_season.buildId);
|
||||
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
|
||||
// Недокачанный или нераскатанный пак не должен занимать место: цепочку
|
||||
// всегда можно начать заново с чистого листа.
|
||||
if (!m_season.packPath.isEmpty())
|
||||
QFile::remove(m_season.packPath);
|
||||
|
||||
m_season = {};
|
||||
setBusy(false);
|
||||
emit seasonalInstallingChanged();
|
||||
// Пустое message означает, что о причине уже сообщил тот, кто её нашёл, —
|
||||
// второй раз ту же ошибку не показываем.
|
||||
emit launchError(message.isEmpty()
|
||||
? tr("Установка сборки «%1» прервана").arg(name)
|
||||
: tr("Установка сборки «%1» прервана: %2").arg(name, message));
|
||||
}
|
||||
|
||||
void LauncherBackend::cancelSeasonalInstall()
|
||||
{
|
||||
switch (m_season.stage) {
|
||||
case SeasonStage::Idle:
|
||||
return;
|
||||
case SeasonStage::Java:
|
||||
m_javaInstaller->cancel();
|
||||
return;
|
||||
case SeasonStage::Loader:
|
||||
if (m_loaderInstaller->isRunning())
|
||||
m_loaderInstaller->cancel();
|
||||
else
|
||||
m_installer->cancel();
|
||||
return;
|
||||
case SeasonStage::Downloading:
|
||||
m_packDownloader->cancel();
|
||||
return;
|
||||
case SeasonStage::Switching:
|
||||
case SeasonStage::Applying:
|
||||
// На этих шагах содержимое .minecraft уже разобрано: отступать некуда,
|
||||
// операцию нужно довести до конца.
|
||||
emit launchError(tr("Сейчас переносятся файлы — отменить нельзя"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Состояние установленной сезонной сборки ────────────────────────────────
|
||||
|
||||
QStringList LauncherBackend::seasonalPackEntries(int buildId) const
|
||||
{
|
||||
QFile file(LauncherPaths::seasonalStateFile(buildId));
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
|
||||
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
||||
QStringList entries;
|
||||
const QJsonArray array = root.value(QStringLiteral("entries")).toArray();
|
||||
entries.reserve(array.size());
|
||||
for (const QJsonValue &value : array)
|
||||
entries << value.toString();
|
||||
return entries;
|
||||
}
|
||||
|
||||
void LauncherBackend::recordSeasonalPack(int buildId,
|
||||
const QJsonObject ¬e,
|
||||
const QStringList &entries)
|
||||
{
|
||||
const QString seasonalId = note.value(QStringLiteral("seasonalId")).toString();
|
||||
const int revision = note.value(QStringLiteral("revision")).toInt();
|
||||
if (seasonalId.isEmpty() || revision <= 0)
|
||||
return;
|
||||
|
||||
const QJsonObject state{{QStringLiteral("seasonalId"), seasonalId},
|
||||
{QStringLiteral("revision"), revision},
|
||||
{QStringLiteral("sha256"),
|
||||
note.value(QStringLiteral("sha256")).toString()},
|
||||
{QStringLiteral("installedAt"),
|
||||
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)},
|
||||
{QStringLiteral("entries"), QJsonArray::fromStringList(entries)}};
|
||||
// Не через writeJsonFile: файл лежит в папке сборки, а не в корне лаунчера,
|
||||
// и его отсутствие не мешает игре — ошибку достаточно показать.
|
||||
QDir().mkpath(LauncherPaths::buildDir(buildId));
|
||||
QSaveFile file(LauncherPaths::seasonalStateFile(buildId));
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
const QByteArray payload = QJsonDocument(state).toJson(QJsonDocument::Indented);
|
||||
if (file.write(payload) == payload.size())
|
||||
file.commit();
|
||||
else
|
||||
file.cancelWriting();
|
||||
}
|
||||
|
||||
const int index = indexOfBuildId(buildId);
|
||||
if (index < 0)
|
||||
return;
|
||||
// Ревизия проставляется только здесь: до этого момента файлы сборки на
|
||||
// диске не лежали.
|
||||
updateCustomBuild(index,
|
||||
{{QStringLiteral("seasonalId"), seasonalId},
|
||||
{QStringLiteral("seasonalRevision"), revision}});
|
||||
}
|
||||
|
||||
void LauncherBackend::openMinecraftFolder()
|
||||
{
|
||||
const QString modsDir = gameDir() + QStringLiteral("/mods");
|
||||
@@ -1624,6 +2218,8 @@ void LauncherBackend::loadData()
|
||||
build.loader = o.value("loader").toString();
|
||||
build.loaderVersion = o.value("loaderVersion").toString();
|
||||
build.resolvedVersionId = o.value("resolvedVersionId").toString();
|
||||
build.seasonalId = o.value("seasonalId").toString();
|
||||
build.seasonalRevision = o.value("seasonalRevision").toInt();
|
||||
|
||||
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
|
||||
// пустую строку.
|
||||
@@ -1727,7 +2323,9 @@ void LauncherBackend::saveCustomBuilds()
|
||||
{"minecraftVersion", b.minecraftVersion},
|
||||
{"loader", b.loader},
|
||||
{"loaderVersion", b.loaderVersion},
|
||||
{"resolvedVersionId", b.resolvedVersionId}});
|
||||
{"resolvedVersionId", b.resolvedVersionId},
|
||||
{"seasonalId", b.seasonalId},
|
||||
{"seasonalRevision", b.seasonalRevision}});
|
||||
|
||||
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
|
||||
}
|
||||
@@ -1746,7 +2344,10 @@ void LauncherBackend::loadSettings()
|
||||
{QStringLiteral("fullscreen"), false},
|
||||
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
|
||||
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
|
||||
{QStringLiteral("activeBuildId"), 0}};
|
||||
{QStringLiteral("activeBuildId"), 0},
|
||||
// Сервер готовых сборок. В интерфейс не вынесен: менять его
|
||||
// приходится только при отладке против локального сервера.
|
||||
{QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}};
|
||||
|
||||
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
|
||||
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "minecraftversion.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
@@ -20,6 +21,8 @@ class ModLoaderInstaller;
|
||||
class BuildSwitcher;
|
||||
class JavaRuntimeService;
|
||||
class JavaInstaller;
|
||||
class SeasonalBuildService;
|
||||
class SeasonalPackDownloader;
|
||||
|
||||
class LauncherBackend : public QObject
|
||||
{
|
||||
@@ -55,6 +58,15 @@ class LauncherBackend : public QObject
|
||||
Q_PROPERTY(QVariantList javaCatalog READ javaCatalog NOTIFY javaCatalogChanged)
|
||||
Q_PROPERTY(bool javaCatalogLoading READ javaCatalogLoading NOTIFY javaCatalogLoadingChanged)
|
||||
|
||||
// Готовые сборки с сервера. Строки таблицы уже сведены с локальными
|
||||
// записями: окно показывает статус, не считая ничего само.
|
||||
Q_PROPERTY(QVariantList seasonalCatalog READ seasonalCatalog NOTIFY seasonalCatalogChanged)
|
||||
Q_PROPERTY(bool seasonalCatalogLoading READ seasonalCatalogLoading
|
||||
NOTIFY seasonalCatalogLoadingChanged)
|
||||
Q_PROPERTY(QString seasonalCatalogError READ seasonalCatalogError NOTIFY seasonalCatalogChanged)
|
||||
// Идёт установка сезонной сборки: окно не даёт начать вторую.
|
||||
Q_PROPERTY(bool seasonalInstalling READ seasonalInstalling NOTIFY seasonalInstallingChanged)
|
||||
|
||||
// Одного сигнала на все свойства загрузки достаточно: установщик уже
|
||||
// ограничивает частоту, а QML всё равно перечитывает их разом.
|
||||
Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged)
|
||||
@@ -88,6 +100,11 @@ public:
|
||||
QVariantList javaCatalog() const;
|
||||
bool javaCatalogLoading() const;
|
||||
|
||||
QVariantList seasonalCatalog() const;
|
||||
bool seasonalCatalogLoading() const;
|
||||
QString seasonalCatalogError() const;
|
||||
bool seasonalInstalling() const;
|
||||
|
||||
bool downloading() const;
|
||||
double downloadProgress() const;
|
||||
QString downloadVersion() const;
|
||||
@@ -168,6 +185,13 @@ public:
|
||||
// Минимальная Java для версии игры выбранной сборки; 0 — версия не выбрана.
|
||||
Q_INVOKABLE int requiredJavaMajor(int buildIndex) const;
|
||||
|
||||
// ── Сезонные сборки ─────────────────────────────────────────────────
|
||||
Q_INVOKABLE void refreshSeasonalCatalog(bool force = false);
|
||||
// Ставит или обновляет сборку целиком: запись, версия, модлоадер, Java и
|
||||
// файлы — одной цепочкой.
|
||||
Q_INVOKABLE void installSeasonalBuild(const QString &seasonalId);
|
||||
Q_INVOKABLE void cancelSeasonalInstall();
|
||||
|
||||
Q_INVOKABLE void openMinecraftFolder();
|
||||
Q_INVOKABLE void openGameFolder();
|
||||
|
||||
@@ -185,8 +209,14 @@ signals:
|
||||
void catalogLoadingChanged();
|
||||
void javaCatalogChanged();
|
||||
void javaCatalogLoadingChanged();
|
||||
void seasonalCatalogChanged();
|
||||
void seasonalCatalogLoadingChanged();
|
||||
void seasonalInstallingChanged();
|
||||
void downloadChanged();
|
||||
|
||||
// Сезонная сборка установлена и активна — можно запускать игру.
|
||||
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
||||
|
||||
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
||||
void javaRuntimeInstalled(const QString &runtimeId);
|
||||
|
||||
@@ -215,6 +245,24 @@ private:
|
||||
QString loader; // "" | forge | fabric | neoforge | quilt
|
||||
QString loaderVersion; // 47.4.0 | 0.19.3
|
||||
QString resolvedVersionId; // versions/<id>, который реально запускается
|
||||
// Сборка приехала с сервера сезонных сборок: id её строки в каталоге и
|
||||
// установленная ревизия. Пусто — обычная пользовательская сборка.
|
||||
QString seasonalId;
|
||||
int seasonalRevision = 0;
|
||||
};
|
||||
|
||||
// Шаг установки сезонной сборки. Порядок жёсткий: Java нужна установщику
|
||||
// Forge, а раскатывать файлы поверх имеет смысл только когда всё остальное
|
||||
// на месте.
|
||||
enum class SeasonStage { Idle, Switching, Java, Loader, Downloading, Applying };
|
||||
struct SeasonalInstall
|
||||
{
|
||||
SeasonStage stage = SeasonStage::Idle;
|
||||
int buildId = -1;
|
||||
QString packPath;
|
||||
// Копия строки каталога на момент старта: обновление списка посреди
|
||||
// установки не должно менять то, что ставится.
|
||||
SeasonalBuildEntry entry;
|
||||
};
|
||||
|
||||
void loadData();
|
||||
@@ -246,6 +294,30 @@ private:
|
||||
void rebuildCatalog() const;
|
||||
void invalidateJavaCatalog();
|
||||
void rebuildJavaCatalog() const;
|
||||
void invalidateSeasonalCatalog();
|
||||
void rebuildSeasonalCatalog() const;
|
||||
// Панель загрузки занята скачиванием пака сезонной сборки.
|
||||
bool packDownloadShown() const;
|
||||
|
||||
// ── Установка сезонной сборки ───────────────────────────────────────
|
||||
// Каждый шаг либо запускает асинхронную операцию и возвращает true, либо
|
||||
// решает, что делать нечего, и уступает следующему.
|
||||
void seasonAdvance();
|
||||
bool seasonBeginSwitch();
|
||||
bool seasonBeginJava();
|
||||
bool seasonBeginLoader();
|
||||
bool seasonBeginDownload();
|
||||
bool seasonBeginApply();
|
||||
// message пусто — о причине уже сообщил тот, кто её обнаружил.
|
||||
void seasonFail(const QString &message);
|
||||
void seasonFinish();
|
||||
int indexOfSeasonalBuild(const QString &seasonalId) const;
|
||||
// Что принесла установленная ревизия сборки; пусто — сезонных файлов нет.
|
||||
QStringList seasonalPackEntries(int buildId) const;
|
||||
// Запоминает раскатанную ревизию: и в builds/<id>/season.json, и в записи
|
||||
// сборки. Вызывается и после доигрывания прерванной установки.
|
||||
void recordSeasonalPack(int buildId, const QJsonObject ¬e, const QStringList &entries);
|
||||
QUrl seasonalBaseUrl() const;
|
||||
// Панель загрузки занята установкой Java, а не версии или модлоадера.
|
||||
bool javaDownloadShown() const;
|
||||
// Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана
|
||||
@@ -277,6 +349,8 @@ private:
|
||||
BuildSwitcher *m_switcher;
|
||||
JavaRuntimeService *m_javaMeta;
|
||||
JavaInstaller *m_javaInstaller;
|
||||
SeasonalBuildService *m_seasonalMeta;
|
||||
SeasonalPackDownloader *m_packDownloader;
|
||||
|
||||
// Сборка, для которой сейчас ставится модлоадер: по её id находим запись,
|
||||
// когда установщик отчитается.
|
||||
@@ -290,6 +364,11 @@ private:
|
||||
mutable QVariantList m_javaCatalog;
|
||||
mutable bool m_javaCatalogDirty = true;
|
||||
|
||||
mutable QVariantList m_seasonalCatalog;
|
||||
mutable bool m_seasonalCatalogDirty = true;
|
||||
|
||||
SeasonalInstall m_season;
|
||||
|
||||
bool m_busy = false;
|
||||
|
||||
// Состояние запуска, начатого до асинхронной авторизации.
|
||||
|
||||
@@ -66,6 +66,16 @@ QString LauncherPaths::buildStorageDir()
|
||||
return rootDir() + QStringLiteral("/builds");
|
||||
}
|
||||
|
||||
QString LauncherPaths::buildDir(int buildId)
|
||||
{
|
||||
return buildStorageDir() + u'/' + QString::number(buildId);
|
||||
}
|
||||
|
||||
QString LauncherPaths::seasonalStateFile(int buildId)
|
||||
{
|
||||
return buildDir(buildId) + QStringLiteral("/season.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::runtimeDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/runtime");
|
||||
@@ -96,6 +106,11 @@ QString LauncherPaths::versionManifestFile()
|
||||
return cacheDir() + QStringLiteral("/version_manifest.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::seasonalCatalogFile()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/seasonal_builds.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::loaderCacheFile(const QString &loaderKey)
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/loader_") + loaderKey + QStringLiteral(".json");
|
||||
|
||||
@@ -28,6 +28,15 @@ QString legacyCustomBuildsFile();
|
||||
// <root>/builds — архивы содержимого .minecraft по одному на сборку.
|
||||
QString buildStorageDir();
|
||||
|
||||
// <root>/builds/<id> — папка одной сборки: её архив и, у сезонных, скачанный
|
||||
// пак с описанием установленной ревизии.
|
||||
QString buildDir(int buildId);
|
||||
|
||||
// <root>/builds/<id>/season.json — какая ревизия сезонной сборки установлена и
|
||||
// какие файлы она принесла. Список нужен, чтобы при обновлении убрать те, что
|
||||
// из сборки ушли.
|
||||
QString seasonalStateFile(int buildId);
|
||||
|
||||
// Сюда качается authlib-injector.
|
||||
QString runtimeDir();
|
||||
|
||||
@@ -47,6 +56,9 @@ QString cacheDir();
|
||||
// Слепок манифеста версий Mojang с отметкой времени.
|
||||
QString versionManifestFile();
|
||||
|
||||
// Слепок каталога сезонных сборок с отметкой времени.
|
||||
QString seasonalCatalogFile();
|
||||
|
||||
// Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt.
|
||||
QString loaderCacheFile(const QString &loaderKey);
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
#include "seasonalbuildservice.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "modloader.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
// Формат каталога, который понимает этот лаунчер. Сервер, отдающий версию
|
||||
// новее, обслуживает лаунчер новее — молча показывать половину полей нельзя.
|
||||
constexpr int kSupportedFormatVersion = 1;
|
||||
|
||||
// Каталог весит килобайты, а новая ревизия сборки должна доезжать до игроков
|
||||
// быстро. Четверть часа — это и не поток запросов при каждом открытии окна, и
|
||||
// не полдня ожидания после публикации; кнопка «Обновить список» ходит с force.
|
||||
constexpr qint64 kCacheTtlSeconds = 15 * 60;
|
||||
|
||||
constexpr int kTransferTimeoutMs = 20000;
|
||||
|
||||
// id попадает в имена файлов и в сравнение с локальными записями, поэтому
|
||||
// набор символов узкий и проверяется до всего остального.
|
||||
bool isSafeCatalogId(const QString &id)
|
||||
{
|
||||
static const QRegularExpression re(QStringLiteral("^[a-z0-9][a-z0-9._-]{0,63}$"));
|
||||
return re.match(id).hasMatch();
|
||||
}
|
||||
|
||||
// Тот же запрет, что у идентификаторов версий в minecraftversion.cpp: строка
|
||||
// становится именем папки в versions.
|
||||
bool isSafeVersionId(const QString &id)
|
||||
{
|
||||
if (id.isEmpty() || id.size() > 128)
|
||||
return false;
|
||||
if (id.contains(u'/') || id.contains(u'\\') || id.contains(u':'))
|
||||
return false;
|
||||
return id != QLatin1String(".") && id != QLatin1String("..");
|
||||
}
|
||||
|
||||
bool isSha256Hex(const QString &value)
|
||||
{
|
||||
if (value.size() != 64)
|
||||
return false;
|
||||
for (const QChar c : value) {
|
||||
if (!((c >= u'0' && c <= u'9') || (c >= u'a' && c <= u'f') || (c >= u'A' && c <= u'F')))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// null или пустая строка — даты нет; в таблице на её месте прочерк.
|
||||
QDate readDate(const QJsonValue &value)
|
||||
{
|
||||
const QString text = value.toString();
|
||||
return text.isEmpty() ? QDate() : QDate::fromString(text, Qt::ISODate);
|
||||
}
|
||||
|
||||
QString dateToJson(const QDate &date)
|
||||
{
|
||||
return date.isValid() ? date.toString(Qt::ISODate) : QString();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SeasonalBuildService::SeasonalBuildService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
}
|
||||
|
||||
std::optional<SeasonalBuildEntry> SeasonalBuildService::find(const QString &id) const
|
||||
{
|
||||
const auto it = m_indexById.constFind(id);
|
||||
if (it == m_indexById.constEnd())
|
||||
return std::nullopt;
|
||||
return m_builds.at(it.value());
|
||||
}
|
||||
|
||||
void SeasonalBuildService::setBaseUrl(const QUrl &baseUrl)
|
||||
{
|
||||
if (m_baseUrl == baseUrl)
|
||||
return;
|
||||
|
||||
m_baseUrl = baseUrl;
|
||||
// Ссылки на архивы привязаны к прежнему хосту, а проверка их происхождения
|
||||
// сверяется с базовым адресом — держать такие данные после смены сервера
|
||||
// нельзя.
|
||||
m_builds.clear();
|
||||
m_indexById.clear();
|
||||
m_fetchedAt = QDateTime();
|
||||
m_cacheRead = false;
|
||||
m_lastError.clear();
|
||||
emit buildsChanged();
|
||||
}
|
||||
|
||||
void SeasonalBuildService::applyBuilds(QList<SeasonalBuildEntry> list)
|
||||
{
|
||||
m_builds = std::move(list);
|
||||
m_indexById.clear();
|
||||
m_indexById.reserve(m_builds.size());
|
||||
for (int i = 0; i < m_builds.size(); ++i)
|
||||
m_indexById.insert(m_builds.at(i).id, i);
|
||||
}
|
||||
|
||||
QList<SeasonalBuildEntry> SeasonalBuildService::parseCatalog(const QJsonObject &root,
|
||||
QString *error) const
|
||||
{
|
||||
const int format = root.value(QStringLiteral("formatVersion")).toInt();
|
||||
if (format != kSupportedFormatVersion) {
|
||||
if (error) {
|
||||
*error = format > kSupportedFormatVersion
|
||||
? tr("Сервер сборок отдаёт формат %1 — обновите лаунчер").arg(format)
|
||||
: tr("Неизвестный формат каталога сборок (%1)").arg(format);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QList<SeasonalBuildEntry> list;
|
||||
const QJsonArray builds = root.value(QStringLiteral("builds")).toArray();
|
||||
list.reserve(builds.size());
|
||||
|
||||
for (const QJsonValue &value : builds) {
|
||||
const QJsonObject object = value.toObject();
|
||||
|
||||
SeasonalBuildEntry entry;
|
||||
entry.id = object.value(QStringLiteral("id")).toString();
|
||||
entry.name = object.value(QStringLiteral("name")).toString().trimmed();
|
||||
entry.revision = object.value(QStringLiteral("revision")).toInt();
|
||||
entry.minecraftVersion = object.value(QStringLiteral("minecraftVersion")).toString();
|
||||
entry.loader = object.value(QStringLiteral("loader")).toString();
|
||||
entry.loaderVersion = object.value(QStringLiteral("loaderVersion")).toString().trimmed();
|
||||
entry.modCount = object.value(QStringLiteral("modCount")).toInt();
|
||||
entry.seasonStart = readDate(object.value(QStringLiteral("seasonStart")));
|
||||
entry.seasonEnd = readDate(object.value(QStringLiteral("seasonEnd")));
|
||||
entry.serverUrl = object.value(QStringLiteral("serverUrl")).toString().trimmed();
|
||||
entry.javaMajor = object.value(QStringLiteral("javaMajor")).toInt();
|
||||
entry.description = object.value(QStringLiteral("description")).toString().trimmed();
|
||||
|
||||
const QJsonObject archive = object.value(QStringLiteral("archive")).toObject();
|
||||
entry.archiveUrl = QUrl(archive.value(QStringLiteral("url")).toString());
|
||||
entry.archiveSize = qint64(archive.value(QStringLiteral("size")).toDouble());
|
||||
entry.archiveSha256 = archive.value(QStringLiteral("sha256")).toString().toLower();
|
||||
|
||||
// Данные пришли из сети и превратятся в пути на диске и в запуск игры,
|
||||
// поэтому строка отбрасывается целиком при любой странности.
|
||||
if (!isSafeCatalogId(entry.id) || entry.name.isEmpty() || entry.revision <= 0)
|
||||
continue;
|
||||
if (!isSafeVersionId(entry.minecraftVersion))
|
||||
continue;
|
||||
if (!entry.loader.isEmpty() && !loaderFromKey(entry.loader))
|
||||
continue;
|
||||
// Загрузчик без версии установить нечем.
|
||||
if (!entry.loader.isEmpty() && entry.loaderVersion.isEmpty())
|
||||
continue;
|
||||
if (!isSha256Hex(entry.archiveSha256) || entry.archiveSize < 0)
|
||||
continue;
|
||||
// Ссылка обязана вести на тот же сервер, с которого пришёл каталог:
|
||||
// подменённый index.json иначе увёл бы загрузку куда угодно.
|
||||
if (entry.archiveUrl.scheme() != m_baseUrl.scheme()
|
||||
|| entry.archiveUrl.host() != m_baseUrl.host()
|
||||
|| entry.archiveUrl.port(-1) != m_baseUrl.port(-1)) {
|
||||
continue;
|
||||
}
|
||||
// http допустим только если сам адрес сервера задан по http — так
|
||||
// работает отладка против локального python3 -m http.server.
|
||||
if (entry.archiveUrl.scheme() != QLatin1String("https")
|
||||
&& entry.archiveUrl.scheme() != QLatin1String("http")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list << entry;
|
||||
}
|
||||
|
||||
// Свежие сезоны сверху: сначала те, что ещё идут, потом по дате старта.
|
||||
std::stable_sort(list.begin(), list.end(),
|
||||
[](const SeasonalBuildEntry &a, const SeasonalBuildEntry &b) {
|
||||
const bool aOpen = !a.seasonEnd.isValid();
|
||||
const bool bOpen = !b.seasonEnd.isValid();
|
||||
if (aOpen != bOpen)
|
||||
return aOpen;
|
||||
if (a.seasonStart != b.seasonStart)
|
||||
return a.seasonStart > b.seasonStart;
|
||||
return a.name.localeAwareCompare(b.name) < 0;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
void SeasonalBuildService::ensureLoaded(Callback callback, bool forceRefresh)
|
||||
{
|
||||
if (!m_baseUrl.isValid() || m_baseUrl.host().isEmpty()) {
|
||||
m_lastError = tr("Адрес сервера сборок не задан");
|
||||
if (callback) {
|
||||
const QString error = m_lastError;
|
||||
QTimer::singleShot(0, this, [callback = std::move(callback), error] {
|
||||
callback(false, error);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_cacheRead) {
|
||||
m_cacheRead = true;
|
||||
if (readCache())
|
||||
emit buildsChanged();
|
||||
}
|
||||
|
||||
const bool fresh = hasData() && m_fetchedAt.isValid()
|
||||
&& m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
|
||||
if (!forceRefresh && fresh) {
|
||||
// Колбэк, который то синхронный, то нет, ломает вызывающий код —
|
||||
// на попадании в кэш отвечаем через событийный цикл.
|
||||
if (callback)
|
||||
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback)
|
||||
m_pending << std::move(callback);
|
||||
if (m_refreshing)
|
||||
return;
|
||||
startRefresh();
|
||||
}
|
||||
|
||||
void SeasonalBuildService::startRefresh()
|
||||
{
|
||||
m_refreshing = true;
|
||||
emit refreshingChanged();
|
||||
|
||||
QUrl url = m_baseUrl;
|
||||
url.setPath(m_baseUrl.path() + QStringLiteral("/index.json"));
|
||||
|
||||
QNetworkRequest request{url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
QNetworkReply *reply = m_network->get(request);
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
||||
reply->deleteLater();
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
finishRefresh(false,
|
||||
tr("Не удалось получить список сборок: %1").arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QString parseError;
|
||||
QList<SeasonalBuildEntry> parsed =
|
||||
parseCatalog(QJsonDocument::fromJson(reply->readAll()).object(), &parseError);
|
||||
// Пустой разбор — испорченный ответ; хороший кэш им не затираем.
|
||||
// Пустой каталог на сервере от испорченного не отличить, поэтому
|
||||
// «сборок нет» и «ответ битый» — одно и то же состояние: список
|
||||
// остаётся прежним, а причина уходит в lastError.
|
||||
if (parsed.isEmpty()) {
|
||||
finishRefresh(false,
|
||||
parseError.isEmpty() ? tr("Список сборок пуст или не разобран")
|
||||
: parseError);
|
||||
return;
|
||||
}
|
||||
|
||||
applyBuilds(std::move(parsed));
|
||||
m_fetchedAt = QDateTime::currentDateTimeUtc();
|
||||
writeCache();
|
||||
emit buildsChanged();
|
||||
finishRefresh(true, {});
|
||||
});
|
||||
}
|
||||
|
||||
void SeasonalBuildService::finishRefresh(bool ok, const QString &warning)
|
||||
{
|
||||
m_lastError = ok ? QString() : warning;
|
||||
m_refreshing = false;
|
||||
emit refreshingChanged();
|
||||
// Строка ошибки — часть того, что показывает окно, поэтому список
|
||||
// перечитывается и на неудаче.
|
||||
emit buildsChanged();
|
||||
|
||||
// Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
|
||||
const QList<Callback> pending = std::exchange(m_pending, {});
|
||||
for (const Callback &callback : pending) {
|
||||
// Сеть недоступна, но старые данные есть — работаем на них.
|
||||
if (!ok && hasData())
|
||||
callback(true, warning);
|
||||
else
|
||||
callback(ok, warning);
|
||||
}
|
||||
}
|
||||
|
||||
bool SeasonalBuildService::readCache()
|
||||
{
|
||||
QFile file(LauncherPaths::seasonalCatalogFile());
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
|
||||
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
||||
// Кэш от другого сервера бесполезен: ссылки в нём ведут не туда.
|
||||
if (root.value(QStringLiteral("baseUrl")).toString() != m_baseUrl.toString())
|
||||
return false;
|
||||
|
||||
QList<SeasonalBuildEntry> parsed = parseCatalog(root, nullptr);
|
||||
if (parsed.isEmpty())
|
||||
return false;
|
||||
|
||||
applyBuilds(std::move(parsed));
|
||||
m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
|
||||
Qt::ISODate);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SeasonalBuildService::writeCache()
|
||||
{
|
||||
// Папку кэша могли удалить уже после запуска лаунчера.
|
||||
if (!LauncherPaths::ensureRootExists())
|
||||
return;
|
||||
|
||||
QJsonArray builds;
|
||||
for (const SeasonalBuildEntry &entry : std::as_const(m_builds)) {
|
||||
builds << QJsonObject{
|
||||
{QStringLiteral("id"), entry.id},
|
||||
{QStringLiteral("name"), entry.name},
|
||||
{QStringLiteral("revision"), entry.revision},
|
||||
{QStringLiteral("minecraftVersion"), entry.minecraftVersion},
|
||||
{QStringLiteral("loader"), entry.loader},
|
||||
{QStringLiteral("loaderVersion"), entry.loaderVersion},
|
||||
{QStringLiteral("modCount"), entry.modCount},
|
||||
{QStringLiteral("seasonStart"), dateToJson(entry.seasonStart)},
|
||||
{QStringLiteral("seasonEnd"), dateToJson(entry.seasonEnd)},
|
||||
{QStringLiteral("serverUrl"), entry.serverUrl},
|
||||
{QStringLiteral("javaMajor"), entry.javaMajor},
|
||||
{QStringLiteral("description"), entry.description},
|
||||
{QStringLiteral("archive"),
|
||||
QJsonObject{{QStringLiteral("url"), entry.archiveUrl.toString()},
|
||||
{QStringLiteral("size"), double(entry.archiveSize)},
|
||||
{QStringLiteral("sha256"), entry.archiveSha256}}}};
|
||||
}
|
||||
|
||||
// Своя обёртка: в каталоге нет ни отметки свежести кэша, ни адреса, с
|
||||
// которого он приехал, — а без них кэш нечем проверить.
|
||||
const QJsonObject root{{QStringLiteral("formatVersion"), kSupportedFormatVersion},
|
||||
{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)},
|
||||
{QStringLiteral("baseUrl"), m_baseUrl.toString()},
|
||||
{QStringLiteral("builds"), builds}};
|
||||
|
||||
QSaveFile file(LauncherPaths::seasonalCatalogFile());
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return;
|
||||
}
|
||||
file.commit();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QJsonObject;
|
||||
|
||||
// Одна готовая сборка с сервера сезонных сборок.
|
||||
struct SeasonalBuildEntry
|
||||
{
|
||||
QString id; // season-5 — он же ключ, по которому сборка
|
||||
// узнаётся среди локальных записей
|
||||
QString name; // «Сезон 5: Пустоши»
|
||||
int revision = 0; // растёт при каждой публикации
|
||||
QString minecraftVersion; // 1.20.1
|
||||
QString loader; // "" | forge | fabric | neoforge | quilt
|
||||
QString loaderVersion; // 47.4.0
|
||||
int modCount = 0;
|
||||
QDate seasonStart;
|
||||
QDate seasonEnd; // невалидная — сезон ещё не закончен
|
||||
QString serverUrl; // адрес игрового сервера, не файлового
|
||||
int javaMajor = 0; // 0 — определять по версии игры
|
||||
QString description;
|
||||
|
||||
QUrl archiveUrl;
|
||||
qint64 archiveSize = 0;
|
||||
QString archiveSha256;
|
||||
|
||||
bool isValid() const { return !id.isEmpty() && revision > 0 && archiveUrl.isValid(); }
|
||||
};
|
||||
|
||||
// Каталог сезонных сборок: скачивает index.json с файлового сервера, кэширует
|
||||
// его в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устройство
|
||||
// повторяет VersionManifestService — включая то, что пустой разбор считается
|
||||
// испорченным ответом и хороший кэш им не затирается.
|
||||
class SeasonalBuildService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// ok = true и непустой warning — данные отдали из устаревшего кэша.
|
||||
using Callback = std::function<void(bool ok, const QString &warning)>;
|
||||
|
||||
explicit SeasonalBuildService(QObject *parent = nullptr);
|
||||
|
||||
QList<SeasonalBuildEntry> builds() const { return m_builds; }
|
||||
bool hasData() const { return !m_builds.isEmpty(); }
|
||||
bool isRefreshing() const { return m_refreshing; }
|
||||
// Последняя ошибка обращения к серверу; пусто — всё в порядке.
|
||||
QString lastError() const { return m_lastError; }
|
||||
|
||||
std::optional<SeasonalBuildEntry> find(const QString &id) const;
|
||||
|
||||
// Адрес сервера сборок. Меняется из настроек, поэтому при смене хоста
|
||||
// накопленные данные и кэш сбрасываются: ссылки в них указывают на старый.
|
||||
void setBaseUrl(const QUrl &baseUrl);
|
||||
QUrl baseUrl() const { return m_baseUrl; }
|
||||
|
||||
void ensureLoaded(Callback callback, bool forceRefresh = false);
|
||||
|
||||
signals:
|
||||
void buildsChanged();
|
||||
void refreshingChanged();
|
||||
|
||||
private:
|
||||
bool readCache();
|
||||
void writeCache();
|
||||
void startRefresh();
|
||||
void finishRefresh(bool ok, const QString &warning);
|
||||
void applyBuilds(QList<SeasonalBuildEntry> list);
|
||||
QList<SeasonalBuildEntry> parseCatalog(const QJsonObject &root, QString *error) const;
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
QUrl m_baseUrl;
|
||||
QList<SeasonalBuildEntry> m_builds;
|
||||
QHash<QString, int> m_indexById;
|
||||
QDateTime m_fetchedAt;
|
||||
QString m_lastError;
|
||||
bool m_refreshing = false;
|
||||
bool m_cacheRead = false;
|
||||
QList<Callback> m_pending;
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
#include "seasonalpackdownloader.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
|
||||
namespace {
|
||||
|
||||
// Пак большой, но идёт с нашего же сервера: минуты простоя без единого байта
|
||||
// означают, что соединение умерло, а не что файл тяжёлый.
|
||||
constexpr int kTransferTimeoutMs = 60000;
|
||||
|
||||
// Столько же, сколько у остальных установщиков: сигналы прогресса приходят
|
||||
// пачками, а перерисовывать панель чаще десяти раз в секунду незачем.
|
||||
constexpr int kProgressIntervalMs = 100;
|
||||
|
||||
} // namespace
|
||||
|
||||
SeasonalPackDownloader::SeasonalPackDownloader(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
, m_progressTimer(new QTimer(this))
|
||||
{
|
||||
m_progressTimer->setInterval(kProgressIntervalMs);
|
||||
connect(m_progressTimer, &QTimer::timeout, this, &SeasonalPackDownloader::flushProgress);
|
||||
}
|
||||
|
||||
SeasonalPackDownloader::~SeasonalPackDownloader()
|
||||
{
|
||||
if (m_reply)
|
||||
m_reply->abort();
|
||||
}
|
||||
|
||||
double SeasonalPackDownloader::fraction() const
|
||||
{
|
||||
if (m_bytesTotal <= 0)
|
||||
return -1.0;
|
||||
return qBound(0.0, double(m_bytesDone) / double(m_bytesTotal), 1.0);
|
||||
}
|
||||
|
||||
void SeasonalPackDownloader::download(const QUrl &url,
|
||||
const QString &targetPath,
|
||||
const QString &sha256,
|
||||
qint64 expectedSize,
|
||||
const QString &label)
|
||||
{
|
||||
if (m_running) {
|
||||
emit failed(label, tr("Загрузка сборки уже идёт"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_running = true;
|
||||
m_canceling = false;
|
||||
m_targetPath = targetPath;
|
||||
m_sha256 = sha256;
|
||||
m_label = label;
|
||||
m_bytesDone = 0;
|
||||
m_bytesTotal = expectedSize;
|
||||
m_stage = tr("Загрузка файлов сборки");
|
||||
m_currentFile = QFileInfo(targetPath).fileName();
|
||||
|
||||
if (!QDir().mkpath(QFileInfo(targetPath).absolutePath())) {
|
||||
fail(tr("Не удалось создать %1")
|
||||
.arg(QDir::toNativeSeparators(QFileInfo(targetPath).absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
m_file = std::make_unique<QSaveFile>(targetPath);
|
||||
if (!m_file->open(QIODevice::WriteOnly)) {
|
||||
fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(targetPath)));
|
||||
return;
|
||||
}
|
||||
m_hash.reset();
|
||||
|
||||
emit started(m_label);
|
||||
|
||||
QNetworkRequest request{url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
m_reply = m_network->get(request);
|
||||
connect(m_reply, &QNetworkReply::readyRead, this, [this] {
|
||||
const QByteArray chunk = m_reply->readAll();
|
||||
if (chunk.isEmpty())
|
||||
return;
|
||||
m_hash.addData(chunk);
|
||||
// Диск кончился или файл забрали из-под нас: продолжать качать незачем,
|
||||
// ошибку разберёт обработчик finished.
|
||||
if (m_file->write(chunk) != chunk.size())
|
||||
m_reply->abort();
|
||||
});
|
||||
connect(m_reply, &QNetworkReply::downloadProgress, this,
|
||||
[this](qint64 received, qint64 total) {
|
||||
m_bytesDone = received;
|
||||
// Размер из каталога может разойтись с реальным — верим серверу.
|
||||
if (total > 0)
|
||||
m_bytesTotal = total;
|
||||
scheduleProgress();
|
||||
});
|
||||
connect(m_reply, &QNetworkReply::finished, this, [this] {
|
||||
QNetworkReply *reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
|
||||
if (m_canceling) {
|
||||
m_file->cancelWriting();
|
||||
m_file.reset();
|
||||
const QString label = m_label;
|
||||
reset();
|
||||
emit canceled(label);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
m_file->cancelWriting();
|
||||
m_file.reset();
|
||||
fail(tr("Не удалось скачать сборку: %1").arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString actual = QString::fromLatin1(m_hash.result().toHex());
|
||||
if (actual.compare(m_sha256, Qt::CaseInsensitive) != 0) {
|
||||
m_file->cancelWriting();
|
||||
m_file.reset();
|
||||
fail(tr("Контрольная сумма архива сборки не совпала — загрузка повреждена"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_file->commit()) {
|
||||
m_file.reset();
|
||||
fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(m_targetPath)));
|
||||
return;
|
||||
}
|
||||
m_file.reset();
|
||||
|
||||
const QString path = m_targetPath;
|
||||
reset();
|
||||
emit finished(path);
|
||||
});
|
||||
|
||||
scheduleProgress();
|
||||
}
|
||||
|
||||
void SeasonalPackDownloader::cancel()
|
||||
{
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
|
||||
m_canceling = true;
|
||||
if (m_reply) {
|
||||
m_reply->abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// Запрос ещё не создан — обрывать нечего, отвечаем сразу.
|
||||
if (m_file)
|
||||
m_file->cancelWriting();
|
||||
m_file.reset();
|
||||
const QString label = m_label;
|
||||
reset();
|
||||
emit canceled(label);
|
||||
}
|
||||
|
||||
void SeasonalPackDownloader::fail(const QString &message)
|
||||
{
|
||||
const QString label = m_label;
|
||||
if (m_file)
|
||||
m_file->cancelWriting();
|
||||
m_file.reset();
|
||||
// Обрезанный пак нельзя ни распаковать, ни докачать: следующая попытка
|
||||
// начнётся с чистого файла.
|
||||
if (!m_targetPath.isEmpty())
|
||||
QFile::remove(m_targetPath);
|
||||
reset();
|
||||
emit failed(label, message);
|
||||
}
|
||||
|
||||
void SeasonalPackDownloader::reset()
|
||||
{
|
||||
m_running = false;
|
||||
m_canceling = false;
|
||||
m_progressTimer->stop();
|
||||
m_progressDirty = false;
|
||||
m_file.reset();
|
||||
m_targetPath.clear();
|
||||
m_sha256.clear();
|
||||
m_label.clear();
|
||||
m_stage.clear();
|
||||
m_currentFile.clear();
|
||||
m_bytesDone = 0;
|
||||
m_bytesTotal = 0;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void SeasonalPackDownloader::scheduleProgress()
|
||||
{
|
||||
m_progressDirty = true;
|
||||
if (!m_progressTimer->isActive())
|
||||
m_progressTimer->start();
|
||||
}
|
||||
|
||||
void SeasonalPackDownloader::flushProgress()
|
||||
{
|
||||
if (!m_progressDirty) {
|
||||
m_progressTimer->stop();
|
||||
return;
|
||||
}
|
||||
m_progressDirty = false;
|
||||
emit progressChanged();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
#include <memory>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
class QSaveFile;
|
||||
class QTimer;
|
||||
|
||||
// Загрузка одного архива сезонной сборки в файл. Пишем потоком и сверяем
|
||||
// sha256: пак — это сотни мегабайт, держать их в памяти незачем, а
|
||||
// распаковывать битую загрузку поверх рабочей .minecraft нельзя.
|
||||
//
|
||||
// Распаковкой занимается BuildSwitcher: она идёт в отдельном потоке вместе с
|
||||
// остальными операциями над содержимым .minecraft.
|
||||
//
|
||||
// Набор геттеров повторяет VersionInstaller и JavaInstaller: панель загрузки в
|
||||
// интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
|
||||
class SeasonalPackDownloader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SeasonalPackDownloader(QObject *parent = nullptr);
|
||||
~SeasonalPackDownloader() override;
|
||||
|
||||
bool isRunning() const { return m_running; }
|
||||
QString label() const { return m_label; }
|
||||
QString stage() const { return m_stage; }
|
||||
QString currentFile() const { return m_currentFile; }
|
||||
|
||||
qint64 bytesDone() const { return m_bytesDone; }
|
||||
qint64 bytesTotal() const { return m_bytesTotal; }
|
||||
double fraction() const; // 0..1, либо -1 пока итог неизвестен
|
||||
|
||||
// targetPath перезаписывается: недокачанный пак с прошлой попытки не
|
||||
// должен пережить новую.
|
||||
void download(const QUrl &url,
|
||||
const QString &targetPath,
|
||||
const QString &sha256,
|
||||
qint64 expectedSize,
|
||||
const QString &label);
|
||||
void cancel();
|
||||
|
||||
signals:
|
||||
void started(const QString &label);
|
||||
void progressChanged();
|
||||
void finished(const QString &path);
|
||||
void failed(const QString &label, const QString &message);
|
||||
void canceled(const QString &label);
|
||||
|
||||
private:
|
||||
void fail(const QString &message);
|
||||
void reset();
|
||||
void scheduleProgress();
|
||||
void flushProgress();
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
QNetworkReply *m_reply = nullptr;
|
||||
std::unique_ptr<QSaveFile> m_file;
|
||||
QCryptographicHash m_hash{QCryptographicHash::Sha256};
|
||||
|
||||
QString m_targetPath;
|
||||
QString m_sha256;
|
||||
QString m_label;
|
||||
QString m_stage;
|
||||
QString m_currentFile;
|
||||
|
||||
qint64 m_bytesDone = 0;
|
||||
qint64 m_bytesTotal = 0;
|
||||
|
||||
QTimer *m_progressTimer;
|
||||
bool m_progressDirty = false;
|
||||
bool m_running = false;
|
||||
bool m_canceling = false;
|
||||
};
|
||||
Reference in New Issue
Block a user