seasonal builds

This commit is contained in:
2026-08-31 00:59:14 +03:00
parent 4689e59b5a
commit c25e071b29
17 changed files with 2312 additions and 18 deletions
+5
View File
@@ -50,6 +50,10 @@ qt_add_qml_module(appMinecraft_launcher
buildarchiveworker.cpp buildarchiveworker.cpp
buildswitcher.h buildswitcher.h
buildswitcher.cpp buildswitcher.cpp
seasonalbuildservice.h
seasonalbuildservice.cpp
seasonalpackdownloader.h
seasonalpackdownloader.cpp
QML_FILES QML_FILES
Main.qml Main.qml
DarkCombo.qml DarkCombo.qml
@@ -57,6 +61,7 @@ qt_add_qml_module(appMinecraft_launcher
JavaPickerDialog.qml JavaPickerDialog.qml
LabelledField.qml LabelledField.qml
BuildsDialog.qml BuildsDialog.qml
SeasonalBuildsDialog.qml
LoaderRow.qml LoaderRow.qml
ProgressPanel.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 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
+46
View File
@@ -32,6 +32,10 @@ Window {
crashed || exitCode !== 0 ? "#cc3333" : "#555555") crashed || exitCode !== 0 ? "#cc3333" : "#555555")
} }
onGameOutput: (line) => console.log(line) onGameOutput: (line) => console.log(line)
onSeasonalInstallFinished: (seasonalId, buildName) => {
window.showToast(qsTr("Сборка «%1» установлена — можно запускать").arg(buildName),
"#4b7a1f", 8000)
}
} }
// Единая всплывающая плашка: сообщения о ходе запуска, ошибки, статус игры. // Единая всплывающая плашка: сообщения о ходе запуска, ошибки, статус игры.
@@ -451,6 +455,48 @@ Window {
onClicked: settingsDialog.load() 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 ───────────────────────────────────────────────── // ── Add Profile Dialog ─────────────────────────────────────────────────
// Bug fix: header/footer must be Item (not Rectangle) with explicit // Bug fix: header/footer must be Item (not Rectangle) with explicit
// implicitHeight so Dialog correctly computes its own total height. // implicitHeight so Dialog correctly computes its own total height.
+339
View File
@@ -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()
}
}
}
}
+152
View File
@@ -5,6 +5,8 @@
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <algorithm>
#include <QtCore/private/qzipreader_p.h> #include <QtCore/private/qzipreader_p.h>
#include <QtCore/private/qzipwriter_p.h> #include <QtCore/private/qzipwriter_p.h>
@@ -25,6 +27,40 @@ bool isSafeRelativePath(const QString &path)
return true; 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. // Файлы, которые попадут в архив, — относительными путями от gameDir.
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop) QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
{ {
@@ -218,3 +254,119 @@ void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
emit finished(true, {}); 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, {});
}
+16
View File
@@ -34,9 +34,25 @@ public slots:
// Распаковывает архив в gameDir. // Распаковывает архив в gameDir.
void restore(const QString &zipPath, const QString &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: signals:
void progress(int done, int total, const QString &currentPath); void progress(int done, int total, const QString &currentPath);
void finished(bool ok, const QString &error); void finished(bool ok, const QString &error);
// Что именно принёс пак — относительными путями. Пригодится следующему
// обновлению, чтобы вычислить, какие файлы из сборки ушли.
void packEntries(const QStringList &entries);
private: private:
bool canceled() const { return m_cancel.loadRelaxed() != 0; } bool canceled() const { return m_cancel.loadRelaxed() != 0; }
+188 -6
View File
@@ -8,6 +8,7 @@
#include <QDirIterator> #include <QDirIterator>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include <QStorageInfo> #include <QStorageInfo>
@@ -18,6 +19,7 @@ namespace {
const QString kIndexFile = QStringLiteral("index.json"); const QString kIndexFile = QStringLiteral("index.json");
const QString kStateSwitching = QStringLiteral("switching"); const QString kStateSwitching = QStringLiteral("switching");
const QString kStateApplying = QStringLiteral("applying");
const QString kStateIdle = QStringLiteral("idle"); const QString kStateIdle = QStringLiteral("idle");
// Запас на архив: точный размер заранее неизвестен, но упасть на середине // Запас на архив: точный размер заранее неизвестен, но упасть на середине
@@ -89,6 +91,8 @@ BuildSwitcher::BuildSwitcher(QObject *parent)
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished); connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress); connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
connect(m_worker, &BuildArchiveWorker::packEntries, this,
[this](const QStringList &entries) { m_lastPackEntries = entries; });
m_thread->start(); m_thread->start();
sweepTempArchives(); sweepTempArchives();
@@ -110,7 +114,7 @@ double BuildSwitcher::fraction() const
QString BuildSwitcher::buildDir(int buildId) 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 QString BuildSwitcher::archivePathOf(int buildId) const
{ {
const QDir dir(buildDir(buildId)); const QDir dir(buildDir(buildId));
// Рядом лежат служебные .tmp-*.zip и .pack-*.zip. На Unix они скрытые и в
// выборку не попадают, на Windows — попадают, поэтому отсекаем по имени.
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time); 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) 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)); 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 &currentPath) void BuildSwitcher::onWorkerProgress(int done, int total, const QString &currentPath)
{ {
m_done = done; m_done = done;
@@ -254,6 +273,17 @@ void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
} }
finish(); finish();
return; 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 QDir dir(buildDir(m_fromId));
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files); const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
for (const QString &name : zips) { 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)); QFile::remove(dir.absoluteFilePath(name));
} }
@@ -323,6 +355,98 @@ void BuildSwitcher::beginRestoring()
requestRestore(archive, m_gameDir); requestRestore(archive, m_gameDir);
} }
// ── Раскатка пака сезонной сборки ──────────────────────────────────────────
void BuildSwitcher::applyPack(int buildId,
const QString &buildName,
const QString &packZipPath,
const QStringList &removeRelative,
const QJsonObject &note,
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() void BuildSwitcher::finish()
{ {
QJsonObject index = readIndex(); QJsonObject index = readIndex();
@@ -379,7 +503,14 @@ bool BuildSwitcher::forgetBuild(int buildId)
QString BuildSwitcher::interruptedSwitchWarning() const QString BuildSwitcher::interruptedSwitchWarning() const
{ {
const QJsonObject index = readIndex(); 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 {}; return {};
const int toId = index.value(QStringLiteral("to")).toInt(-1); const int toId = index.value(QStringLiteral("to")).toInt(-1);
@@ -395,7 +526,13 @@ QString BuildSwitcher::interruptedSwitchWarning() const
void BuildSwitcher::resumeInterrupted(const QString &gameDir) void BuildSwitcher::resumeInterrupted(const QString &gameDir)
{ {
const QJsonObject index = readIndex(); 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; return;
const int toId = index.value(QStringLiteral("to")).toInt(-1); const int toId = index.value(QStringLiteral("to")).toInt(-1);
@@ -419,6 +556,50 @@ void BuildSwitcher::resumeInterrupted(const QString &gameDir)
beginClearing(); 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 QJsonObject BuildSwitcher::readIndex() const
@@ -445,7 +626,8 @@ void BuildSwitcher::writeIndex(const QJsonObject &index) const
} }
// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может // Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может
// занимать гигабайты. // занимать гигабайты. Пакеты .pack-*.zip не трогаем: по ним доигрывается
// прерванная раскатка сезонной сборки, а лишний тут удаляет resumeApplying.
void BuildSwitcher::sweepTempArchives() const void BuildSwitcher::sweepTempArchives() const
{ {
QDir root(LauncherPaths::buildStorageDir()); QDir root(LauncherPaths::buildStorageDir());
+35 -1
View File
@@ -37,6 +37,26 @@ public:
int toBuildId, int toBuildId,
const QString &toName, const QString &toName,
const QString &gameDir); const QString &gameDir);
// Докатывает пак сезонной сборки поверх содержимого .minecraft. Вызывается
// только для уже активной сборки: пак ложится на то, что сейчас разложено,
// а не внутрь чужого архива.
//
// note — непрозрачные данные вызывающей стороны: они переживают перезапуск
// вместе с отметкой о незавершённой операции и возвращаются через
// lastPackNote(), когда раскатка доиграна. Сам переключатель в них не
// заглядывает.
void applyPack(int buildId,
const QString &buildName,
const QString &packZipPath,
const QStringList &removeRelative,
const QJsonObject &note,
const QString &gameDir);
// Что принёс последний успешно раскатанный пак и с чем он пришёл.
QStringList lastPackEntries() const { return m_lastPackEntries; }
QJsonObject lastPackNote() const { return m_lastPackNote; }
void cancel(); void cancel();
// Сборку удалили: убираем её архив и запись о нём. Возвращает false, если // Сборку удалили: убираем её архив и запись о нём. Возвращает false, если
@@ -58,17 +78,23 @@ signals:
void failed(int toBuildId, const QString &message, bool gameDirIntact); void failed(int toBuildId, const QString &message, bool gameDirIntact);
private: 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 requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude);
void requestClear(const QString &gameDir, const QStringList &keep); void requestClear(const QString &gameDir, const QStringList &keep);
void requestRestore(const QString &zipPath, const QString &gameDir); 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 onWorkerFinished(bool ok, const QString &error);
void onWorkerProgress(int done, int total, const QString &currentPath); void onWorkerProgress(int done, int total, const QString &currentPath);
void commitArchive(); void commitArchive();
void beginApplying();
void finishApplying();
void resumeApplying(const QJsonObject &index, const QString &gameDir);
void beginClearing(); void beginClearing();
void beginRestoring(); void beginRestoring();
void finish(); void finish();
@@ -97,4 +123,12 @@ private:
QString m_toName; QString m_toName;
QString m_gameDir; QString m_gameDir;
QString m_tempZip; QString m_tempZip;
// Раскатка пака: путь к архиву, список уходящих файлов и данные вызывающей
// стороны, которые надо вернуть по завершении.
QString m_packPath;
QStringList m_packRemove;
QJsonObject m_packNote;
QStringList m_lastPackEntries;
QJsonObject m_lastPackNote;
}; };
+54
View File
@@ -132,6 +132,60 @@ QList<JavaRuntimeEntry> JavaRuntimeService::entries(JavaRuntimeKind kind) const
return result; 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 std::optional<JavaRuntimeEntry> JavaRuntimeService::find(const QString &id) const
{ {
const auto it = m_indexById.constFind(id); const auto it = m_indexById.constFind(id);
+6
View File
@@ -43,6 +43,12 @@ public:
QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const; QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const;
std::optional<JavaRuntimeEntry> find(const QString &id) 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 isRefreshing() const { return m_outstanding > 0; }
bool hasData() const { return !m_entries.isEmpty(); } bool hasData() const { return !m_entries.isEmpty(); }
+612 -11
View File
@@ -12,7 +12,11 @@
#include "modloaderinstaller.h" #include "modloaderinstaller.h"
#include "modloaderversionservice.h" #include "modloaderversionservice.h"
#include "buildswitcher.h" #include "buildswitcher.h"
#include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h"
#include <QDate>
#include <QDateTime>
#include <QDesktopServices> #include <QDesktopServices>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
@@ -21,6 +25,7 @@
#include <QJsonObject> #include <QJsonObject>
#include <QHash> #include <QHash>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSaveFile>
#include <QSet> #include <QSet>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
@@ -31,6 +36,17 @@ namespace {
const QString kOffline = QStringLiteral("offline"); const QString kOffline = QStringLiteral("offline");
const QString kElyBy = QStringLiteral("elyby"); 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) int settingInt(const QVariantMap &settings, const QString &key, int fallback)
{ {
const QVariant value = settings.value(key); const QVariant value = settings.value(key);
@@ -52,6 +68,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
, m_switcher(new BuildSwitcher(this)) , m_switcher(new BuildSwitcher(this))
, m_javaMeta(new JavaRuntimeService(this)) , m_javaMeta(new JavaRuntimeService(this))
, m_javaInstaller(new JavaInstaller(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 javaRuntimeInstalled(runtimeId);
emit launchProgress(tr("Java установлена: %1") emit launchProgress(tr("Java установлена: %1")
.arg(QDir::toNativeSeparators(javaPath))); .arg(QDir::toNativeSeparators(javaPath)));
if (m_season.stage == SeasonStage::Java)
seasonAdvance();
}); });
connect(m_javaInstaller, &JavaInstaller::failed, this, connect(m_javaInstaller, &JavaInstaller::failed, this,
[this](const QString &label, const QString &message) { [this](const QString &label, const QString &message) {
@@ -120,12 +140,16 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged(); emit downloadChanged();
emit busyChanged(); emit busyChanged();
emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message)); 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) { connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
invalidateJavaCatalog(); invalidateJavaCatalog();
emit downloadChanged(); emit downloadChanged();
emit busyChanged(); emit busyChanged();
emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label)); emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label));
if (m_season.stage == SeasonStage::Java)
seasonFail({});
}); });
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) { connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
@@ -143,6 +167,11 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged(); emit downloadChanged();
emit busyChanged(); emit busyChanged();
emit launchProgress(tr("Версия «%1» загружена").arg(id)); 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, connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
const QString &message) { const QString &message) {
@@ -152,6 +181,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged(); emit downloadChanged();
emit busyChanged(); emit busyChanged();
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message)); 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] { connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
emit switchChanged(); emit switchChanged();
@@ -170,6 +201,17 @@ LauncherBackend::LauncherBackend(QObject *parent)
const int index = indexOfBuildId(toBuildId); const int index = indexOfBuildId(toBuildId);
emit launchProgress(tr("Активна сборка «%1»") emit launchProgress(tr("Активна сборка «%1»")
.arg(index >= 0 ? m_builds[index].name : QString())); .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, connect(m_switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) { [this](int, const QString &message, bool gameDirIntact) {
@@ -177,11 +219,27 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit installedVersionsChanged(); emit installedVersionsChanged();
emit switchChanged(); emit switchChanged();
emit busyChanged(); emit busyChanged();
emit launchError(gameDirIntact // Раскатка пака и смена сборки приходят одним сигналом, а
? tr("Сборка не переключена: %1").arg(message) // сказать пользователю надо разное: после сорванного
: tr("Сборка не переключена: %1\nСодержимое .minecraft " // переключения .minecraft лежит разобранной, а сорванная
"осталось разобранным — повторите переключение.") // раскатка означает лишь наполовину обновлённую сборку.
.arg(message)); 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, connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
@@ -216,6 +274,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged(); emit downloadChanged();
emit launchProgress(tr("Готово: %1 для Minecraft %2") emit launchProgress(tr("Готово: %1 для Minecraft %2")
.arg(producedVersionId, gameVersion)); .arg(producedVersionId, gameVersion));
if (m_season.stage == SeasonStage::Loader)
seasonAdvance();
}); });
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this, connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &label, const QString &message) { [this](const QString &label, const QString &message) {
@@ -225,6 +285,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit busyChanged(); emit busyChanged();
emit downloadChanged(); emit downloadChanged();
emit launchError(tr("%1: %2").arg(label, message)); 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) { connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
m_loaderInstallBuildId = -1; m_loaderInstallBuildId = -1;
@@ -233,6 +295,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit busyChanged(); emit busyChanged();
emit downloadChanged(); emit downloadChanged();
emit launchProgress(tr("Установка %1 отменена").arg(label)); emit launchProgress(tr("Установка %1 отменена").arg(label));
if (m_season.stage == SeasonStage::Loader)
seasonFail({});
}); });
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) { connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
@@ -241,6 +305,49 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged(); emit downloadChanged();
emit busyChanged(); emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id)); 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("loader", build.loader);
take("loaderVersion", build.loaderVersion); take("loaderVersion", build.loaderVersion);
take("resolvedVersionId", build.resolvedVersionId); take("resolvedVersionId", build.resolvedVersionId);
take("seasonalId", build.seasonalId);
if (fields.contains(QStringLiteral("seasonalRevision")))
build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt();
if (build.minecraftVersion != previousMinecraft if (build.minecraftVersion != previousMinecraft
&& !fields.contains(QStringLiteral("loaderVersion"))) { && !fields.contains(QStringLiteral("loaderVersion"))) {
@@ -399,6 +509,8 @@ QVariantMap LauncherBackend::customBuildAt(int index) const
{"loader", b.loader}, {"loader", b.loader},
{"loaderVersion", b.loaderVersion}, {"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId}, {"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision},
{"launchVersionId", launchVersionIdOf(b)}}; {"launchVersionId", launchVersionIdOf(b)}};
} }
@@ -752,8 +864,10 @@ void LauncherBackend::cancelDownload()
m_loaderInstaller->cancel(); m_loaderInstaller->cancel();
else if (m_installer->isRunning()) else if (m_installer->isRunning())
m_installer->cancel(); m_installer->cancel();
else else if (m_javaInstaller->isRunning())
m_javaInstaller->cancel(); m_javaInstaller->cancel();
else
m_packDownloader->cancel();
} }
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она // Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
@@ -762,7 +876,7 @@ void LauncherBackend::cancelDownload()
bool LauncherBackend::downloading() const bool LauncherBackend::downloading() const
{ {
return m_loaderInstaller->isRunning() || m_installer->isRunning() return m_loaderInstaller->isRunning() || m_installer->isRunning()
|| m_javaInstaller->isRunning(); || m_javaInstaller->isRunning() || m_packDownloader->isRunning();
} }
// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок. // Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
@@ -772,8 +886,18 @@ bool LauncherBackend::javaDownloadShown() const
&& !m_loaderInstaller->isRunning(); && !m_loaderInstaller->isRunning();
} }
// Пак качается отдельным шагом цепочки, когда остальные установщики уже
// отработали, — за панель они не спорят.
bool LauncherBackend::packDownloadShown() const
{
return m_packDownloader->isRunning() && !m_installer->isRunning()
&& !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning();
}
double LauncherBackend::downloadProgress() const double LauncherBackend::downloadProgress() const
{ {
if (packDownloadShown())
return m_packDownloader->fraction();
if (javaDownloadShown()) if (javaDownloadShown())
return m_javaInstaller->fraction(); return m_javaInstaller->fraction();
if (!m_loaderInstaller->isRunning()) if (!m_loaderInstaller->isRunning())
@@ -784,6 +908,8 @@ double LauncherBackend::downloadProgress() const
QString LauncherBackend::downloadVersion() const QString LauncherBackend::downloadVersion() const
{ {
if (packDownloadShown())
return m_packDownloader->label();
if (javaDownloadShown()) if (javaDownloadShown())
return m_javaInstaller->label(); return m_javaInstaller->label();
if (m_loaderInstaller->isRunning()) if (m_loaderInstaller->isRunning())
@@ -795,7 +921,10 @@ QString LauncherBackend::downloadStatus() const
{ {
QString stage = m_installer->stage(); QString stage = m_installer->stage();
QString file = m_installer->currentFile(); QString file = m_installer->currentFile();
if (javaDownloadShown()) { if (packDownloadShown()) {
stage = m_packDownloader->stage();
file = m_packDownloader->currentFile();
} else if (javaDownloadShown()) {
stage = m_javaInstaller->stage(); stage = m_javaInstaller->stage();
file = m_javaInstaller->currentFile(); file = m_javaInstaller->currentFile();
} else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) { } else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
@@ -811,6 +940,8 @@ QString LauncherBackend::downloadStatus() const
qint64 LauncherBackend::downloadBytesDone() const qint64 LauncherBackend::downloadBytesDone() const
{ {
if (packDownloadShown())
return m_packDownloader->bytesDone();
if (javaDownloadShown()) if (javaDownloadShown())
return m_javaInstaller->bytesDone(); return m_javaInstaller->bytesDone();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
@@ -820,6 +951,8 @@ qint64 LauncherBackend::downloadBytesDone() const
qint64 LauncherBackend::downloadBytesTotal() const qint64 LauncherBackend::downloadBytesTotal() const
{ {
if (packDownloadShown())
return m_packDownloader->bytesTotal();
if (javaDownloadShown()) if (javaDownloadShown())
return m_javaInstaller->bytesTotal(); return m_javaInstaller->bytesTotal();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
@@ -1217,7 +1350,8 @@ QString LauncherBackend::switchStatus() const
bool LauncherBackend::busy() const bool LauncherBackend::busy() const
{ {
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning() 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) void LauncherBackend::setBusy(bool busy)
@@ -1473,6 +1607,466 @@ QString LauncherBackend::managedJavaPath() const
return runtime ? runtime->javaPath : QString(); 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 &note,
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() void LauncherBackend::openMinecraftFolder()
{ {
const QString modsDir = gameDir() + QStringLiteral("/mods"); const QString modsDir = gameDir() + QStringLiteral("/mods");
@@ -1624,6 +2218,8 @@ void LauncherBackend::loadData()
build.loader = o.value("loader").toString(); build.loader = o.value("loader").toString();
build.loaderVersion = o.value("loaderVersion").toString(); build.loaderVersion = o.value("loaderVersion").toString();
build.resolvedVersionId = o.value("resolvedVersionId").toString(); build.resolvedVersionId = o.value("resolvedVersionId").toString();
build.seasonalId = o.value("seasonalId").toString();
build.seasonalRevision = o.value("seasonalRevision").toInt();
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт // Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
// пустую строку. // пустую строку.
@@ -1727,7 +2323,9 @@ void LauncherBackend::saveCustomBuilds()
{"minecraftVersion", b.minecraftVersion}, {"minecraftVersion", b.minecraftVersion},
{"loader", b.loader}, {"loader", b.loader},
{"loaderVersion", b.loaderVersion}, {"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId}}); {"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision}});
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr)); writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
} }
@@ -1746,7 +2344,10 @@ void LauncherBackend::loadSettings()
{QStringLiteral("fullscreen"), false}, {QStringLiteral("fullscreen"), false},
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")}, {QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0}}; {QStringLiteral("activeBuildId"), 0},
// Сервер готовых сборок. В интерфейс не вынесен: менять его
// приходится только при отладке против локального сервера.
{QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}};
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile()); const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it) for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
+79
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "minecraftversion.h" #include "minecraftversion.h"
#include "seasonalbuildservice.h"
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
@@ -20,6 +21,8 @@ class ModLoaderInstaller;
class BuildSwitcher; class BuildSwitcher;
class JavaRuntimeService; class JavaRuntimeService;
class JavaInstaller; class JavaInstaller;
class SeasonalBuildService;
class SeasonalPackDownloader;
class LauncherBackend : public QObject class LauncherBackend : public QObject
{ {
@@ -55,6 +58,15 @@ class LauncherBackend : public QObject
Q_PROPERTY(QVariantList javaCatalog READ javaCatalog NOTIFY javaCatalogChanged) Q_PROPERTY(QVariantList javaCatalog READ javaCatalog NOTIFY javaCatalogChanged)
Q_PROPERTY(bool javaCatalogLoading READ javaCatalogLoading NOTIFY javaCatalogLoadingChanged) 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 всё равно перечитывает их разом. // ограничивает частоту, а QML всё равно перечитывает их разом.
Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged) Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged)
@@ -88,6 +100,11 @@ public:
QVariantList javaCatalog() const; QVariantList javaCatalog() const;
bool javaCatalogLoading() const; bool javaCatalogLoading() const;
QVariantList seasonalCatalog() const;
bool seasonalCatalogLoading() const;
QString seasonalCatalogError() const;
bool seasonalInstalling() const;
bool downloading() const; bool downloading() const;
double downloadProgress() const; double downloadProgress() const;
QString downloadVersion() const; QString downloadVersion() const;
@@ -168,6 +185,13 @@ public:
// Минимальная Java для версии игры выбранной сборки; 0 — версия не выбрана. // Минимальная Java для версии игры выбранной сборки; 0 — версия не выбрана.
Q_INVOKABLE int requiredJavaMajor(int buildIndex) const; 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 openMinecraftFolder();
Q_INVOKABLE void openGameFolder(); Q_INVOKABLE void openGameFolder();
@@ -185,8 +209,14 @@ signals:
void catalogLoadingChanged(); void catalogLoadingChanged();
void javaCatalogChanged(); void javaCatalogChanged();
void javaCatalogLoadingChanged(); void javaCatalogLoadingChanged();
void seasonalCatalogChanged();
void seasonalCatalogLoadingChanged();
void seasonalInstallingChanged();
void downloadChanged(); void downloadChanged();
// Сезонная сборка установлена и активна — можно запускать игру.
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь. // Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
void javaRuntimeInstalled(const QString &runtimeId); void javaRuntimeInstalled(const QString &runtimeId);
@@ -215,6 +245,24 @@ private:
QString loader; // "" | forge | fabric | neoforge | quilt QString loader; // "" | forge | fabric | neoforge | quilt
QString loaderVersion; // 47.4.0 | 0.19.3 QString loaderVersion; // 47.4.0 | 0.19.3
QString resolvedVersionId; // versions/<id>, который реально запускается 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(); void loadData();
@@ -246,6 +294,30 @@ private:
void rebuildCatalog() const; void rebuildCatalog() const;
void invalidateJavaCatalog(); void invalidateJavaCatalog();
void rebuildJavaCatalog() const; 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 &note, const QStringList &entries);
QUrl seasonalBaseUrl() const;
// Панель загрузки занята установкой Java, а не версии или модлоадера. // Панель загрузки занята установкой Java, а не версии или модлоадера.
bool javaDownloadShown() const; bool javaDownloadShown() const;
// Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана // Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана
@@ -277,6 +349,8 @@ private:
BuildSwitcher *m_switcher; BuildSwitcher *m_switcher;
JavaRuntimeService *m_javaMeta; JavaRuntimeService *m_javaMeta;
JavaInstaller *m_javaInstaller; JavaInstaller *m_javaInstaller;
SeasonalBuildService *m_seasonalMeta;
SeasonalPackDownloader *m_packDownloader;
// Сборка, для которой сейчас ставится модлоадер: по её id находим запись, // Сборка, для которой сейчас ставится модлоадер: по её id находим запись,
// когда установщик отчитается. // когда установщик отчитается.
@@ -290,6 +364,11 @@ private:
mutable QVariantList m_javaCatalog; mutable QVariantList m_javaCatalog;
mutable bool m_javaCatalogDirty = true; mutable bool m_javaCatalogDirty = true;
mutable QVariantList m_seasonalCatalog;
mutable bool m_seasonalCatalogDirty = true;
SeasonalInstall m_season;
bool m_busy = false; bool m_busy = false;
// Состояние запуска, начатого до асинхронной авторизации. // Состояние запуска, начатого до асинхронной авторизации.
+15
View File
@@ -66,6 +66,16 @@ QString LauncherPaths::buildStorageDir()
return rootDir() + QStringLiteral("/builds"); 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() QString LauncherPaths::runtimeDir()
{ {
return rootDir() + QStringLiteral("/runtime"); return rootDir() + QStringLiteral("/runtime");
@@ -96,6 +106,11 @@ QString LauncherPaths::versionManifestFile()
return cacheDir() + QStringLiteral("/version_manifest.json"); return cacheDir() + QStringLiteral("/version_manifest.json");
} }
QString LauncherPaths::seasonalCatalogFile()
{
return cacheDir() + QStringLiteral("/seasonal_builds.json");
}
QString LauncherPaths::loaderCacheFile(const QString &loaderKey) QString LauncherPaths::loaderCacheFile(const QString &loaderKey)
{ {
return cacheDir() + QStringLiteral("/loader_") + loaderKey + QStringLiteral(".json"); return cacheDir() + QStringLiteral("/loader_") + loaderKey + QStringLiteral(".json");
+12
View File
@@ -28,6 +28,15 @@ QString legacyCustomBuildsFile();
// <root>/builds — архивы содержимого .minecraft по одному на сборку. // <root>/builds — архивы содержимого .minecraft по одному на сборку.
QString buildStorageDir(); QString buildStorageDir();
// <root>/builds/<id> — папка одной сборки: её архив и, у сезонных, скачанный
// пак с описанием установленной ревизии.
QString buildDir(int buildId);
// <root>/builds/<id>/season.json — какая ревизия сезонной сборки установлена и
// какие файлы она принесла. Список нужен, чтобы при обновлении убрать те, что
// из сборки ушли.
QString seasonalStateFile(int buildId);
// Сюда качается authlib-injector. // Сюда качается authlib-injector.
QString runtimeDir(); QString runtimeDir();
@@ -47,6 +56,9 @@ QString cacheDir();
// Слепок манифеста версий Mojang с отметкой времени. // Слепок манифеста версий Mojang с отметкой времени.
QString versionManifestFile(); QString versionManifestFile();
// Слепок каталога сезонных сборок с отметкой времени.
QString seasonalCatalogFile();
// Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt. // Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt.
QString loaderCacheFile(const QString &loaderKey); QString loaderCacheFile(const QString &loaderKey);
+365
View File
@@ -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();
}
+91
View File
@@ -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;
};
+216
View File
@@ -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();
}
+81
View File
@@ -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;
};