diff --git a/CMakeLists.txt b/CMakeLists.txt index 258d925..879947e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,10 @@ qt_add_qml_module(appMinecraft_launcher buildarchiveworker.cpp buildswitcher.h buildswitcher.cpp + seasonalbuildservice.h + seasonalbuildservice.cpp + seasonalpackdownloader.h + seasonalpackdownloader.cpp QML_FILES Main.qml DarkCombo.qml @@ -57,6 +61,7 @@ qt_add_qml_module(appMinecraft_launcher JavaPickerDialog.qml LabelledField.qml BuildsDialog.qml + SeasonalBuildsDialog.qml LoaderRow.qml ProgressPanel.qml RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg diff --git a/Main.qml b/Main.qml index fe3b191..2bc084e 100644 --- a/Main.qml +++ b/Main.qml @@ -32,6 +32,10 @@ Window { crashed || exitCode !== 0 ? "#cc3333" : "#555555") } onGameOutput: (line) => console.log(line) + onSeasonalInstallFinished: (seasonalId, buildName) => { + window.showToast(qsTr("Сборка «%1» установлена — можно запускать").arg(buildName), + "#4b7a1f", 8000) + } } // Единая всплывающая плашка: сообщения о ходе запуска, ошибки, статус игры. @@ -451,6 +455,48 @@ Window { onClicked: settingsDialog.load() } + // ── Seasonal builds button ───────────────────────────────────────────── + // Правый нижний угол — единственная свободная часть окна: панели хода работ + // висят слева, а всё остальное собрано вокруг кнопки запуска. + Button { + id: seasonsButton + width: 183 + height: 42 + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 24 + anchors.bottomMargin: 24 + hoverEnabled: true + + contentItem: Text { + text: qsTr("Сезонные сборки") + color: "#ffffff" + font.pixelSize: 14 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: seasonsButton.pressed ? "#2d2d2d" : "#232323" + radius: 6 + border.color: "#91B315" + border.width: seasonsButton.hovered ? 2 : 1 + } + + ToolTip.visible: hovered + ToolTip.text: qsTr("Готовые сборки с сервера: моды, конфиги и Java одной кнопкой") + + onClicked: seasonalDialog.openCatalog() + } + + SeasonalBuildsDialog { + id: seasonalDialog + backend: backend + x: (window.width - width) / 2 + y: (window.height - height) / 2 + } + // ── Add Profile Dialog ───────────────────────────────────────────────── // Bug fix: header/footer must be Item (not Rectangle) with explicit // implicitHeight so Dialog correctly computes its own total height. diff --git a/SeasonalBuildsDialog.qml b/SeasonalBuildsDialog.qml new file mode 100644 index 0000000..46e7016 --- /dev/null +++ b/SeasonalBuildsDialog.qml @@ -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() + } + } + } +} diff --git a/buildarchiveworker.cpp b/buildarchiveworker.cpp index d915b8f..d7e1c93 100644 --- a/buildarchiveworker.cpp +++ b/buildarchiveworker.cpp @@ -5,6 +5,8 @@ #include #include +#include + #include #include @@ -25,6 +27,40 @@ bool isSafeRelativePath(const QString &path) return true; } +// Последняя проверка перед записью: куда бы ни указывало имя из архива после +// всех преобразований, файл обязан оказаться внутри папки игры. Правило имён +// выше это уже гарантирует, но проверка по готовому пути не зависит от того, +// как именно QZipReader разбирает записи. +bool isInside(const QDir &root, const QString &absolutePath) +{ + const QString base = QDir::cleanPath(root.absolutePath()) + u'/'; + return QDir::cleanPath(absolutePath).startsWith(base); +} + +// Верхний сегмент относительного пути: по нему проверяется, не лезет ли пак в +// общие каталоги лаунчера. +QString topSegment(const QString &path) +{ + const int slash = path.indexOf(u'/'); + return slash < 0 ? path : path.left(slash); +} + +// После удаления файлов остаются пустые папки: пустой mods/ вместо удалённого +// набора модов выглядит как незавершённая установка. +void pruneEmptyDirs(const QDir &root, QStringList dirs) +{ + // Сначала самые глубокие: удаление вложенной может опустошить родителя. + std::sort(dirs.begin(), dirs.end(), + [](const QString &a, const QString &b) { return a.size() > b.size(); }); + for (const QString &relative : std::as_const(dirs)) { + if (relative.isEmpty() || relative == QLatin1String(".")) + continue; + QDir dir(root.absoluteFilePath(relative)); + if (dir.exists() && dir.isEmpty()) + root.rmdir(relative); + } +} + // Файлы, которые попадут в архив, — относительными путями от gameDir. QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop) { @@ -218,3 +254,119 @@ void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir) emit finished(true, {}); } + +void BuildArchiveWorker::applyPack(const QString &zipPath, + const QString &gameDir, + const QStringList &removeRelative, + const QStringList &forbiddenTop) +{ + QZipReader reader(zipPath); + if (!reader.isReadable()) { + emit finished(false, + tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath))); + return; + } + + // Весь архив проверяется до первой записи на диск: наполовину раскатанный + // пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии. + const QList 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, {}); +} diff --git a/buildarchiveworker.h b/buildarchiveworker.h index 91a74a6..c494d2c 100644 --- a/buildarchiveworker.h +++ b/buildarchiveworker.h @@ -34,9 +34,25 @@ public slots: // Распаковывает архив в gameDir. void restore(const QString &zipPath, const QString &gameDir); + // Докатывает пак сезонной сборки поверх уже разложенного содержимого: + // сначала удаляет removeRelative (файлы, ушедшие из сборки в новой + // ревизии), затем распаковывает архив с перезаписью. Того, чего нет ни в + // списке, ни в архиве, операция не касается — миры и скриншоты остаются. + // + // forbiddenTop — элементы верхнего уровня, которые паку трогать нельзя + // (общие каталоги лаунчера). Проверяется здесь, а не только у издателя: + // архив приезжает из сети. + void applyPack(const QString &zipPath, + const QString &gameDir, + const QStringList &removeRelative, + const QStringList &forbiddenTop); + signals: void progress(int done, int total, const QString ¤tPath); void finished(bool ok, const QString &error); + // Что именно принёс пак — относительными путями. Пригодится следующему + // обновлению, чтобы вычислить, какие файлы из сборки ушли. + void packEntries(const QStringList &entries); private: bool canceled() const { return m_cancel.loadRelaxed() != 0; } diff --git a/buildswitcher.cpp b/buildswitcher.cpp index 94db612..a05d019 100644 --- a/buildswitcher.cpp +++ b/buildswitcher.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ namespace { const QString kIndexFile = QStringLiteral("index.json"); const QString kStateSwitching = QStringLiteral("switching"); +const QString kStateApplying = QStringLiteral("applying"); const QString kStateIdle = QStringLiteral("idle"); // Запас на архив: точный размер заранее неизвестен, но упасть на середине @@ -89,6 +91,8 @@ BuildSwitcher::BuildSwitcher(QObject *parent) connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished); connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress); + connect(m_worker, &BuildArchiveWorker::packEntries, this, + [this](const QStringList &entries) { m_lastPackEntries = entries; }); m_thread->start(); sweepTempArchives(); @@ -110,7 +114,7 @@ double BuildSwitcher::fraction() const QString BuildSwitcher::buildDir(int buildId) const { - return LauncherPaths::buildStorageDir() + u'/' + QString::number(buildId); + return LauncherPaths::buildDir(buildId); } // На сборку — ровно один архив: имя с датой меняется от переключения к @@ -118,8 +122,14 @@ QString BuildSwitcher::buildDir(int buildId) const QString BuildSwitcher::archivePathOf(int buildId) const { const QDir dir(buildDir(buildId)); + // Рядом лежат служебные .tmp-*.zip и .pack-*.zip. На Unix они скрытые и в + // выборку не попадают, на Windows — попадают, поэтому отсекаем по имени. const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time); - return zips.isEmpty() ? QString() : dir.absoluteFilePath(zips.first()); + for (const QString &name : zips) { + if (!name.startsWith(u'.')) + return dir.absoluteFilePath(name); + } + return {}; } void BuildSwitcher::setStage(const QString &stage) @@ -217,6 +227,15 @@ void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDi Q_ARG(QString, zipPath), Q_ARG(QString, gameDir)); } +void BuildSwitcher::requestApplyPack(const QString &zipPath, + const QString &gameDir, + const QStringList &remove) +{ + QMetaObject::invokeMethod(m_worker, "applyPack", Qt::QueuedConnection, + Q_ARG(QString, zipPath), Q_ARG(QString, gameDir), + Q_ARG(QStringList, remove), Q_ARG(QStringList, sharedTopLevel())); +} + void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath) { m_done = done; @@ -254,6 +273,17 @@ void BuildSwitcher::onWorkerFinished(bool ok, const QString &error) } finish(); return; + case Applying: + if (!ok) { + // Архив проверяется целиком до первой записи, поэтому нулевой + // счётчик означает, что в папке игры ничего не тронуто. Иначе + // часть файлов уже заменена: отметка в index.json остаётся, и + // следующий запуск доиграет раскатку из сохранённого пака. + fail(error, /*gameDirIntact=*/m_done == 0); + return; + } + finishApplying(); + return; } } @@ -278,7 +308,9 @@ void BuildSwitcher::commitArchive() const QDir dir(buildDir(m_fromId)); const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files); for (const QString &name : zips) { - if (dir.absoluteFilePath(name) != target) + // Служебные файлы под удаление не попадают: в .pack-*.zip может лежать + // пак недоигранной раскатки, без которого её нечем довести до конца. + if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target) QFile::remove(dir.absoluteFilePath(name)); } @@ -323,6 +355,98 @@ void BuildSwitcher::beginRestoring() requestRestore(archive, m_gameDir); } +// ── Раскатка пака сезонной сборки ────────────────────────────────────────── + +void BuildSwitcher::applyPack(int buildId, + const QString &buildName, + const QString &packZipPath, + const QStringList &removeRelative, + const QJsonObject ¬e, + const QString &gameDir) +{ + if (m_state != Idle) { + emit failed(buildId, tr("Операция со сборками уже выполняется"), true); + return; + } + + QString rootError; + if (!LauncherPaths::ensureRootExists(&rootError)) { + emit failed(buildId, rootError, true); + return; + } + if (!QFile::exists(packZipPath)) { + emit failed(buildId, + tr("Архив сборки не найден: %1") + .arg(QDir::toNativeSeparators(packZipPath)), + true); + return; + } + + m_fromId = -1; + m_toId = buildId; + m_toName = buildName; + m_gameDir = gameDir; + m_packPath = packZipPath; + m_packRemove = removeRelative; + m_packNote = note; + m_lastPackEntries.clear(); + m_lastPackNote = {}; + m_done = 0; + m_total = 0; + m_worker->clearCancel(); + + // Отметка ставится до первого разрушающего действия — как и при смене + // сборки. Пак лежит рядом и переживёт перезапуск вместе с ней. + QJsonObject index = readIndex(); + index.insert(QStringLiteral("state"), kStateApplying); + index.insert(QStringLiteral("to"), buildId); + index.insert(QStringLiteral("pack"), QFileInfo(packZipPath).fileName()); + index.insert(QStringLiteral("packName"), buildName); + index.insert(QStringLiteral("packRemove"), QJsonArray::fromStringList(removeRelative)); + index.insert(QStringLiteral("packNote"), note); + writeIndex(index); + + m_state = Applying; + beginApplying(); +} + +void BuildSwitcher::beginApplying() +{ + setStage(tr("Установка сборки «%1»").arg(m_toName)); + requestApplyPack(m_packPath, m_gameDir, m_packRemove); +} + +void BuildSwitcher::finishApplying() +{ + // Пак сделал своё дело: держать сотни мегабайт рядом с архивом сборки не + // за чем, а его наличие означало бы недоигранную раскатку. + QFile::remove(m_packPath); + + QJsonObject index = readIndex(); + index.insert(QStringLiteral("state"), kStateIdle); + index.insert(QStringLiteral("active"), m_toId); + index.remove(QStringLiteral("from")); + index.remove(QStringLiteral("to")); + index.remove(QStringLiteral("pack")); + index.remove(QStringLiteral("packName")); + index.remove(QStringLiteral("packRemove")); + index.remove(QStringLiteral("packNote")); + writeIndex(index); + + m_lastPackNote = m_packNote; + m_packPath.clear(); + m_packRemove.clear(); + m_packNote = {}; + + const int toId = m_toId; + m_state = Idle; + m_stage.clear(); + m_status.clear(); + m_done = m_total = 0; + emit progressChanged(); + emit finished(toId); +} + void BuildSwitcher::finish() { QJsonObject index = readIndex(); @@ -379,7 +503,14 @@ bool BuildSwitcher::forgetBuild(int buildId) QString BuildSwitcher::interruptedSwitchWarning() const { const QJsonObject index = readIndex(); - if (index.value(QStringLiteral("state")).toString() != kStateSwitching) + const QString state = index.value(QStringLiteral("state")).toString(); + + if (state == kStateApplying) { + const QString name = index.value(QStringLiteral("packName")).toString(); + return tr("Установка сборки%1 не завершилась — докатываем её файлы.") + .arg(name.isEmpty() ? QString() : tr(" «%1»").arg(name)); + } + if (state != kStateSwitching) return {}; const int toId = index.value(QStringLiteral("to")).toInt(-1); @@ -395,7 +526,13 @@ QString BuildSwitcher::interruptedSwitchWarning() const void BuildSwitcher::resumeInterrupted(const QString &gameDir) { const QJsonObject index = readIndex(); - if (index.value(QStringLiteral("state")).toString() != kStateSwitching) + const QString state = index.value(QStringLiteral("state")).toString(); + + if (state == kStateApplying) { + resumeApplying(index, gameDir); + return; + } + if (state != kStateSwitching) return; const int toId = index.value(QStringLiteral("to")).toInt(-1); @@ -419,6 +556,50 @@ void BuildSwitcher::resumeInterrupted(const QString &gameDir) beginClearing(); } +void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &gameDir) +{ + const int toId = index.value(QStringLiteral("to")).toInt(-1); + if (toId < 0 || m_state != Idle) + return; + + const QString packName = index.value(QStringLiteral("pack")).toString(); + const QString packPath = buildDir(toId) + u'/' + packName; + if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) { + // Пак не сохранился — доигрывать нечем. Отметку снимаем, иначе + // предупреждение о незавершённой установке будет всплывать вечно; + // сборка при этом остаётся наполовину обновлённой, и лаунчер покажет + // её как требующую обновления. + QJsonObject cleaned = index; + cleaned.insert(QStringLiteral("state"), kStateIdle); + cleaned.remove(QStringLiteral("pack")); + cleaned.remove(QStringLiteral("packName")); + cleaned.remove(QStringLiteral("packRemove")); + cleaned.remove(QStringLiteral("packNote")); + cleaned.remove(QStringLiteral("to")); + writeIndex(cleaned); + return; + } + + QStringList remove; + const QJsonArray removeArray = index.value(QStringLiteral("packRemove")).toArray(); + for (const QJsonValue &value : removeArray) + remove << value.toString(); + + m_fromId = -1; + m_toId = toId; + m_toName = index.value(QStringLiteral("packName")).toString(); + m_gameDir = gameDir; + m_packPath = packPath; + m_packRemove = remove; + m_packNote = index.value(QStringLiteral("packNote")).toObject(); + m_lastPackEntries.clear(); + m_lastPackNote = {}; + m_done = m_total = 0; + m_worker->clearCancel(); + m_state = Applying; + beginApplying(); +} + // ── Реестр архивов ───────────────────────────────────────────────────────── QJsonObject BuildSwitcher::readIndex() const @@ -445,7 +626,8 @@ void BuildSwitcher::writeIndex(const QJsonObject &index) const } // Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может -// занимать гигабайты. +// занимать гигабайты. Пакеты .pack-*.zip не трогаем: по ним доигрывается +// прерванная раскатка сезонной сборки, а лишний тут удаляет resumeApplying. void BuildSwitcher::sweepTempArchives() const { QDir root(LauncherPaths::buildStorageDir()); diff --git a/buildswitcher.h b/buildswitcher.h index c6cda93..dd438b9 100644 --- a/buildswitcher.h +++ b/buildswitcher.h @@ -37,6 +37,26 @@ public: int toBuildId, const QString &toName, const QString &gameDir); + + // Докатывает пак сезонной сборки поверх содержимого .minecraft. Вызывается + // только для уже активной сборки: пак ложится на то, что сейчас разложено, + // а не внутрь чужого архива. + // + // note — непрозрачные данные вызывающей стороны: они переживают перезапуск + // вместе с отметкой о незавершённой операции и возвращаются через + // lastPackNote(), когда раскатка доиграна. Сам переключатель в них не + // заглядывает. + void applyPack(int buildId, + const QString &buildName, + const QString &packZipPath, + const QStringList &removeRelative, + const QJsonObject ¬e, + const QString &gameDir); + + // Что принёс последний успешно раскатанный пак и с чем он пришёл. + QStringList lastPackEntries() const { return m_lastPackEntries; } + QJsonObject lastPackNote() const { return m_lastPackNote; } + void cancel(); // Сборку удалили: убираем её архив и запись о нём. Возвращает false, если @@ -58,17 +78,23 @@ signals: void failed(int toBuildId, const QString &message, bool gameDirIntact); private: - enum State { Idle, Archiving, Clearing, Restoring }; + enum State { Idle, Archiving, Clearing, Restoring, Applying }; // Сигналы к рабочему объекту в другом потоке. void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude); void requestClear(const QString &gameDir, const QStringList &keep); void requestRestore(const QString &zipPath, const QString &gameDir); + void requestApplyPack(const QString &zipPath, + const QString &gameDir, + const QStringList &remove); void onWorkerFinished(bool ok, const QString &error); void onWorkerProgress(int done, int total, const QString ¤tPath); void commitArchive(); + void beginApplying(); + void finishApplying(); + void resumeApplying(const QJsonObject &index, const QString &gameDir); void beginClearing(); void beginRestoring(); void finish(); @@ -97,4 +123,12 @@ private: QString m_toName; QString m_gameDir; QString m_tempZip; + + // Раскатка пака: путь к архиву, список уходящих файлов и данные вызывающей + // стороны, которые надо вернуть по завершении. + QString m_packPath; + QStringList m_packRemove; + QJsonObject m_packNote; + QStringList m_lastPackEntries; + QJsonObject m_lastPackNote; }; diff --git a/javaruntimeservice.cpp b/javaruntimeservice.cpp index 43a2934..5a78780 100644 --- a/javaruntimeservice.cpp +++ b/javaruntimeservice.cpp @@ -132,6 +132,60 @@ QList JavaRuntimeService::entries(JavaRuntimeKind kind) const return result; } +std::optional 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 JavaRuntimeService::find(const QString &id) const { const auto it = m_indexById.constFind(id); diff --git a/javaruntimeservice.h b/javaruntimeservice.h index 04a1062..173f8a2 100644 --- a/javaruntimeservice.h +++ b/javaruntimeservice.h @@ -43,6 +43,12 @@ public: QList entries(JavaRuntimeKind kind) const; std::optional find(const QString &id) const; + // Что скачать, если для версии игры нужна Java major, а подходящей в + // системе нет. Точное совпадение мажорной версии предпочтительнее более + // новой, JDK — предпочтительнее JRE: установщики Forge и NeoForge иногда + // требуют инструментов из полного комплекта. Пусто — предложить нечего. + std::optional bestFor(int major) const; + bool isRefreshing() const { return m_outstanding > 0; } bool hasData() const { return !m_entries.isEmpty(); } diff --git a/launcherbackend.cpp b/launcherbackend.cpp index fbb80d8..2e134bb 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -12,7 +12,11 @@ #include "modloaderinstaller.h" #include "modloaderversionservice.h" #include "buildswitcher.h" +#include "seasonalbuildservice.h" +#include "seasonalpackdownloader.h" +#include +#include #include #include #include @@ -21,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +36,17 @@ namespace { const QString kOffline = QStringLiteral("offline"); const QString kElyBy = QStringLiteral("elyby"); +// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl +// в settings.json — это нужно, чтобы прогонять установку против локального +// http-сервера, не трогая боевой. +const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com"); + +// Дата сезона в таблице: год нужен, время — нет. +QString formatSeasonDate(const QDate &date) +{ + return date.isValid() ? date.toString(QStringLiteral("dd.MM.yyyy")) : QStringLiteral("—"); +} + int settingInt(const QVariantMap &settings, const QString &key, int fallback) { const QVariant value = settings.value(key); @@ -52,6 +68,8 @@ LauncherBackend::LauncherBackend(QObject *parent) , m_switcher(new BuildSwitcher(this)) , m_javaMeta(new JavaRuntimeService(this)) , m_javaInstaller(new JavaInstaller(this)) + , m_seasonalMeta(new SeasonalBuildService(this)) + , m_packDownloader(new SeasonalPackDownloader(this)) { // Папка лаунчера создаётся при первом запуске и проверяется при каждом // следующем — если её удалили, она появится снова. @@ -113,6 +131,8 @@ LauncherBackend::LauncherBackend(QObject *parent) emit javaRuntimeInstalled(runtimeId); emit launchProgress(tr("Java установлена: %1") .arg(QDir::toNativeSeparators(javaPath))); + if (m_season.stage == SeasonStage::Java) + seasonAdvance(); }); connect(m_javaInstaller, &JavaInstaller::failed, this, [this](const QString &label, const QString &message) { @@ -120,12 +140,16 @@ LauncherBackend::LauncherBackend(QObject *parent) emit downloadChanged(); emit busyChanged(); emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message)); + if (m_season.stage == SeasonStage::Java) + seasonFail({}); }); connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) { invalidateJavaCatalog(); emit downloadChanged(); emit busyChanged(); emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label)); + if (m_season.stage == SeasonStage::Java) + seasonFail({}); }); connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) { @@ -143,6 +167,11 @@ LauncherBackend::LauncherBackend(QObject *parent) emit downloadChanged(); emit busyChanged(); emit launchProgress(tr("Версия «%1» загружена").arg(id)); + // У ванильной сезонной сборки этот установщик и есть весь шаг Loader. + // Внутри установки модлоадера он тоже отчитывается — тогда ход + // передаст ModLoaderInstaller, а не он. + if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) + seasonAdvance(); }); connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id, const QString &message) { @@ -152,6 +181,8 @@ LauncherBackend::LauncherBackend(QObject *parent) emit downloadChanged(); emit busyChanged(); emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message)); + if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) + seasonFail({}); }); connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] { emit switchChanged(); @@ -170,6 +201,17 @@ LauncherBackend::LauncherBackend(QObject *parent) const int index = indexOfBuildId(toBuildId); emit launchProgress(tr("Активна сборка «%1»") .arg(index >= 0 ? m_builds[index].name : QString())); + + // Раскатка пака могла быть и той, что доигралась после перезапуска — + // тогда цепочки установки уже нет, а записать ревизию всё равно надо. + const QJsonObject note = m_switcher->lastPackNote(); + if (!note.isEmpty()) + recordSeasonalPack(toBuildId, note, m_switcher->lastPackEntries()); + + if (m_season.stage == SeasonStage::Applying) + seasonFinish(); + else if (m_season.stage == SeasonStage::Switching) + seasonAdvance(); }); connect(m_switcher, &BuildSwitcher::failed, this, [this](int, const QString &message, bool gameDirIntact) { @@ -177,11 +219,27 @@ LauncherBackend::LauncherBackend(QObject *parent) emit installedVersionsChanged(); emit switchChanged(); emit busyChanged(); - emit launchError(gameDirIntact - ? tr("Сборка не переключена: %1").arg(message) - : tr("Сборка не переключена: %1\nСодержимое .minecraft " - "осталось разобранным — повторите переключение.") - .arg(message)); + // Раскатка пака и смена сборки приходят одним сигналом, а + // сказать пользователю надо разное: после сорванного + // переключения .minecraft лежит разобранной, а сорванная + // раскатка означает лишь наполовину обновлённую сборку. + if (m_season.stage == SeasonStage::Applying) { + emit launchError(gameDirIntact + ? tr("Файлы сборки не установлены: %1").arg(message) + : tr("Файлы сборки установлены не полностью: %1\n" + "Повторите установку сборки.") + .arg(message)); + } else { + emit launchError(gameDirIntact + ? tr("Сборка не переключена: %1").arg(message) + : tr("Сборка не переключена: %1\nСодержимое .minecraft " + "осталось разобранным — повторите переключение.") + .arg(message)); + } + if (m_season.stage == SeasonStage::Switching + || m_season.stage == SeasonStage::Applying) { + seasonFail({}); + } }); connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged, @@ -216,6 +274,8 @@ LauncherBackend::LauncherBackend(QObject *parent) emit downloadChanged(); emit launchProgress(tr("Готово: %1 для Minecraft %2") .arg(producedVersionId, gameVersion)); + if (m_season.stage == SeasonStage::Loader) + seasonAdvance(); }); connect(m_loaderInstaller, &ModLoaderInstaller::failed, this, [this](const QString &label, const QString &message) { @@ -225,6 +285,8 @@ LauncherBackend::LauncherBackend(QObject *parent) emit busyChanged(); emit downloadChanged(); emit launchError(tr("%1: %2").arg(label, message)); + if (m_season.stage == SeasonStage::Loader) + seasonFail({}); }); connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) { m_loaderInstallBuildId = -1; @@ -233,6 +295,8 @@ LauncherBackend::LauncherBackend(QObject *parent) emit busyChanged(); emit downloadChanged(); emit launchProgress(tr("Установка %1 отменена").arg(label)); + if (m_season.stage == SeasonStage::Loader) + seasonFail({}); }); connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) { @@ -241,6 +305,49 @@ LauncherBackend::LauncherBackend(QObject *parent) emit downloadChanged(); emit busyChanged(); emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id)); + if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) + seasonFail({}); + }); + + m_seasonalMeta->setBaseUrl(seasonalBaseUrl()); + connect(m_seasonalMeta, &SeasonalBuildService::buildsChanged, + this, &LauncherBackend::invalidateSeasonalCatalog); + connect(m_seasonalMeta, &SeasonalBuildService::refreshingChanged, + this, &LauncherBackend::seasonalCatalogLoadingChanged); + // Статус строки («установлена», «доступно обновление») считается по + // локальным записям — их изменение обновляет и таблицу. + connect(this, &LauncherBackend::customBuildsChanged, + this, &LauncherBackend::invalidateSeasonalCatalog); + + connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) { + emit downloadChanged(); + emit busyChanged(); + emit launchProgress(tr("Загрузка файлов сборки «%1»…").arg(label)); + }); + connect(m_packDownloader, &SeasonalPackDownloader::progressChanged, + this, &LauncherBackend::downloadChanged); + connect(m_packDownloader, &SeasonalPackDownloader::finished, this, [this](const QString &path) { + emit downloadChanged(); + emit busyChanged(); + if (m_season.stage != SeasonStage::Downloading) + return; + m_season.packPath = path; + seasonAdvance(); + }); + connect(m_packDownloader, &SeasonalPackDownloader::failed, this, + [this](const QString &label, const QString &message) { + emit downloadChanged(); + emit busyChanged(); + emit launchError(tr("Сборка «%1»: %2").arg(label, message)); + if (m_season.stage == SeasonStage::Downloading) + seasonFail({}); + }); + connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) { + emit downloadChanged(); + emit busyChanged(); + emit launchProgress(tr("Загрузка сборки «%1» отменена").arg(label)); + if (m_season.stage == SeasonStage::Downloading) + seasonFail({}); }); // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно. @@ -362,6 +469,9 @@ void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields) take("loader", build.loader); take("loaderVersion", build.loaderVersion); take("resolvedVersionId", build.resolvedVersionId); + take("seasonalId", build.seasonalId); + if (fields.contains(QStringLiteral("seasonalRevision"))) + build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt(); if (build.minecraftVersion != previousMinecraft && !fields.contains(QStringLiteral("loaderVersion"))) { @@ -399,6 +509,8 @@ QVariantMap LauncherBackend::customBuildAt(int index) const {"loader", b.loader}, {"loaderVersion", b.loaderVersion}, {"resolvedVersionId", b.resolvedVersionId}, + {"seasonalId", b.seasonalId}, + {"seasonalRevision", b.seasonalRevision}, {"launchVersionId", launchVersionIdOf(b)}}; } @@ -752,8 +864,10 @@ void LauncherBackend::cancelDownload() m_loaderInstaller->cancel(); else if (m_installer->isRunning()) m_installer->cancel(); - else + else if (m_javaInstaller->isRunning()) m_javaInstaller->cancel(); + else + m_packDownloader->cancel(); } // Панель загрузки одна на три установщика: пока идёт установка модлоадера, она @@ -762,7 +876,7 @@ void LauncherBackend::cancelDownload() bool LauncherBackend::downloading() const { return m_loaderInstaller->isRunning() || m_installer->isRunning() - || m_javaInstaller->isRunning(); + || m_javaInstaller->isRunning() || m_packDownloader->isRunning(); } // Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок. @@ -772,8 +886,18 @@ bool LauncherBackend::javaDownloadShown() const && !m_loaderInstaller->isRunning(); } +// Пак качается отдельным шагом цепочки, когда остальные установщики уже +// отработали, — за панель они не спорят. +bool LauncherBackend::packDownloadShown() const +{ + return m_packDownloader->isRunning() && !m_installer->isRunning() + && !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning(); +} + double LauncherBackend::downloadProgress() const { + if (packDownloadShown()) + return m_packDownloader->fraction(); if (javaDownloadShown()) return m_javaInstaller->fraction(); if (!m_loaderInstaller->isRunning()) @@ -784,6 +908,8 @@ double LauncherBackend::downloadProgress() const QString LauncherBackend::downloadVersion() const { + if (packDownloadShown()) + return m_packDownloader->label(); if (javaDownloadShown()) return m_javaInstaller->label(); if (m_loaderInstaller->isRunning()) @@ -795,7 +921,10 @@ QString LauncherBackend::downloadStatus() const { QString stage = m_installer->stage(); QString file = m_installer->currentFile(); - if (javaDownloadShown()) { + if (packDownloadShown()) { + stage = m_packDownloader->stage(); + file = m_packDownloader->currentFile(); + } else if (javaDownloadShown()) { stage = m_javaInstaller->stage(); file = m_javaInstaller->currentFile(); } else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) { @@ -811,6 +940,8 @@ QString LauncherBackend::downloadStatus() const qint64 LauncherBackend::downloadBytesDone() const { + if (packDownloadShown()) + return m_packDownloader->bytesDone(); if (javaDownloadShown()) return m_javaInstaller->bytesDone(); if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) @@ -820,6 +951,8 @@ qint64 LauncherBackend::downloadBytesDone() const qint64 LauncherBackend::downloadBytesTotal() const { + if (packDownloadShown()) + return m_packDownloader->bytesTotal(); if (javaDownloadShown()) return m_javaInstaller->bytesTotal(); if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) @@ -1217,7 +1350,8 @@ QString LauncherBackend::switchStatus() const bool LauncherBackend::busy() const { return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning() - || m_javaInstaller->isRunning() || m_switcher->isRunning(); + || m_javaInstaller->isRunning() || m_switcher->isRunning() + || m_packDownloader->isRunning(); } void LauncherBackend::setBusy(bool busy) @@ -1473,6 +1607,466 @@ QString LauncherBackend::managedJavaPath() const return runtime ? runtime->javaPath : QString(); } +// ── Сезонные сборки ──────────────────────────────────────────────────────── + +QUrl LauncherBackend::seasonalBaseUrl() const +{ + const QString configured = + m_settings.value(QStringLiteral("seasonalBaseUrl")).toString().trimmed(); + QUrl url(configured.isEmpty() ? kSeasonalBaseUrl : configured); + // Хвостовой слэш превратил бы адрес каталога в «…//index.json». + while (url.path().endsWith(u'/')) + url.setPath(url.path().chopped(1)); + return url; +} + +void LauncherBackend::invalidateSeasonalCatalog() +{ + m_seasonalCatalogDirty = true; + emit seasonalCatalogChanged(); +} + +int LauncherBackend::indexOfSeasonalBuild(const QString &seasonalId) const +{ + if (seasonalId.isEmpty()) + return -1; + for (int i = 0; i < m_builds.size(); ++i) { + if (m_builds[i].seasonalId == seasonalId) + return i; + } + return -1; +} + +void LauncherBackend::rebuildSeasonalCatalog() const +{ + m_seasonalCatalog.clear(); + const QList builds = m_seasonalMeta->builds(); + m_seasonalCatalog.reserve(builds.size()); + + for (const SeasonalBuildEntry &entry : builds) { + int installedRevision = 0; + bool installed = false; + for (const CustomBuild &build : m_builds) { + if (build.seasonalId != entry.id) + continue; + installed = true; + installedRevision = build.seasonalRevision; + break; + } + const bool updateAvailable = installed && installedRevision < entry.revision; + + QString status; + if (!installed) + status = tr("Не установлена"); + else if (updateAvailable) + status = tr("Есть обновление"); + else + status = tr("Установлена"); + + const auto loader = loaderFromKey(entry.loader); + m_seasonalCatalog.append(QVariantMap{ + {QStringLiteral("id"), entry.id}, + {QStringLiteral("name"), entry.name}, + {QStringLiteral("minecraftVersion"), entry.minecraftVersion}, + {QStringLiteral("loader"), entry.loader}, + {QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : tr("Ванильная")}, + {QStringLiteral("loaderVersion"), entry.loaderVersion}, + {QStringLiteral("modCount"), entry.modCount}, + {QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)}, + {QStringLiteral("seasonEnd"), formatSeasonDate(entry.seasonEnd)}, + {QStringLiteral("sizeBytes"), entry.archiveSize}, + {QStringLiteral("revision"), entry.revision}, + {QStringLiteral("description"), entry.description}, + {QStringLiteral("serverUrl"), entry.serverUrl}, + {QStringLiteral("installed"), installed}, + {QStringLiteral("installedRevision"), installedRevision}, + {QStringLiteral("updateAvailable"), updateAvailable}, + {QStringLiteral("status"), status}}); + } + m_seasonalCatalogDirty = false; +} + +QVariantList LauncherBackend::seasonalCatalog() const +{ + if (m_seasonalCatalogDirty) + rebuildSeasonalCatalog(); + return m_seasonalCatalog; +} + +bool LauncherBackend::seasonalCatalogLoading() const +{ + return m_seasonalMeta->isRefreshing(); +} + +QString LauncherBackend::seasonalCatalogError() const +{ + return m_seasonalMeta->lastError(); +} + +bool LauncherBackend::seasonalInstalling() const +{ + return m_season.stage != SeasonStage::Idle; +} + +void LauncherBackend::refreshSeasonalCatalog(bool force) +{ + // Адрес мог смениться в settings.json между запусками окна. + m_seasonalMeta->setBaseUrl(seasonalBaseUrl()); + m_seasonalMeta->ensureLoaded(nullptr, force); +} + +// ── Установка сезонной сборки ────────────────────────────────────────────── + +void LauncherBackend::installSeasonalBuild(const QString &seasonalId) +{ + if (m_season.stage != SeasonStage::Idle) { + emit launchError(tr("Установка сборки уже идёт")); + return; + } + if (m_launcher->isRunning()) { + emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся")); + return; + } + if (busy()) { + emit launchError(tr("Дождитесь окончания текущей операции")); + return; + } + + const auto entry = m_seasonalMeta->find(seasonalId); + if (!entry) { + emit launchError(tr("Сборка «%1» не найдена в списке — обновите его").arg(seasonalId)); + return; + } + + m_season = {}; + m_season.entry = *entry; + + // Сборка с таким seasonalId уже заведена — обновляем её описание под новую + // ревизию, а не плодим вторую запись. + int index = indexOfSeasonalBuild(seasonalId); + if (index < 0) { + CustomBuild build; + build.id = m_nextBuildId++; + build.name = entry->name; + build.serverUrl = entry->serverUrl; + build.minecraftVersion = entry->minecraftVersion; + build.loader = entry->loader; + build.loaderVersion = entry->loaderVersion; + build.seasonalId = entry->id; + // Ревизия появится только когда файлы лягут на диск: иначе оборванная + // установка выглядела бы завершённой. + m_builds.append(build); + index = m_builds.size() - 1; + saveCustomBuilds(); + emit customBuildsChanged(); + // maybeInstall() здесь намеренно нет: версию и модлоадер поставит шаг + // цепочки, а параллельная загрузка только мешала бы ему. + } else { + updateCustomBuild(index, + {{QStringLiteral("name"), entry->name}, + {QStringLiteral("serverUrl"), entry->serverUrl}, + {QStringLiteral("minecraftVersion"), entry->minecraftVersion}, + {QStringLiteral("loader"), entry->loader}, + {QStringLiteral("loaderVersion"), entry->loaderVersion}}); + } + + m_season.buildId = m_builds[index].id; + setBusy(true); + emit launchProgress(tr("Установка сборки «%1»…").arg(entry->name)); + // Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его + // вызова seasonalInstalling() ещё вернул бы false. + seasonAdvance(); + emit seasonalInstallingChanged(); +} + +void LauncherBackend::seasonAdvance() +{ + if (indexOfBuildId(m_season.buildId) < 0) { + seasonFail(tr("запись сборки исчезла")); + return; + } + + // Каждый шаг либо начинает свою асинхронную работу и обрывает цепочку до + // следующего сигнала, либо уступает следующему — отсюда провалы между + // ветками. + switch (m_season.stage) { + case SeasonStage::Idle: + if (seasonBeginSwitch()) + return; + [[fallthrough]]; + case SeasonStage::Switching: + if (seasonBeginJava()) + return; + [[fallthrough]]; + case SeasonStage::Java: + if (seasonBeginLoader()) + return; + [[fallthrough]]; + case SeasonStage::Loader: + if (seasonBeginDownload()) + return; + [[fallthrough]]; + case SeasonStage::Downloading: + if (seasonBeginApply()) + return; + [[fallthrough]]; + case SeasonStage::Applying: + seasonFinish(); + return; + } +} + +bool LauncherBackend::seasonBeginSwitch() +{ + const int index = indexOfBuildId(m_season.buildId); + const int current = activeBuildIndex(); + if (current == index) + return false; + + // Пак ложится на то, что сейчас разложено в .minecraft, поэтому сборка + // должна стать активной до всего остального. Содержимое прежней уезжает в + // её архив обычным путём. + m_season.stage = SeasonStage::Switching; + emit launchProgress(tr("Смена сборки на «%1»…").arg(m_builds[index].name)); + m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1, + current >= 0 ? m_builds[current].name : QString(), + m_builds[index].id, + m_builds[index].name, + gameDir()); + emit switchChanged(); + emit busyChanged(); + return true; +} + +bool LauncherBackend::seasonBeginJava() +{ + // Сервер может назвать нужную Java прямо; иначе спрашиваем таблицу. + const int major = m_season.entry.javaMajor > 0 + ? m_season.entry.javaMajor + : JavaRequirement::minimumFor(m_season.entry.minecraftVersion); + if (major <= 0) + return false; + + // Java уже есть — своя, системная или скачанная раньше. + QString error; + if (!JavaLocator::select(gameDir(), major, QString(), &error).isEmpty()) + return false; + + m_season.stage = SeasonStage::Java; + emit launchProgress(tr("Для сборки нужна Java %1 — устанавливаем").arg(major)); + + // Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать. + m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) { + if (m_season.stage != SeasonStage::Java) + return; + if (!ok) { + seasonFail(warning.isEmpty() ? tr("не удалось получить каталог Java") : warning); + return; + } + const auto entry = m_javaMeta->bestFor(major); + if (!entry) { + seasonFail(tr("в каталоге нет сборки Java %1 для этой системы").arg(major)); + return; + } + m_javaInstaller->install(*entry); + }); + return true; +} + +bool LauncherBackend::seasonBeginLoader() +{ + const int index = indexOfBuildId(m_season.buildId); + const CustomBuild &build = m_builds[index]; + const auto loader = loaderFromKey(build.loader); + + // Ванильная сборка: ставить надо только саму версию игры. + if (!loader) { + if (checkInstallation(index).isEmpty()) + return false; + m_season.stage = SeasonStage::Loader; + m_installer->install(gameDir(), build.minecraftVersion); + return true; + } + + // Профиль лоадера уже собран и укомплектован — второй раз не ставим. + if (!build.resolvedVersionId.isEmpty() && checkInstallation(index).isEmpty()) + return false; + + m_season.stage = SeasonStage::Loader; + m_loaderInstallBuildId = build.id; + m_loaderInstaller->install(gameDir(), + *loader, + build.minecraftVersion, + build.loaderVersion, + managedJavaPath().isEmpty() + ? m_settings.value(QStringLiteral("javaPath")).toString() + : managedJavaPath()); + return true; +} + +bool LauncherBackend::seasonBeginDownload() +{ + const SeasonalBuildEntry &entry = m_season.entry; + // Точка в начале имени: файл служебный, и BuildSwitcher по этому признаку + // не примет его за архив сборки и не сотрёт при следующем переключении. + const QString path = LauncherPaths::buildDir(m_season.buildId) + + QStringLiteral("/.pack-r") + QString::number(entry.revision) + + QStringLiteral(".zip"); + + m_season.stage = SeasonStage::Downloading; + m_packDownloader->download(entry.archiveUrl, + path, + entry.archiveSha256, + entry.archiveSize, + entry.name); + return true; +} + +bool LauncherBackend::seasonBeginApply() +{ + if (m_season.packPath.isEmpty()) { + seasonFail(tr("файлы сборки не скачаны")); + return true; + } + + // Под удаление идёт весь список прошлой ревизии, а не разница с новой: + // распаковка всё равно кладёт каждый файл пака заново, поэтому разница + // сэкономила бы лишь перезапись того, что и так перезаписывается, а вот + // ушедшие из сборки моды так убираются наверняка. Миров и скриншотов в + // списке нет — их пак не приносил. + const QStringList remove = seasonalPackEntries(m_season.buildId); + + const QJsonObject note{{QStringLiteral("seasonalId"), m_season.entry.id}, + {QStringLiteral("revision"), m_season.entry.revision}, + {QStringLiteral("sha256"), m_season.entry.archiveSha256}}; + + const int index = indexOfBuildId(m_season.buildId); + m_season.stage = SeasonStage::Applying; + m_switcher->applyPack(m_season.buildId, + m_builds[index].name, + m_season.packPath, + remove, + note, + gameDir()); + emit switchChanged(); + emit busyChanged(); + return true; +} + +void LauncherBackend::seasonFinish() +{ + const int index = indexOfBuildId(m_season.buildId); + const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name; + const QString seasonalId = m_season.entry.id; + + m_season = {}; + setBusy(false); + invalidateSeasonalCatalog(); + emit seasonalInstallingChanged(); + emit launchProgress(tr("Сборка «%1» установлена").arg(name)); + emit seasonalInstallFinished(seasonalId, name); +} + +void LauncherBackend::seasonFail(const QString &message) +{ + const int index = indexOfBuildId(m_season.buildId); + const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name; + // Недокачанный или нераскатанный пак не должен занимать место: цепочку + // всегда можно начать заново с чистого листа. + if (!m_season.packPath.isEmpty()) + QFile::remove(m_season.packPath); + + m_season = {}; + setBusy(false); + emit seasonalInstallingChanged(); + // Пустое message означает, что о причине уже сообщил тот, кто её нашёл, — + // второй раз ту же ошибку не показываем. + emit launchError(message.isEmpty() + ? tr("Установка сборки «%1» прервана").arg(name) + : tr("Установка сборки «%1» прервана: %2").arg(name, message)); +} + +void LauncherBackend::cancelSeasonalInstall() +{ + switch (m_season.stage) { + case SeasonStage::Idle: + return; + case SeasonStage::Java: + m_javaInstaller->cancel(); + return; + case SeasonStage::Loader: + if (m_loaderInstaller->isRunning()) + m_loaderInstaller->cancel(); + else + m_installer->cancel(); + return; + case SeasonStage::Downloading: + m_packDownloader->cancel(); + return; + case SeasonStage::Switching: + case SeasonStage::Applying: + // На этих шагах содержимое .minecraft уже разобрано: отступать некуда, + // операцию нужно довести до конца. + emit launchError(tr("Сейчас переносятся файлы — отменить нельзя")); + return; + } +} + +// ── Состояние установленной сезонной сборки ──────────────────────────────── + +QStringList LauncherBackend::seasonalPackEntries(int buildId) const +{ + QFile file(LauncherPaths::seasonalStateFile(buildId)); + if (!file.open(QIODevice::ReadOnly)) + return {}; + + const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); + QStringList entries; + const QJsonArray array = root.value(QStringLiteral("entries")).toArray(); + entries.reserve(array.size()); + for (const QJsonValue &value : array) + entries << value.toString(); + return entries; +} + +void LauncherBackend::recordSeasonalPack(int buildId, + const QJsonObject ¬e, + const QStringList &entries) +{ + const QString seasonalId = note.value(QStringLiteral("seasonalId")).toString(); + const int revision = note.value(QStringLiteral("revision")).toInt(); + if (seasonalId.isEmpty() || revision <= 0) + return; + + const QJsonObject state{{QStringLiteral("seasonalId"), seasonalId}, + {QStringLiteral("revision"), revision}, + {QStringLiteral("sha256"), + note.value(QStringLiteral("sha256")).toString()}, + {QStringLiteral("installedAt"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}, + {QStringLiteral("entries"), QJsonArray::fromStringList(entries)}}; + // Не через writeJsonFile: файл лежит в папке сборки, а не в корне лаунчера, + // и его отсутствие не мешает игре — ошибку достаточно показать. + QDir().mkpath(LauncherPaths::buildDir(buildId)); + QSaveFile file(LauncherPaths::seasonalStateFile(buildId)); + if (file.open(QIODevice::WriteOnly)) { + const QByteArray payload = QJsonDocument(state).toJson(QJsonDocument::Indented); + if (file.write(payload) == payload.size()) + file.commit(); + else + file.cancelWriting(); + } + + const int index = indexOfBuildId(buildId); + if (index < 0) + return; + // Ревизия проставляется только здесь: до этого момента файлы сборки на + // диске не лежали. + updateCustomBuild(index, + {{QStringLiteral("seasonalId"), seasonalId}, + {QStringLiteral("seasonalRevision"), revision}}); +} + void LauncherBackend::openMinecraftFolder() { const QString modsDir = gameDir() + QStringLiteral("/mods"); @@ -1624,6 +2218,8 @@ void LauncherBackend::loadData() build.loader = o.value("loader").toString(); build.loaderVersion = o.value("loaderVersion").toString(); build.resolvedVersionId = o.value("resolvedVersionId").toString(); + build.seasonalId = o.value("seasonalId").toString(); + build.seasonalRevision = o.value("seasonalRevision").toInt(); // Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт // пустую строку. @@ -1727,7 +2323,9 @@ void LauncherBackend::saveCustomBuilds() {"minecraftVersion", b.minecraftVersion}, {"loader", b.loader}, {"loaderVersion", b.loaderVersion}, - {"resolvedVersionId", b.resolvedVersionId}}); + {"resolvedVersionId", b.resolvedVersionId}, + {"seasonalId", b.seasonalId}, + {"seasonalRevision", b.seasonalRevision}}); writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr)); } @@ -1746,7 +2344,10 @@ void LauncherBackend::loadSettings() {QStringLiteral("fullscreen"), false}, {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}, - {QStringLiteral("activeBuildId"), 0}}; + {QStringLiteral("activeBuildId"), 0}, + // Сервер готовых сборок. В интерфейс не вынесен: менять его + // приходится только при отладке против локального сервера. + {QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}}; const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile()); for (auto it = stored.constBegin(); it != stored.constEnd(); ++it) diff --git a/launcherbackend.h b/launcherbackend.h index 35d661f..deb723d 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -1,6 +1,7 @@ #pragma once #include "minecraftversion.h" +#include "seasonalbuildservice.h" #include #include @@ -20,6 +21,8 @@ class ModLoaderInstaller; class BuildSwitcher; class JavaRuntimeService; class JavaInstaller; +class SeasonalBuildService; +class SeasonalPackDownloader; class LauncherBackend : public QObject { @@ -55,6 +58,15 @@ class LauncherBackend : public QObject Q_PROPERTY(QVariantList javaCatalog READ javaCatalog NOTIFY javaCatalogChanged) Q_PROPERTY(bool javaCatalogLoading READ javaCatalogLoading NOTIFY javaCatalogLoadingChanged) + // Готовые сборки с сервера. Строки таблицы уже сведены с локальными + // записями: окно показывает статус, не считая ничего само. + Q_PROPERTY(QVariantList seasonalCatalog READ seasonalCatalog NOTIFY seasonalCatalogChanged) + Q_PROPERTY(bool seasonalCatalogLoading READ seasonalCatalogLoading + NOTIFY seasonalCatalogLoadingChanged) + Q_PROPERTY(QString seasonalCatalogError READ seasonalCatalogError NOTIFY seasonalCatalogChanged) + // Идёт установка сезонной сборки: окно не даёт начать вторую. + Q_PROPERTY(bool seasonalInstalling READ seasonalInstalling NOTIFY seasonalInstallingChanged) + // Одного сигнала на все свойства загрузки достаточно: установщик уже // ограничивает частоту, а QML всё равно перечитывает их разом. Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged) @@ -88,6 +100,11 @@ public: QVariantList javaCatalog() const; bool javaCatalogLoading() const; + QVariantList seasonalCatalog() const; + bool seasonalCatalogLoading() const; + QString seasonalCatalogError() const; + bool seasonalInstalling() const; + bool downloading() const; double downloadProgress() const; QString downloadVersion() const; @@ -168,6 +185,13 @@ public: // Минимальная Java для версии игры выбранной сборки; 0 — версия не выбрана. Q_INVOKABLE int requiredJavaMajor(int buildIndex) const; + // ── Сезонные сборки ───────────────────────────────────────────────── + Q_INVOKABLE void refreshSeasonalCatalog(bool force = false); + // Ставит или обновляет сборку целиком: запись, версия, модлоадер, Java и + // файлы — одной цепочкой. + Q_INVOKABLE void installSeasonalBuild(const QString &seasonalId); + Q_INVOKABLE void cancelSeasonalInstall(); + Q_INVOKABLE void openMinecraftFolder(); Q_INVOKABLE void openGameFolder(); @@ -185,8 +209,14 @@ signals: void catalogLoadingChanged(); void javaCatalogChanged(); void javaCatalogLoadingChanged(); + void seasonalCatalogChanged(); + void seasonalCatalogLoadingChanged(); + void seasonalInstallingChanged(); void downloadChanged(); + // Сезонная сборка установлена и активна — можно запускать игру. + void seasonalInstallFinished(const QString &seasonalId, const QString &buildName); + // Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь. void javaRuntimeInstalled(const QString &runtimeId); @@ -215,6 +245,24 @@ private: QString loader; // "" | forge | fabric | neoforge | quilt QString loaderVersion; // 47.4.0 | 0.19.3 QString resolvedVersionId; // versions/, который реально запускается + // Сборка приехала с сервера сезонных сборок: id её строки в каталоге и + // установленная ревизия. Пусто — обычная пользовательская сборка. + QString seasonalId; + int seasonalRevision = 0; + }; + + // Шаг установки сезонной сборки. Порядок жёсткий: Java нужна установщику + // Forge, а раскатывать файлы поверх имеет смысл только когда всё остальное + // на месте. + enum class SeasonStage { Idle, Switching, Java, Loader, Downloading, Applying }; + struct SeasonalInstall + { + SeasonStage stage = SeasonStage::Idle; + int buildId = -1; + QString packPath; + // Копия строки каталога на момент старта: обновление списка посреди + // установки не должно менять то, что ставится. + SeasonalBuildEntry entry; }; void loadData(); @@ -246,6 +294,30 @@ private: void rebuildCatalog() const; void invalidateJavaCatalog(); void rebuildJavaCatalog() const; + void invalidateSeasonalCatalog(); + void rebuildSeasonalCatalog() const; + // Панель загрузки занята скачиванием пака сезонной сборки. + bool packDownloadShown() const; + + // ── Установка сезонной сборки ─────────────────────────────────────── + // Каждый шаг либо запускает асинхронную операцию и возвращает true, либо + // решает, что делать нечего, и уступает следующему. + void seasonAdvance(); + bool seasonBeginSwitch(); + bool seasonBeginJava(); + bool seasonBeginLoader(); + bool seasonBeginDownload(); + bool seasonBeginApply(); + // message пусто — о причине уже сообщил тот, кто её обнаружил. + void seasonFail(const QString &message); + void seasonFinish(); + int indexOfSeasonalBuild(const QString &seasonalId) const; + // Что принесла установленная ревизия сборки; пусто — сезонных файлов нет. + QStringList seasonalPackEntries(int buildId) const; + // Запоминает раскатанную ревизию: и в builds//season.json, и в записи + // сборки. Вызывается и после доигрывания прерванной установки. + void recordSeasonalPack(int buildId, const QJsonObject ¬e, const QStringList &entries); + QUrl seasonalBaseUrl() const; // Панель загрузки занята установкой Java, а не версии или модлоадера. bool javaDownloadShown() const; // Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана @@ -277,6 +349,8 @@ private: BuildSwitcher *m_switcher; JavaRuntimeService *m_javaMeta; JavaInstaller *m_javaInstaller; + SeasonalBuildService *m_seasonalMeta; + SeasonalPackDownloader *m_packDownloader; // Сборка, для которой сейчас ставится модлоадер: по её id находим запись, // когда установщик отчитается. @@ -290,6 +364,11 @@ private: mutable QVariantList m_javaCatalog; mutable bool m_javaCatalogDirty = true; + mutable QVariantList m_seasonalCatalog; + mutable bool m_seasonalCatalogDirty = true; + + SeasonalInstall m_season; + bool m_busy = false; // Состояние запуска, начатого до асинхронной авторизации. diff --git a/launcherpaths.cpp b/launcherpaths.cpp index 4f558dd..cd2b1ef 100644 --- a/launcherpaths.cpp +++ b/launcherpaths.cpp @@ -66,6 +66,16 @@ QString LauncherPaths::buildStorageDir() return rootDir() + QStringLiteral("/builds"); } +QString LauncherPaths::buildDir(int buildId) +{ + return buildStorageDir() + u'/' + QString::number(buildId); +} + +QString LauncherPaths::seasonalStateFile(int buildId) +{ + return buildDir(buildId) + QStringLiteral("/season.json"); +} + QString LauncherPaths::runtimeDir() { return rootDir() + QStringLiteral("/runtime"); @@ -96,6 +106,11 @@ QString LauncherPaths::versionManifestFile() return cacheDir() + QStringLiteral("/version_manifest.json"); } +QString LauncherPaths::seasonalCatalogFile() +{ + return cacheDir() + QStringLiteral("/seasonal_builds.json"); +} + QString LauncherPaths::loaderCacheFile(const QString &loaderKey) { return cacheDir() + QStringLiteral("/loader_") + loaderKey + QStringLiteral(".json"); diff --git a/launcherpaths.h b/launcherpaths.h index ebe61af..ef93759 100644 --- a/launcherpaths.h +++ b/launcherpaths.h @@ -28,6 +28,15 @@ QString legacyCustomBuildsFile(); // /builds — архивы содержимого .minecraft по одному на сборку. QString buildStorageDir(); +// /builds/ — папка одной сборки: её архив и, у сезонных, скачанный +// пак с описанием установленной ревизии. +QString buildDir(int buildId); + +// /builds//season.json — какая ревизия сезонной сборки установлена и +// какие файлы она принесла. Список нужен, чтобы при обновлении убрать те, что +// из сборки ушли. +QString seasonalStateFile(int buildId); + // Сюда качается authlib-injector. QString runtimeDir(); @@ -47,6 +56,9 @@ QString cacheDir(); // Слепок манифеста версий Mojang с отметкой времени. QString versionManifestFile(); +// Слепок каталога сезонных сборок с отметкой времени. +QString seasonalCatalogFile(); + // Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt. QString loaderCacheFile(const QString &loaderKey); diff --git a/seasonalbuildservice.cpp b/seasonalbuildservice.cpp new file mode 100644 index 0000000..a4132f0 --- /dev/null +++ b/seasonalbuildservice.cpp @@ -0,0 +1,365 @@ +#include "seasonalbuildservice.h" + +#include "launcherpaths.h" +#include "modloader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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 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 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 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 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 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 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 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(); +} diff --git a/seasonalbuildservice.h b/seasonalbuildservice.h new file mode 100644 index 0000000..23ee7d8 --- /dev/null +++ b/seasonalbuildservice.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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; + + explicit SeasonalBuildService(QObject *parent = nullptr); + + QList 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 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 list); + QList parseCatalog(const QJsonObject &root, QString *error) const; + + QNetworkAccessManager *m_network; + QUrl m_baseUrl; + QList m_builds; + QHash m_indexById; + QDateTime m_fetchedAt; + QString m_lastError; + bool m_refreshing = false; + bool m_cacheRead = false; + QList m_pending; +}; diff --git a/seasonalpackdownloader.cpp b/seasonalpackdownloader.cpp new file mode 100644 index 0000000..0b8ad56 --- /dev/null +++ b/seasonalpackdownloader.cpp @@ -0,0 +1,216 @@ +#include "seasonalpackdownloader.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(); +} diff --git a/seasonalpackdownloader.h b/seasonalpackdownloader.h new file mode 100644 index 0000000..4c6bea0 --- /dev/null +++ b/seasonalpackdownloader.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include + +#include + +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 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; +};