From 168884127b010090196043db12a84e56e054eab0 Mon Sep 17 00:00:00 2001 From: galeon Date: Thu, 27 Aug 2026 09:21:51 +0300 Subject: [PATCH] new builds menu and modloaders implementation --- BuildsDialog.qml | 460 ++++++++++++++++ CMakeLists.txt | 16 +- DarkCombo.qml | 73 +++ LabelledField.qml | 39 ++ LoaderRow.qml | 185 +++++++ Main.qml | 1004 +++-------------------------------- ProgressPanel.qml | 90 ++++ VersionCatalogCombo.qml | 250 +++++++++ buildarchiveworker.cpp | 220 ++++++++ buildarchiveworker.h | 45 ++ buildswitcher.cpp | 438 +++++++++++++++ buildswitcher.h | 93 ++++ launcherbackend.cpp | 580 +++++++++++++++++--- launcherbackend.h | 106 +++- launcherpaths.cpp | 25 +- launcherpaths.h | 17 +- modloader.h | 54 ++ modloaderinstaller.cpp | 552 +++++++++++++++++++ modloaderinstaller.h | 106 ++++ modloaderversionservice.cpp | 520 ++++++++++++++++++ modloaderversionservice.h | 96 ++++ versioninstaller.cpp | 89 ++-- versioninstaller.h | 2 + 23 files changed, 3979 insertions(+), 1081 deletions(-) create mode 100644 BuildsDialog.qml create mode 100644 DarkCombo.qml create mode 100644 LabelledField.qml create mode 100644 LoaderRow.qml create mode 100644 ProgressPanel.qml create mode 100644 VersionCatalogCombo.qml create mode 100644 buildarchiveworker.cpp create mode 100644 buildarchiveworker.h create mode 100644 buildswitcher.cpp create mode 100644 buildswitcher.h create mode 100644 modloader.h create mode 100644 modloaderinstaller.cpp create mode 100644 modloaderinstaller.h create mode 100644 modloaderversionservice.cpp create mode 100644 modloaderversionservice.h diff --git a/BuildsDialog.qml b/BuildsDialog.qml new file mode 100644 index 0000000..37a1426 --- /dev/null +++ b/BuildsDialog.qml @@ -0,0 +1,460 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 2.15 + +// Пользовательские сборки одним окном: слева список со сменой активной, +// справа карточка выбранной — имя, сервер, версия Minecraft и модлоадеры. +// +// Карточка сохраняет правки по ходу редактирования, отдельной кнопки +// «Сохранить» нет: иначе появляется неочевидное несохранённое состояние, +// пока пользователь переключается между сборками в левом списке. +Dialog { + id: buildsDialog + + required property var backend + + // Сборка, открытая в карточке справа. С активной не связана: активную + // меняет отдельная кнопка, чтобы случайный клик по списку не запускал + // архивацию .minecraft. + property int editIndex: -1 + // Пока карточка заполняется, обработчики полей не должны писать обратно. + property bool loading: false + + modal: true + padding: 0 + width: 880 + height: 600 + closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose + : Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#1e1e1e" + radius: 10 + border.color: "#91B315" + border.width: 1 + } + + function selectBuild(index) { + const data = buildsDialog.backend.customBuildAt(index) + buildsDialog.loading = true + buildsDialog.editIndex = index + bdName.text = data.name || "" + bdServer.text = data.serverUrl || "" + bdMinecraft.selectedId = data.minecraftVersion || "" + loaderPanel.applyBuild(data.loader || "", data.loaderVersion || "") + buildsDialog.loading = false + buildsDialog.refreshStatus() + } + + function commit(fields) { + if (buildsDialog.loading || buildsDialog.editIndex < 0) + return + buildsDialog.backend.updateCustomBuild(buildsDialog.editIndex, fields) + buildsDialog.refreshStatus() + } + + function refreshStatus() { + if (buildsDialog.editIndex < 0) { + bdStatus.text = "" + return + } + const problems = buildsDialog.backend.checkInstallation(buildsDialog.editIndex) + bdStatus.ok = problems.length === 0 + bdStatus.text = problems.length === 0 + ? qsTr("Всё на месте, сборку можно запускать") + : qsTr("Не хватает файлов (%1): %2").arg(problems.length).arg(problems[0]) + } + + function newBuildName() { + const taken = buildsDialog.backend.customBuildNames + for (var n = 1; ; ++n) { + const candidate = qsTr("Сборка %1").arg(n) + if (taken.indexOf(candidate) === -1) + return candidate + } + } + + // Установка версии или модлоадера меняет комплектность сборки — строка + // состояния должна это заметить, не дожидаясь переоткрытия окна. + Connections { + target: buildsDialog.backend + function onInstalledVersionsChanged() { buildsDialog.refreshStatus() } + } + + onAboutToShow: { + buildsDialog.backend.refreshVersionCatalog() + const names = buildsDialog.backend.customBuildNames + if (names.length === 0) + buildsDialog.editIndex = -1 + else + buildsDialog.selectBuild(Math.max(0, buildsDialog.backend.activeBuildIndex)) + } + + 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: RowLayout { + spacing: 0 + + // ── Список сборок ─────────────────────────────────────────────── + Item { + Layout.preferredWidth: 260 + Layout.fillHeight: true + enabled: !buildsDialog.backend.switching + + ListView { + id: buildList + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: addBuildRow.top + anchors.margins: 8 + clip: true + spacing: 2 + model: buildsDialog.backend.customBuildNames + + delegate: ItemDelegate { + id: buildRow + required property string modelData + required property int index + + width: buildList.width + height: 40 + + contentItem: Item { + Text { + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.right: activeBadge.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: buildRow.modelData + color: "#ffffff" + elide: Text.ElideRight + font.pixelSize: 13 + } + Text { + id: activeBadge + anchors.right: buildTrash.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + visible: buildRow.index === buildsDialog.backend.activeBuildIndex + text: qsTr("активна") + color: "#91B315" + font.pixelSize: 10 + } + Image { + id: buildTrash + width: 16; height: 16 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + visible: buildRow.hovered + opacity: buildTrashArea.containsMouse ? 1.0 : 0.7 + source: "images/Trash.svg" + sourceSize.width: 16; sourceSize.height: 16 + fillMode: Image.PreserveAspectFit + + MouseArea { + id: buildTrashArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + onClicked: { + buildsDialog.backend.removeCustomBuild(buildRow.index) + const left = buildsDialog.backend.customBuildNames.length + if (left === 0) + buildsDialog.editIndex = -1 + else + buildsDialog.selectBuild(Math.min(buildRow.index, left - 1)) + } + } + } + } + + background: Rectangle { + color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered) + ? "#91B315" : "#232323" + radius: 5 + } + + onClicked: buildsDialog.selectBuild(buildRow.index) + } + + ScrollIndicator.vertical: ScrollIndicator {} + } + + Rectangle { + id: addBuildRow + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 8 + height: 34 + radius: 5 + color: addBuildArea.containsPress ? "#2d2d2d" : "transparent" + + Text { + anchors.centerIn: parent + text: qsTr("+ Новая сборка") + color: "#91B315" + font.pixelSize: 12 + } + + MouseArea { + id: addBuildArea + anchors.fill: parent + onClicked: { + buildsDialog.backend.addCustomBuild(buildsDialog.newBuildName(), "", "") + buildsDialog.selectBuild(buildsDialog.backend.customBuildNames.length - 1) + } + } + } + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: "#333333" + } + + // ── Карточка выбранной сборки ─────────────────────────────────── + Item { + Layout.fillWidth: true + Layout.fillHeight: true + enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.switching + opacity: enabled ? 1.0 : 0.4 + + Flickable { + anchors.fill: parent + anchors.margins: 20 + contentHeight: detailsColumn.height + clip: true + boundsBehavior: Flickable.StopAtBounds + ScrollIndicator.vertical: ScrollIndicator {} + + Column { + id: detailsColumn + width: parent.width + spacing: 12 + + LabelledField { + id: bdName + width: parent.width + label: qsTr("Название сборки") + placeholder: qsTr("как её видно в списке") + onEditingFinished: buildsDialog.commit({"name": bdName.text.trim()}) + } + + LabelledField { + id: bdServer + width: parent.width + label: qsTr("Адрес сервера") + placeholder: qsTr("host:port (необязательно)") + onEditingFinished: buildsDialog.commit({"serverUrl": bdServer.text.trim()}) + } + + Text { + text: qsTr("Версия Minecraft") + color: "#aaaaaa" + font.pixelSize: 11 + } + + VersionCatalogCombo { + id: bdMinecraft + width: parent.width + catalog: buildsDialog.backend.versionCatalog + onAboutToOpen: buildsDialog.backend.refreshVersionCatalog() + onSelectedIdChanged: buildsDialog.commit({"minecraftVersion": + bdMinecraft.selectedId}) + } + + Text { + text: qsTr("Модлоадер") + color: "#aaaaaa" + font.pixelSize: 11 + } + + // Лоадеры несовместимы между собой: игра запускается ровно с + // одним профилем versions/. Поэтому отметка одного снимает + // остальные, а «ничего не отмечено» — это чистая ваниль. + Column { + id: loaderPanel + width: parent.width + spacing: 8 + + property var rows: [bdForge, bdFabric, bdNeoForge, bdQuilt] + + function applyBuild(loader, loaderVersion) { + for (var i = 0; i < loaderPanel.rows.length; ++i) + loaderPanel.rows[i].applyBuild(loader, loaderVersion) + } + + function selectedRow() { + for (var i = 0; i < loaderPanel.rows.length; ++i) { + if (loaderPanel.rows[i].checked) + return loaderPanel.rows[i] + } + return null + } + + function keepOnly(row) { + for (var i = 0; i < loaderPanel.rows.length; ++i) { + if (loaderPanel.rows[i] === row) + continue + loaderPanel.rows[i].applyBuild("", "") + } + } + + function commitSelection() { + const row = loaderPanel.selectedRow() + buildsDialog.commit({ + "loader": row ? row.loaderKey : "", + "loaderVersion": row ? row.selectedVersion : "", + // Профиль появится только после установки: пока + // сборка запускается на чистой ванили. + "resolvedVersionId": "" + }) + } + + LoaderRow { + id: bdForge + width: parent.width + backend: buildsDialog.backend + loaderKey: "forge" + title: qsTr("Minecraft Forge") + gameVersion: bdMinecraft.selectedId + onUserChecked: loaderPanel.keepOnly(bdForge) + onChanged: loaderPanel.commitSelection() + } + LoaderRow { + id: bdFabric + width: parent.width + backend: buildsDialog.backend + loaderKey: "fabric" + title: qsTr("Fabric Loader") + gameVersion: bdMinecraft.selectedId + onUserChecked: loaderPanel.keepOnly(bdFabric) + onChanged: loaderPanel.commitSelection() + } + LoaderRow { + id: bdNeoForge + width: parent.width + backend: buildsDialog.backend + loaderKey: "neoforge" + title: qsTr("NeoForge") + gameVersion: bdMinecraft.selectedId + onUserChecked: loaderPanel.keepOnly(bdNeoForge) + onChanged: loaderPanel.commitSelection() + } + LoaderRow { + id: bdQuilt + width: parent.width + backend: buildsDialog.backend + loaderKey: "quilt" + title: qsTr("Quilt Loader") + gameVersion: bdMinecraft.selectedId + onUserChecked: loaderPanel.keepOnly(bdQuilt) + onChanged: loaderPanel.commitSelection() + } + } + + Text { + id: bdStatus + property bool ok: false + width: parent.width + color: bdStatus.ok ? "#8fbf5a" : "#cc6666" + font.pixelSize: 11 + wrapMode: Text.Wrap + } + } + } + } + } + + footer: Item { + implicitHeight: 60 + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: "#333333" + } + Row { + anchors.centerIn: parent + spacing: 12 + + Button { + text: qsTr("Установить") + width: 140; height: 36 + enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.busy + opacity: enabled ? 1.0 : 0.45 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#444444" : "#333333" + radius: 6 + } + onClicked: buildsDialog.backend.installCustomBuild(buildsDialog.editIndex) + } + + Button { + text: qsTr("Сделать активной") + width: 170; height: 36 + enabled: buildsDialog.editIndex >= 0 + && buildsDialog.editIndex !== buildsDialog.backend.activeBuildIndex + && !buildsDialog.backend.busy + opacity: enabled ? 1.0 : 0.45 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#6a8510" : "#91B315" + radius: 6 + } + onClicked: buildsDialog.backend.activeBuildIndex = buildsDialog.editIndex + } + + Button { + text: qsTr("Закрыть") + width: 120; height: 36 + enabled: !buildsDialog.backend.switching + opacity: enabled ? 1.0 : 0.45 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#444444" : "#333333" + radius: 6 + } + onClicked: buildsDialog.close() + } + } + } +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 1bdc0e1..2d70ed3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,9 +32,23 @@ qt_add_qml_module(appMinecraft_launcher versionmanifestservice.cpp versioninstaller.h versioninstaller.cpp + modloader.h + modloaderversionservice.h + modloaderversionservice.cpp + modloaderinstaller.h + modloaderinstaller.cpp + buildarchiveworker.h + buildarchiveworker.cpp + buildswitcher.h + buildswitcher.cpp QML_FILES Main.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.svg 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 + DarkCombo.qml + VersionCatalogCombo.qml + LabelledField.qml + BuildsDialog.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/DarkCombo.qml b/DarkCombo.qml new file mode 100644 index 0000000..ccf467c --- /dev/null +++ b/DarkCombo.qml @@ -0,0 +1,73 @@ +import QtQuick +import QtQuick.Controls 2.15 + +// Комбобокс в общем тёмном стиле окна. +ComboBox { + id: darkCombo + + // Та же стрелка, что у комбобоксов профиля и версии на главном окне, + // вместо двойного шеврона Basic-стиля. + indicator: Image { + width: 10; height: 10 + x: darkCombo.width - width - 12 + y: (darkCombo.height - height) / 2 + source: darkCombo.down ? "images/Profile_Box/Asset_23.svg" + : "images/Profile_Box/Asset_24.svg" + rotation: 180 + sourceSize.width: 10; sourceSize.height: 10 + fillMode: Image.PreserveAspectFit + } + + contentItem: Text { + leftPadding: 10 + rightPadding: darkCombo.indicator.width + 22 + text: darkCombo.displayText + color: "#ffffff" + font: darkCombo.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + color: "#2a2a2a" + radius: 6 + border.color: darkCombo.activeFocus ? "#91B315" : "#444444" + border.width: 1 + } + + delegate: ItemDelegate { + required property var modelData + required property int index + + width: darkCombo.width + highlighted: darkCombo.highlightedIndex === index + contentItem: Text { + text: modelData + color: "#ffffff" + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { color: highlighted ? "#3a3a3a" : "#2a2a2a" } + } + + popup: Popup { + y: darkCombo.height + width: darkCombo.width + implicitHeight: Math.min(contentItem.implicitHeight, 220) + padding: 1 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: darkCombo.delegateModel + currentIndex: darkCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + background: Rectangle { + color: "#2a2a2a" + radius: 6 + border.color: "#444444" + border.width: 1 + } + } +} diff --git a/LabelledField.qml b/LabelledField.qml new file mode 100644 index 0000000..cbf27e1 --- /dev/null +++ b/LabelledField.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtQuick.Controls 2.15 + +// Подпись + поле ввода одной колонкой — используется в настройках. +Column { + id: labelled + + property alias text: field.text + property alias validator: field.validator + property string label: "" + property string placeholder: "" + + // Правка закончена: поле потеряло фокус или пользователь нажал Enter. + signal editingFinished() + + spacing: 3 + + Text { + text: labelled.label + color: "#aaaaaa" + font.pixelSize: 11 + } + + TextField { + id: field + width: labelled.width + onEditingFinished: labelled.editingFinished() + height: 32 + placeholderText: labelled.placeholder + color: "#ffffff" + placeholderTextColor: "#666666" + background: Rectangle { + color: "#2a2a2a" + radius: 6 + border.color: field.activeFocus ? "#91B315" : "#444444" + border.width: 1 + } + } +} diff --git a/LoaderRow.qml b/LoaderRow.qml new file mode 100644 index 0000000..9242e46 --- /dev/null +++ b/LoaderRow.qml @@ -0,0 +1,185 @@ +import QtQuick +import QtQuick.Controls 2.15 + +// Один модлоадер в карточке сборки: чекбокс и, когда он отмечен, список версий +// именно под выбранную версию Minecraft. +// +// Совместимость проверять не нужно: backend.loaderVersions() отдаёт список уже +// под конкретную версию игры, поэтому несовместимой строки в нём не бывает. +// Пустой список — это «лоадер не поддерживает эту версию игры», и он тоже +// показывается словами, а не пустым выпадающим списком. +Column { + id: loaderRow + + required property var backend + required property string loaderKey + required property string title + + // Версия Minecraft, выбранная в карточке. Её смена сбрасывает выбор. + property string gameVersion: "" + + property alias checked: box.checked + property string selectedVersion: "" + property var entries: [] + + // Пользователь щёлкнул чекбокс — карточка снимет отметки с остальных + // лоадеров: одновременно в .minecraft может жить только один. + signal userChecked() + // Отметка или версия изменились — карточке пора сохранить сборку. + signal changed() + + // Ставится, пока карточка заполняет строку данными сборки: в это время + // обработчики не должны считать изменения пользовательскими. + property bool applying: false + + spacing: 4 + + function versionIndex(version) { + for (var i = 0; i < loaderRow.entries.length; ++i) { + if (loaderRow.entries[i].version === version) + return i + } + return -1 + } + + // Самая свежая стабильная сборка, а если стабильных нет — просто самая + // свежая. Список отсортирован новыми вперёд, поэтому хватает первого + // совпадения. + function preferredIndex() { + for (var i = 0; i < loaderRow.entries.length; ++i) { + if (loaderRow.entries[i].recommended) + return i + } + for (var j = 0; j < loaderRow.entries.length; ++j) { + if (loaderRow.entries[j].stable) + return j + } + return loaderRow.entries.length > 0 ? 0 : -1 + } + + function syncCombo() { + const index = loaderRow.versionIndex(loaderRow.selectedVersion) + combo.currentIndex = index + } + + function reload() { + if (!box.checked || loaderRow.gameVersion === "") { + loaderRow.entries = [] + return + } + // Сначала из кэша — список появляется мгновенно, — потом обновление сети. + loaderRow.entries = loaderRow.backend.loaderVersions(loaderRow.loaderKey, + loaderRow.gameVersion) + loaderRow.syncCombo() + loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion) + } + + // Заполнение из сохранённой сборки: без сигналов changed(). + function applyBuild(loader, loaderVersion) { + loaderRow.applying = true + box.checked = loader === loaderRow.loaderKey + loaderRow.selectedVersion = box.checked ? loaderVersion : "" + loaderRow.reload() + loaderRow.applying = false + } + + onGameVersionChanged: { + // Сборка лоадера привязана к версии игры — при её смене выбор + // сбрасывается, и список перезапрашивается под новую версию. + loaderRow.entries = [] + loaderRow.selectedVersion = "" + combo.currentIndex = -1 + loaderRow.reload() + } + + Connections { + target: loaderRow.backend + function onLoaderVersionsChanged(key, game) { + if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked) + return + loaderRow.entries = loaderRow.backend.loaderVersions(key, game) + if (loaderRow.selectedVersion === "" || loaderRow.versionIndex(loaderRow.selectedVersion) < 0) { + const preferred = loaderRow.preferredIndex() + if (preferred >= 0) { + loaderRow.selectedVersion = loaderRow.entries[preferred].version + if (!loaderRow.applying) + loaderRow.changed() + } + } + loaderRow.syncCombo() + } + } + + CheckBox { + id: box + text: loaderRow.title + enabled: loaderRow.gameVersion !== "" + + indicator: Rectangle { + implicitWidth: 18 + implicitHeight: 18 + x: 0 + y: (box.height - height) / 2 + radius: 4 + color: box.checked ? "#91B315" : "#2a2a2a" + border.color: box.checked ? "#91B315" : "#444444" + border.width: 1 + + Text { + anchors.centerIn: parent + visible: box.checked + text: "✓" + color: "#1e1e1e" + font.pixelSize: 13 + font.bold: true + } + } + + contentItem: Text { + leftPadding: box.indicator.width + 8 + text: box.text + color: box.enabled ? "#ffffff" : "#666666" + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + + onToggled: { + if (box.checked) + loaderRow.userChecked() + else + loaderRow.selectedVersion = "" + loaderRow.reload() + loaderRow.changed() + } + } + + DarkCombo { + id: combo + visible: box.checked && loaderRow.entries.length > 0 + width: loaderRow.width - 26 + height: 32 + x: 26 + model: loaderRow.entries.map(entry => entry.label) + + onActivated: (index) => { + if (index < 0 || index >= loaderRow.entries.length) + return + loaderRow.selectedVersion = loaderRow.entries[index].version + loaderRow.changed() + } + } + + Text { + visible: box.checked && loaderRow.entries.length === 0 + x: 26 + width: loaderRow.width - 26 + wrapMode: Text.Wrap + font.pixelSize: 11 + color: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion) + ? "#888888" : "#cc6666" + text: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion) + ? qsTr("Загрузка списка версий %1…").arg(loaderRow.title) + : qsTr("%1 не поддерживает Minecraft %2").arg(loaderRow.title) + .arg(loaderRow.gameVersion) + } +} diff --git a/Main.qml b/Main.qml index 3afa0e0..95983a9 100644 --- a/Main.qml +++ b/Main.qml @@ -15,8 +15,8 @@ Window { LauncherBackend { id: backend - onLaunched: (profileName, versionName, serverUrl) => { - window.showToast(qsTr("Запущено: %1 — %2").arg(profileName).arg(versionName), "#4b7a1f") + onLaunched: (profileName, buildName, serverUrl) => { + window.showToast(qsTr("Запущено: %1 — %2").arg(profileName).arg(buildName), "#4b7a1f") } onLaunchProgress: (message) => window.showToast(message, "#3a5a8c", 0) onLaunchError: (message) => window.showToast(message, "#cc3333", 8000) @@ -67,7 +67,7 @@ Window { visible: toastLabel.text !== "" anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: downloadPanel.visible ? 110 : 40 + anchors.bottomMargin: (downloadPanel.visible || switchPanel.visible) ? 110 : 40 width: Math.min(window.width - 80, toastLabel.implicitWidth + 32) height: toastLabel.implicitHeight + 20 radius: 8 @@ -90,92 +90,43 @@ Window { } } - // ── Download panel ───────────────────────────────────────────────────── - // Левый нижний угол: не задевает кнопку запуска, плашку по центру и - // кнопки папки с настройками. - Rectangle { + // ── Панели хода работ ────────────────────────────────────────────────── + // Загрузка и смена сборки взаимоисключающи по построению: busy() не даёт + // начать переключение во время установки и наоборот. Поэтому у панелей + // одни и те же якоря. + ProgressPanel { id: downloadPanel visible: backend.downloading anchors.left: parent.left anchors.bottom: parent.bottom anchors.leftMargin: 24 anchors.bottomMargin: 24 - width: 320 - height: 72 - radius: 8 - color: "#1e1e1e" - opacity: 0.94 - border.color: "#91B315" - border.width: 1 - Text { - x: 12; y: 8 - width: parent.width - 70 - elide: Text.ElideRight - color: "#ffffff" - font.pixelSize: 12 - font.bold: true - text: qsTr("Загрузка %1").arg(backend.downloadVersion) - } + title: qsTr("Загрузка %1").arg(backend.downloadVersion) + fraction: backend.downloadProgress + status: backend.downloadStatus + detail: backend.downloadBytesTotal > 0 + ? qsTr("%1 — %2 / %3 МБ").arg(backend.downloadStatus) + .arg(window.formatMb(backend.downloadBytesDone)) + .arg(window.formatMb(backend.downloadBytesTotal)) + : "" + onCancelRequested: backend.cancelDownload() + } - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - y: 8 - color: "#91B315" - font.pixelSize: 12 - // Пока не известен общий объём, доля равна -1. - text: backend.downloadProgress < 0 - ? "…" : Math.round(backend.downloadProgress * 100) + "%" - } + ProgressPanel { + id: switchPanel + visible: backend.switching + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.leftMargin: 24 + anchors.bottomMargin: 24 - Rectangle { - id: dlTrack - x: 12; y: 30 - width: parent.width - 24 - height: 6 - radius: 3 - color: "#2a2a2a" - - Rectangle { - width: backend.downloadProgress > 0 ? dlTrack.width * backend.downloadProgress : 0 - height: parent.height - radius: 3 - color: "#91B315" - Behavior on width { NumberAnimation { duration: 120 } } - } - } - - Text { - x: 12; y: 44 - width: parent.width - 40 - elide: Text.ElideMiddle - color: "#888888" - font.pixelSize: 10 - text: backend.downloadBytesTotal > 0 - ? qsTr("%1 — %2 / %3 МБ").arg(backend.downloadStatus) - .arg(window.formatMb(backend.downloadBytesDone)) - .arg(window.formatMb(backend.downloadBytesTotal)) - : backend.downloadStatus - } - - Text { - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.rightMargin: 10 - anchors.bottomMargin: 6 - text: "✕" - color: cancelDownloadArea.containsMouse ? "#cc6666" : "#666666" - font.pixelSize: 12 - - MouseArea { - id: cancelDownloadArea - anchors.fill: parent - anchors.margins: -6 - hoverEnabled: true - onClicked: backend.cancelDownload() - } - } + title: backend.switchStage + fraction: backend.switchProgress + status: backend.switchStatus + // Отменять можно только пока идёт архивация: после очистки .minecraft + // отступать некуда, операцию нужно довести до конца. + cancellable: false } // ── Play button ──────────────────────────────────────────────────────── @@ -195,7 +146,7 @@ Window { onPressed: buttonImage.source = "images/Play_Button/Play_pressed.svg" onReleased: buttonImage.source = "images/Play_Button/Play_Active.svg" - onClicked: backend.launchGame(profileBox.currentIndex, versionBox.currentIndex) + onClicked: backend.launchGame(profileBox.currentIndex, backend.activeBuildIndex) } // ── Profile ComboBox ─────────────────────────────────────────────────── @@ -385,9 +336,11 @@ Window { } } - // ── Version ComboBox ─────────────────────────────────────────────────── - ComboBox { - id: versionBox + // ── Активная сборка ──────────────────────────────────────────────────── + // Кнопка занимает место бывшего выпадающего списка версий: и сама сборка, + // и её содержимое правятся теперь в модальном окне. + Button { + id: buildButton x: 624 y: 451 width: 183 @@ -396,179 +349,44 @@ Window { anchors.bottom: button.top anchors.rightMargin: 0 anchors.bottomMargin: -218 - editable: false - - model: backend.versionNames - - indicator: Image { - id: versionArrow - width: 10; height: 10 - visible: true - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - source: versionBox.down ? "images/Profile_Box/Asset_23.svg" - : "images/Profile_Box/Asset_24.svg" - rotation: 180 - sourceSize.width: 10; sourceSize.height: 10 - fillMode: Image.PreserveAspectFit - autoTransform: true - } + hoverEnabled: true + enabled: !backend.gameRunning && !backend.switching contentItem: Text { - id: versionText - text: versionBox.displayText - font: versionBox.font + text: backend.activeBuildName !== "" ? backend.activeBuildName + : qsTr("Сборка не выбрана") color: "#ffffff" + font: buildButton.font verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignLeft elide: Text.ElideRight - anchors.fill: parent - anchors.leftMargin: 30 - anchors.rightMargin: 5 - anchors.topMargin: 5 - anchors.bottomMargin: 5 + leftPadding: 30 + rightPadding: 5 } background: Rectangle { - implicitWidth: 120; implicitHeight: 40 - color: versionBox.down ? "#91B315" : "#232323" + color: buildButton.pressed || buildsDialog.opened ? "#91B315" : "#232323" radius: 6 - border.color: versionBox.pressed ? "#ffffff" : "#232323" - border.width: versionBox.visualFocus ? 2 : 1 - } + border.color: buildButton.hovered ? "#91B315" : "#232323" + border.width: 1 - popup: Popup { - y: versionBox.height - 1 - width: versionBox.width - padding: 0 - height: 32 + Math.min(versionItemList.contentHeight, 200) - - contentItem: Item { - anchors.fill: parent - - // ── "+" button — всегда сверху ────────────────────────── - Rectangle { - id: versionAddBtnBg - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 32 - color: versionAddArea.containsPress ? "#2d2d2d" : "transparent" - - Text { - anchors.centerIn: parent - text: "+ Добавить версию" - color: "#91B315" - font.pixelSize: 12 - } - - MouseArea { - id: versionAddArea - anchors.fill: parent - onClicked: { - versionBox.popup.close() - addVersionDialog.open() - } - } - } - - // ── Добавленные версии — всегда ниже кнопки ──────────── - ListView { - id: versionItemList - anchors.top: versionAddBtnBg.bottom - anchors.left: parent.left - anchors.right: parent.right - height: Math.min(contentHeight, 200) - clip: true - model: backend.versionNames - - delegate: ItemDelegate { - id: versionItem - required property string modelData - required property int index - width: versionItemList.width - height: 40 - contentItem: Text { - text: versionItem.modelData - color: "#ffffff" - font: versionBox.font - elide: Text.ElideRight - horizontalAlignment: Text.AlignLeft - verticalAlignment: Text.AlignVCenter - leftPadding: 8 - rightPadding: 62 - } - background: Rectangle { - color: (versionBox.currentIndex === versionItem.index || versionItem.hovered) - ? "#91B315" : "#232323" - radius: 5 - } - onClicked: { - versionBox.currentIndex = versionItem.index - versionBox.popup.close() - } - - // ── Карандаш — видна при наведении на строку ──── - Image { - id: versionPencil - width: 16; height: 16 - anchors.right: versionTrash.left - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - visible: versionItem.hovered - opacity: versionPencilArea.containsMouse ? 1.0 : 0.7 - source: "images/Pencil.svg" - sourceSize.width: 16; sourceSize.height: 16 - fillMode: Image.PreserveAspectFit - - MouseArea { - id: versionPencilArea - anchors.fill: parent - anchors.margins: -6 - hoverEnabled: true - onClicked: editVersionDialog.openFor(versionItem.index) - } - } - - // ── Корзина — видна при наведении на строку ───── - Image { - id: versionTrash - width: 16; height: 16 - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - visible: versionItem.hovered - opacity: versionTrashArea.containsMouse ? 1.0 : 0.7 - source: "images/Trash.svg" - sourceSize.width: 16; sourceSize.height: 16 - fillMode: Image.PreserveAspectFit - - MouseArea { - id: versionTrashArea - anchors.fill: parent - anchors.margins: -6 - hoverEnabled: true - onClicked: backend.removeVersion(versionItem.index) - } - } - } - - ScrollIndicator.vertical: ScrollIndicator { - parent: versionItemList.parent - anchors.top: versionItemList.top - anchors.left: versionItemList.right - anchors.bottom: versionItemList.bottom - } - } - } - - background: Rectangle { - color: "#232323" - radius: 6 - border.color: "#232323" + Image { + width: 10; height: 10 + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + source: buildsDialog.opened ? "images/Profile_Box/Asset_23.svg" + : "images/Profile_Box/Asset_24.svg" + rotation: 180 + sourceSize.width: 10; sourceSize.height: 10 + fillMode: Image.PreserveAspectFit } } + + ToolTip.visible: hovered && !buildsDialog.opened + ToolTip.text: qsTr("Пользовательские сборки") + + onClicked: buildsDialog.open() } // ── Open mods folder button ──────────────────────────────────────────── @@ -576,7 +394,7 @@ Window { id: folderButton width: 70 height: 40 - anchors.left: versionBox.left + anchors.left: buildButton.left anchors.bottom: profileBox.bottom anchors.leftMargin: -59 anchors.bottomMargin: -45 @@ -777,164 +595,6 @@ Window { } } - // ── Add Version Dialog ───────────────────────────────────────────────── - Dialog { - id: addVersionDialog - modal: true - width: 320 - x: (window.width - width) / 2 - y: (window.height - height) / 2 - padding: 0 - - background: Rectangle { - color: "#1e1e1e" - radius: 10 - border.color: "#91B315" - border.width: 1 - } - - header: Item { - implicitHeight: 52 - Text { - anchors.centerIn: parent - text: "Новая версия" - color: "#ffffff" - font.pixelSize: 17 - font.bold: true - } - Rectangle { - anchors.bottom: parent.bottom - width: parent.width - height: 1 - color: "#333333" - } - } - - contentItem: Column { - spacing: 12 - topPadding: 20 - bottomPadding: 20 - - TextField { - id: verName - x: 20 - width: parent.width - 40 - placeholderText: "Название версии" - color: "#ffffff" - placeholderTextColor: "#666666" - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: verName.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - } - - TextField { - id: verServer - x: 20 - width: parent.width - 40 - placeholderText: "Адрес сервера, host:port (необязательно)" - color: "#ffffff" - placeholderTextColor: "#666666" - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: verServer.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - } - - // Установленные версии и весь каталог Mojang одним списком. - VersionCatalogCombo { - id: verVersion - x: 20 - width: parent.width - 40 - catalog: backend.versionCatalog - onAboutToOpen: backend.refreshVersionCatalog() - } - - Text { - x: 20 - width: parent.width - 40 - text: verVersion.selectedId === "" - ? qsTr("Выберите версию из списка") - : backend.isVersionInstalled(verVersion.selectedId) - ? qsTr("Версия уже установлена в .minecraft") - : qsTr("Версия будет загружена в .minecraft после сохранения") - color: verVersion.selectedId !== "" - && !backend.isVersionInstalled(verVersion.selectedId) - ? "#91B315" : "#888888" - font.pixelSize: 11 - wrapMode: Text.Wrap - } - } - - footer: Item { - implicitHeight: 60 - Rectangle { - anchors.top: parent.top - width: parent.width - height: 1 - color: "#333333" - } - Row { - anchors.centerIn: parent - spacing: 12 - - Button { - text: "Отмена" - width: 110; height: 36 - contentItem: Text { - text: parent.text - color: "#ffffff" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: parent.pressed ? "#444444" : "#333333" - radius: 6 - } - onClicked: addVersionDialog.reject() - } - - Button { - text: "Добавить" - width: 110; height: 36 - contentItem: Text { - text: parent.text - color: "#ffffff" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: parent.pressed ? "#6a8510" : "#91B315" - radius: 6 - } - onClicked: addVersionDialog.accept() - } - } - } - - // Манифест прогреваем заранее — тогда список открывается мгновенно. - onAboutToShow: { - verVersion.selectedId = "" - backend.refreshVersionCatalog() - } - - onAccepted: { - const name = verName.text.trim() - if (name !== "") { - backend.addVersion(name, verServer.text.trim(), verVersion.selectedId) - versionBox.currentIndex = backend.versionNames.length - 1 - } - verName.text = ""; verServer.text = ""; verVersion.selectedId = "" - } - onRejected: { - verName.text = ""; verServer.text = ""; verVersion.selectedId = "" - } - } - // ── Edit Profile Dialog ──────────────────────────────────────────────── // Идентичен диалогу создания, но с предзаполненными данными выбранного // профиля. По нажатию ОК данные перезаписываются под тем же индексом. @@ -1103,190 +763,12 @@ Window { } } - // ── Edit Version Dialog ──────────────────────────────────────────────── - // Идентичен диалогу создания, но с предзаполненными данными выбранной - // версии. По нажатию ОК данные перезаписываются под тем же индексом. - Dialog { - id: editVersionDialog - modal: true - width: 380 + // ── Builds Dialog ────────────────────────────────────────────────────── + BuildsDialog { + id: buildsDialog + backend: backend x: (window.width - width) / 2 y: (window.height - height) / 2 - padding: 0 - - property int editIndex: -1 - - function openFor(index) { - const data = backend.versionAt(index) - editIndex = index - evName.text = data.name || "" - evServer.text = data.serverUrl || "" - evVersion.selectedId = data.versionId || "" - backend.refreshVersionCatalog() - - const problems = backend.checkInstallation(index) - evStatus.text = problems.length === 0 - ? qsTr("Всё на месте, версию можно запускать") - : qsTr("Не хватает файлов (%1): %2").arg(problems.length).arg(problems[0]) - - versionBox.popup.close() - open() - } - - background: Rectangle { - color: "#1e1e1e" - radius: 10 - border.color: "#91B315" - border.width: 1 - } - - header: Item { - implicitHeight: 52 - Text { - anchors.centerIn: parent - text: "Редактирование версии" - color: "#ffffff" - font.pixelSize: 17 - font.bold: true - } - Rectangle { - anchors.bottom: parent.bottom - width: parent.width - height: 1 - color: "#333333" - } - } - - contentItem: Column { - spacing: 12 - topPadding: 20 - bottomPadding: 20 - - TextField { - id: evName - x: 20 - width: parent.width - 40 - placeholderText: "Название версии" - color: "#ffffff" - placeholderTextColor: "#666666" - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: evName.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - } - - TextField { - id: evServer - x: 20 - width: parent.width - 40 - placeholderText: "Адрес сервера, host:port (необязательно)" - color: "#ffffff" - placeholderTextColor: "#666666" - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: evServer.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - } - - VersionCatalogCombo { - id: evVersion - x: 20 - width: parent.width - 40 - catalog: backend.versionCatalog - onAboutToOpen: backend.refreshVersionCatalog() - } - - // Показываем, чего не хватает в .minecraft именно для этой версии. - Text { - id: evStatus - x: 20 - width: parent.width - 40 - color: text.indexOf("Всё на месте") === 0 ? "#8fbf5a" : "#cc6666" - font.pixelSize: 11 - wrapMode: Text.Wrap - } - } - - footer: Item { - implicitHeight: 60 - Rectangle { - anchors.top: parent.top - width: parent.width - height: 1 - color: "#333333" - } - Row { - anchors.centerIn: parent - spacing: 12 - - // Починка: докачать то, чего не хватает выбранной версии. - Button { - text: "Скачать" - width: 110; height: 36 - enabled: evVersion.selectedId !== "" && !backend.downloading - opacity: enabled ? 1.0 : 0.45 - contentItem: Text { - text: parent.text - color: "#ffffff" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: parent.pressed ? "#444444" : "#333333" - radius: 6 - } - onClicked: backend.installVersion(evVersion.selectedId) - } - - Button { - text: "Отмена" - width: 110; height: 36 - contentItem: Text { - text: parent.text - color: "#ffffff" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: parent.pressed ? "#444444" : "#333333" - radius: 6 - } - onClicked: editVersionDialog.reject() - } - - Button { - text: "ОК" - width: 110; height: 36 - contentItem: Text { - text: parent.text - color: "#ffffff" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: parent.pressed ? "#6a8510" : "#91B315" - radius: 6 - } - onClicked: editVersionDialog.accept() - } - } - } - - onAccepted: { - const name = evName.text.trim() - if (editIndex >= 0 && name !== "") - backend.updateVersion(editIndex, name, evServer.text.trim(), evVersion.selectedId) - editIndex = -1 - evName.text = ""; evServer.text = ""; evVersion.selectedId = "" - } - onRejected: { - editIndex = -1 - evName.text = ""; evServer.text = ""; evVersion.selectedId = "" - } } // ── Two-factor Dialog ────────────────────────────────────────────────── @@ -1624,356 +1106,4 @@ Window { "fullscreen": stFullscreen.checked }) } - - // ── Переиспользуемые элементы в общем стиле окна ─────────────────────── - component DarkCombo: ComboBox { - id: darkCombo - - // Та же стрелка, что у комбобоксов профиля и версии на главном окне, - // вместо двойного шеврона Basic-стиля. - indicator: Image { - width: 10; height: 10 - x: darkCombo.width - width - 12 - y: (darkCombo.height - height) / 2 - source: darkCombo.down ? "images/Profile_Box/Asset_23.svg" - : "images/Profile_Box/Asset_24.svg" - rotation: 180 - sourceSize.width: 10; sourceSize.height: 10 - fillMode: Image.PreserveAspectFit - } - - contentItem: Text { - leftPadding: 10 - rightPadding: darkCombo.indicator.width + 22 - text: darkCombo.displayText - color: "#ffffff" - font: darkCombo.font - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: darkCombo.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - - delegate: ItemDelegate { - required property var modelData - required property int index - - width: darkCombo.width - highlighted: darkCombo.highlightedIndex === index - contentItem: Text { - text: modelData - color: "#ffffff" - elide: Text.ElideRight - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { color: highlighted ? "#3a3a3a" : "#2a2a2a" } - } - - popup: Popup { - y: darkCombo.height - width: darkCombo.width - implicitHeight: Math.min(contentItem.implicitHeight, 220) - padding: 1 - - contentItem: ListView { - clip: true - implicitHeight: contentHeight - model: darkCombo.delegateModel - currentIndex: darkCombo.highlightedIndex - ScrollIndicator.vertical: ScrollIndicator {} - } - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: "#444444" - border.width: 1 - } - } - } - - // Выпадающий список версий: сверху установленные, ниже через разделитель - // весь каталог Mojang, сверху поле поиска. - // - // Это Item с Popup, а не ComboBox: editable-ComboBox привязывает поле ввода - // к currentText и запускает своё автодополнение, а при активном фильтре - // currentIndex перестаёт указывать в исходную модель. - component VersionCatalogCombo: Item { - id: vcombo - - property var catalog: [] - property string selectedId: "" - property string placeholder: qsTr("Выберите версию") - property string filterText: "" - signal aboutToOpen() - - implicitHeight: 36 - height: implicitHeight - - // Фильтр по номеру версии. ~900 строк за нажатие — доли миллисекунды, - // а делегаты ListView создаёт только для видимых строк. - function filterEntries(needle) { - const query = needle.trim().toLowerCase() - if (query === "") - return vcombo.catalog - const out = [] - for (var i = 0; i < vcombo.catalog.length; ++i) { - const entry = vcombo.catalog[i] - if (entry.selectable === false) - continue - if (entry.search.indexOf(query) !== -1) - out.push(entry) - } - return out - } - - readonly property var visibleEntries: vcombo.filterEntries(vcombo.filterText) - - function openPopup() { - vcombo.aboutToOpen() - vcombo.filterText = "" - vcomboFilter.text = "" - vcomboPopup.open() - vcomboFilter.forceActiveFocus() - } - - Rectangle { - id: vcomboField - anchors.fill: parent - color: "#2a2a2a" - radius: 6 - border.color: vcomboPopup.opened ? "#91B315" : "#444444" - border.width: 1 - - Text { - anchors.fill: parent - leftPadding: 10 - rightPadding: 32 - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - color: vcombo.selectedId === "" ? "#666666" : "#ffffff" - text: vcombo.selectedId === "" ? vcombo.placeholder : vcombo.selectedId - } - - // Та же стрелка, что у остальных списков окна. - Image { - width: 10; height: 10 - x: vcomboField.width - width - 12 - y: (vcomboField.height - height) / 2 - source: vcomboPopup.opened ? "images/Profile_Box/Asset_23.svg" - : "images/Profile_Box/Asset_24.svg" - rotation: 180 - sourceSize.width: 10; sourceSize.height: 10 - fillMode: Image.PreserveAspectFit - } - - MouseArea { - anchors.fill: parent - onClicked: vcombo.openPopup() - } - } - - Popup { - id: vcomboPopup - y: vcombo.height + 2 - width: vcombo.width - padding: 1 - height: 44 + Math.min(vcomboList.contentHeight, 240) - - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: "#444444" - border.width: 1 - } - - contentItem: Item { - TextField { - id: vcomboFilter - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: 4 - height: 30 - placeholderText: qsTr("Поиск версии…") - color: "#ffffff" - placeholderTextColor: "#666666" - background: Rectangle { - color: "#232323" - radius: 5 - border.color: vcomboFilter.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - onTextChanged: { - vcombo.filterText = text - vcomboList.currentIndex = vcomboList.firstSelectable(0, 1) - } - Keys.onEscapePressed: vcomboPopup.close() - Keys.onDownPressed: vcomboList.step(1) - Keys.onUpPressed: vcomboList.step(-1) - Keys.onReturnPressed: vcomboList.acceptCurrent() - Keys.onEnterPressed: vcomboList.acceptCurrent() - } - - ListView { - id: vcomboList - anchors.top: vcomboFilter.bottom - anchors.topMargin: 4 - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - clip: true - model: vcombo.visibleEntries - ScrollIndicator.vertical: ScrollIndicator {} - - // Разделитель и служебные строки клавишами пропускаем. - function firstSelectable(from, delta) { - for (var i = from; i >= 0 && i < count; i += delta) { - if (model[i].selectable !== false) - return i - } - return -1 - } - function step(delta) { - const next = firstSelectable(currentIndex + delta, delta) - if (next >= 0) { - currentIndex = next - positionViewAtIndex(next, ListView.Contain) - } - } - function acceptCurrent() { - if (currentIndex < 0 || currentIndex >= count) - return - const entry = model[currentIndex] - if (entry.selectable === false) - return - vcombo.selectedId = entry.id - vcomboPopup.close() - } - - delegate: Item { - id: vcomboRow - required property var modelData - required property int index - - width: vcomboList.width - height: modelData.kind === "separator" ? 26 - : modelData.kind === "status" ? 30 : 34 - - // Разделитель между установленными и каталогом. - Item { - anchors.fill: parent - visible: vcomboRow.modelData.kind === "separator" - - Rectangle { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: 8 - height: 1 - color: "#3a3a3a" - } - Rectangle { - anchors.centerIn: parent - color: "#2a2a2a" - width: separatorLabel.implicitWidth + 12 - height: 18 - Text { - id: separatorLabel - anchors.centerIn: parent - text: vcomboRow.modelData.label - color: "#888888" - font.pixelSize: 10 - } - } - } - - // «Загрузка списка…» или сообщение о недоступности. - Text { - anchors.centerIn: parent - visible: vcomboRow.modelData.kind === "status" - text: vcomboRow.modelData.label - color: "#888888" - font.pixelSize: 11 - font.italic: true - } - - Rectangle { - anchors.fill: parent - visible: vcomboRow.modelData.selectable !== false - color: (vcomboRowArea.containsMouse || vcomboList.currentIndex === vcomboRow.index) - ? "#3a3a3a" : "#2a2a2a" - - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - width: parent.width - 100 - elide: Text.ElideRight - text: vcomboRow.modelData.label - color: "#ffffff" - font.pixelSize: 12 - } - Text { - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: vcomboRow.modelData.installed ? qsTr("установлена") - : vcomboRow.modelData.type - color: vcomboRow.modelData.installed ? "#91B315" : "#666666" - font.pixelSize: 10 - } - - MouseArea { - id: vcomboRowArea - anchors.fill: parent - hoverEnabled: true - onClicked: { - vcombo.selectedId = vcomboRow.modelData.id - vcomboPopup.close() - } - } - } - } - } - } - } - } - - // Подпись + поле ввода одной колонкой — используется в настройках. - component LabelledField: Column { - id: labelled - - property alias text: field.text - property alias validator: field.validator - property string label: "" - property string placeholder: "" - - spacing: 3 - - Text { - text: labelled.label - color: "#aaaaaa" - font.pixelSize: 11 - } - - TextField { - id: field - width: labelled.width - height: 32 - placeholderText: labelled.placeholder - color: "#ffffff" - placeholderTextColor: "#666666" - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: field.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - } - } } diff --git a/ProgressPanel.qml b/ProgressPanel.qml new file mode 100644 index 0000000..338e2d3 --- /dev/null +++ b/ProgressPanel.qml @@ -0,0 +1,90 @@ +import QtQuick +import QtQuick.Controls 2.15 + +// Плашка хода долгой операции в левом нижнем углу: не задевает кнопку запуска, +// сообщение по центру и кнопки папки с настройками. +Rectangle { + id: panel + + property string title: "" + property string status: "" + // -1 — итог ещё неизвестен, показываем «…» вместо процентов. + property double fraction: -1 + // Необязательная вторая строка: мегабайты у загрузки, путь у архивации. + property string detail: "" + property bool cancellable: true + + signal cancelRequested() + + width: 320 + height: 72 + radius: 8 + color: "#1e1e1e" + opacity: 0.94 + border.color: "#91B315" + border.width: 1 + + Text { + x: 12; y: 8 + width: parent.width - 70 + elide: Text.ElideRight + color: "#ffffff" + font.pixelSize: 12 + font.bold: true + text: panel.title + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + y: 8 + color: "#91B315" + font.pixelSize: 12 + text: panel.fraction < 0 ? "…" : Math.round(panel.fraction * 100) + "%" + } + + Rectangle { + id: track + x: 12; y: 30 + width: parent.width - 24 + height: 6 + radius: 3 + color: "#2a2a2a" + + Rectangle { + width: panel.fraction > 0 ? track.width * panel.fraction : 0 + height: parent.height + radius: 3 + color: "#91B315" + Behavior on width { NumberAnimation { duration: 120 } } + } + } + + Text { + x: 12; y: 44 + width: parent.width - 40 + elide: Text.ElideMiddle + color: "#888888" + font.pixelSize: 10 + text: panel.detail !== "" ? panel.detail : panel.status + } + + Text { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 10 + anchors.bottomMargin: 6 + visible: panel.cancellable + text: "✕" + color: cancelArea.containsMouse ? "#cc6666" : "#666666" + font.pixelSize: 12 + + MouseArea { + id: cancelArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + onClicked: panel.cancelRequested() + } + } +} diff --git a/VersionCatalogCombo.qml b/VersionCatalogCombo.qml new file mode 100644 index 0000000..e798a9c --- /dev/null +++ b/VersionCatalogCombo.qml @@ -0,0 +1,250 @@ +import QtQuick +import QtQuick.Controls 2.15 + +// Выпадающий список версий: сверху установленные, ниже через разделитель +// весь каталог Mojang, сверху поле поиска. +// +// Это Item с Popup, а не ComboBox: editable-ComboBox привязывает поле ввода +// к currentText и запускает своё автодополнение, а при активном фильтре +// currentIndex перестаёт указывать в исходную модель. +Item { + id: vcombo + + property var catalog: [] + property string selectedId: "" + property string placeholder: qsTr("Выберите версию") + property string filterText: "" + signal aboutToOpen() + + implicitHeight: 36 + height: implicitHeight + + // Фильтр по номеру версии. ~900 строк за нажатие — доли миллисекунды, + // а делегаты ListView создаёт только для видимых строк. + function filterEntries(needle) { + const query = needle.trim().toLowerCase() + if (query === "") + return vcombo.catalog + const out = [] + for (var i = 0; i < vcombo.catalog.length; ++i) { + const entry = vcombo.catalog[i] + if (entry.selectable === false) + continue + if (entry.search.indexOf(query) !== -1) + out.push(entry) + } + return out + } + + readonly property var visibleEntries: vcombo.filterEntries(vcombo.filterText) + + function openPopup() { + vcombo.aboutToOpen() + vcombo.filterText = "" + vcomboFilter.text = "" + vcomboPopup.open() + vcomboFilter.forceActiveFocus() + } + + Rectangle { + id: vcomboField + anchors.fill: parent + color: "#2a2a2a" + radius: 6 + border.color: vcomboPopup.opened ? "#91B315" : "#444444" + border.width: 1 + + Text { + anchors.fill: parent + leftPadding: 10 + rightPadding: 32 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + color: vcombo.selectedId === "" ? "#666666" : "#ffffff" + text: vcombo.selectedId === "" ? vcombo.placeholder : vcombo.selectedId + } + + // Та же стрелка, что у остальных списков окна. + Image { + width: 10; height: 10 + x: vcomboField.width - width - 12 + y: (vcomboField.height - height) / 2 + source: vcomboPopup.opened ? "images/Profile_Box/Asset_23.svg" + : "images/Profile_Box/Asset_24.svg" + rotation: 180 + sourceSize.width: 10; sourceSize.height: 10 + fillMode: Image.PreserveAspectFit + } + + MouseArea { + anchors.fill: parent + onClicked: vcombo.openPopup() + } + } + + Popup { + id: vcomboPopup + y: vcombo.height + 2 + width: vcombo.width + padding: 1 + height: 44 + Math.min(vcomboList.contentHeight, 240) + + background: Rectangle { + color: "#2a2a2a" + radius: 6 + border.color: "#444444" + border.width: 1 + } + + contentItem: Item { + TextField { + id: vcomboFilter + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 4 + height: 30 + placeholderText: qsTr("Поиск версии…") + color: "#ffffff" + placeholderTextColor: "#666666" + background: Rectangle { + color: "#232323" + radius: 5 + border.color: vcomboFilter.activeFocus ? "#91B315" : "#444444" + border.width: 1 + } + onTextChanged: { + vcombo.filterText = text + vcomboList.currentIndex = vcomboList.firstSelectable(0, 1) + } + Keys.onEscapePressed: vcomboPopup.close() + Keys.onDownPressed: vcomboList.step(1) + Keys.onUpPressed: vcomboList.step(-1) + Keys.onReturnPressed: vcomboList.acceptCurrent() + Keys.onEnterPressed: vcomboList.acceptCurrent() + } + + ListView { + id: vcomboList + anchors.top: vcomboFilter.bottom + anchors.topMargin: 4 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + clip: true + model: vcombo.visibleEntries + ScrollIndicator.vertical: ScrollIndicator {} + + // Разделитель и служебные строки клавишами пропускаем. + function firstSelectable(from, delta) { + for (var i = from; i >= 0 && i < count; i += delta) { + if (model[i].selectable !== false) + return i + } + return -1 + } + function step(delta) { + const next = firstSelectable(currentIndex + delta, delta) + if (next >= 0) { + currentIndex = next + positionViewAtIndex(next, ListView.Contain) + } + } + function acceptCurrent() { + if (currentIndex < 0 || currentIndex >= count) + return + const entry = model[currentIndex] + if (entry.selectable === false) + return + vcombo.selectedId = entry.id + vcomboPopup.close() + } + + delegate: Item { + id: vcomboRow + required property var modelData + required property int index + + width: vcomboList.width + height: modelData.kind === "separator" ? 26 + : modelData.kind === "status" ? 30 : 34 + + // Разделитель между установленными и каталогом. + Item { + anchors.fill: parent + visible: vcomboRow.modelData.kind === "separator" + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 8 + height: 1 + color: "#3a3a3a" + } + Rectangle { + anchors.centerIn: parent + color: "#2a2a2a" + width: separatorLabel.implicitWidth + 12 + height: 18 + Text { + id: separatorLabel + anchors.centerIn: parent + text: vcomboRow.modelData.label + color: "#888888" + font.pixelSize: 10 + } + } + } + + // «Загрузка списка…» или сообщение о недоступности. + Text { + anchors.centerIn: parent + visible: vcomboRow.modelData.kind === "status" + text: vcomboRow.modelData.label + color: "#888888" + font.pixelSize: 11 + font.italic: true + } + + Rectangle { + anchors.fill: parent + visible: vcomboRow.modelData.selectable !== false + color: (vcomboRowArea.containsMouse || vcomboList.currentIndex === vcomboRow.index) + ? "#3a3a3a" : "#2a2a2a" + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 100 + elide: Text.ElideRight + text: vcomboRow.modelData.label + color: "#ffffff" + font.pixelSize: 12 + } + Text { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: vcomboRow.modelData.installed ? qsTr("установлена") + : vcomboRow.modelData.type + color: vcomboRow.modelData.installed ? "#91B315" : "#666666" + font.pixelSize: 10 + } + + MouseArea { + id: vcomboRowArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + vcombo.selectedId = vcomboRow.modelData.id + vcomboPopup.close() + } + } + } + } + } + } + } +} diff --git a/buildarchiveworker.cpp b/buildarchiveworker.cpp new file mode 100644 index 0000000..d915b8f --- /dev/null +++ b/buildarchiveworker.cpp @@ -0,0 +1,220 @@ +#include "buildarchiveworker.h" + +#include +#include +#include +#include + +#include +#include + +namespace { + +// Имена внутри архива приходят из файла и превращаются в пути на диске — +// «../..» увёл бы распаковку за пределы .minecraft. Правило то же, что у +// установщика версий. +bool isSafeRelativePath(const QString &path) +{ + if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':')) + return false; + const QStringList parts = path.split(u'/'); + for (const QString &part : parts) { + if (part == QLatin1String(".") || part == QLatin1String("..")) + return false; + } + return true; +} + +// Файлы, которые попадут в архив, — относительными путями от gameDir. +QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop) +{ + QStringList result; + const QDir root(gameDir); + const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot + | QDir::Hidden | QDir::System); + for (const QFileInfo &entry : top) { + if (excludeTop.contains(entry.fileName())) + continue; + if (entry.isSymLink()) + continue; // ссылку в архив не положить, а идти по ней опасно + if (entry.isFile()) { + result << entry.fileName(); + continue; + } + if (!entry.isDir()) + continue; + + QDirIterator it(entry.absoluteFilePath(), + QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks, + QDirIterator::Subdirectories); + while (it.hasNext()) + result << root.relativeFilePath(it.next()); + } + return result; +} + +} // namespace + +BuildArchiveWorker::BuildArchiveWorker(QObject *parent) + : QObject(parent) +{ +} + +void BuildArchiveWorker::archive(const QString &gameDir, + const QString &tempZipPath, + const QStringList &excludeTop) +{ + const QDir root(gameDir); + if (!root.exists()) { + emit finished(false, tr("Папка игры не найдена: %1").arg(QDir::toNativeSeparators(gameDir))); + return; + } + + const QStringList files = collectFiles(gameDir, excludeTop); + emit progress(0, files.size(), {}); + + { + QZipWriter writer(tempZipPath); + if (writer.status() != QZipWriter::NoError) { + emit finished(false, + tr("Не удалось создать архив %1") + .arg(QDir::toNativeSeparators(tempZipPath))); + return; + } + // Миры и конфиги сжимаются хорошо, уже сжатые jar-моды — нет: пусть + // решает эвристика, а не мы. + writer.setCompressionPolicy(QZipWriter::AutoCompress); + + int done = 0; + for (const QString &relative : files) { + if (canceled()) { + writer.close(); + QFile::remove(tempZipPath); + emit finished(false, tr("Архивация отменена")); + return; + } + + QFile file(root.absoluteFilePath(relative)); + if (!file.open(QIODevice::ReadOnly)) { + // Один нечитаемый файл не повод потерять всю сборку, но и молча + // выкидывать его нельзя — иначе пользователь узнает о пропаже + // уже после очистки .minecraft. + writer.close(); + QFile::remove(tempZipPath); + emit finished(false, + tr("Не удалось прочитать %1: %2") + .arg(QDir::toNativeSeparators(relative), file.errorString())); + return; + } + writer.addFile(relative, &file); + file.close(); + + if (writer.status() != QZipWriter::NoError) { + writer.close(); + QFile::remove(tempZipPath); + emit finished(false, + tr("Ошибка записи архива на файле %1") + .arg(QDir::toNativeSeparators(relative))); + return; + } + emit progress(++done, files.size(), relative); + } + writer.close(); + } + + emit finished(true, {}); +} + +void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop) +{ + QDir root(gameDir); + if (!root.exists()) { + emit finished(true, {}); // чистить нечего + return; + } + + QFileInfoList victims; + const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot + | QDir::Hidden | QDir::System); + for (const QFileInfo &entry : top) { + if (!keepTop.contains(entry.fileName())) + victims << entry; + } + + emit progress(0, victims.size(), {}); + int done = 0; + for (const QFileInfo &entry : victims) { + // Отмена посреди очистки оставила бы полупустую папку, которую нечем + // склеить обратно, — на этом шаге её не слушаем. + const bool ok = entry.isDir() && !entry.isSymLink() + ? QDir(entry.absoluteFilePath()).removeRecursively() + : QFile::remove(entry.absoluteFilePath()); + if (!ok) { + emit finished(false, + tr("Не удалось удалить %1") + .arg(QDir::toNativeSeparators(entry.absoluteFilePath()))); + return; + } + emit progress(++done, victims.size(), entry.fileName()); + } + + emit finished(true, {}); +} + +void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir) +{ + QZipReader reader(zipPath); + if (!reader.isReadable()) { + emit finished(false, + tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath))); + return; + } + + const QList entries = reader.fileInfoList(); + emit progress(0, entries.size(), {}); + + const QDir root(gameDir); + if (!QDir().mkpath(gameDir)) { + emit finished(false, + tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir))); + return; + } + + int done = 0; + for (const QZipReader::FileInfo &entry : entries) { + if (canceled()) { + emit finished(false, tr("Распаковка отменена")); + return; + } + if (!entry.isFile) { + emit progress(++done, entries.size(), entry.filePath); + continue; + } + if (!isSafeRelativePath(entry.filePath)) { + emit finished(false, + tr("Архив повреждён: недопустимый путь «%1»").arg(entry.filePath)); + return; + } + + const QString target = root.absoluteFilePath(entry.filePath); + 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(entry.filePath); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) { + emit finished(false, + tr("Не удалось записать %1: %2") + .arg(QDir::toNativeSeparators(entry.filePath), file.errorString())); + return; + } + file.close(); + emit progress(++done, entries.size(), entry.filePath); + } + + emit finished(true, {}); +} diff --git a/buildarchiveworker.h b/buildarchiveworker.h new file mode 100644 index 0000000..91a74a6 --- /dev/null +++ b/buildarchiveworker.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +// Три операции над содержимым .minecraft, выполняемые в отдельном потоке: +// упаковать, вычистить, распаковать. Гигабайты модов и миров нельзя гонять в +// GUI-потоке — окно бы замерзало на всё время смены сборки. +// +// Объект живёт в своём QThread, вызывается только через очередь сигналов и +// сам ничего не знает ни о сборках, ни о путях лаунчера: порядок шагов и +// восстановление после сбоя — дело BuildSwitcher. +class BuildArchiveWorker : public QObject +{ + Q_OBJECT + +public: + explicit BuildArchiveWorker(QObject *parent = nullptr); + + // Вызывается из GUI-потока во время работы: отмена проверяется между файлами. + void requestCancel() { m_cancel.storeRelaxed(1); } + void clearCancel() { m_cancel.storeRelaxed(0); } + +public slots: + // Упаковывает всё содержимое gameDir, кроме элементов верхнего уровня из + // excludeTop, во временный файл tempZipPath. + void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop); + + // Удаляет из gameDir всё, кроме элементов верхнего уровня из keepTop. + void clear(const QString &gameDir, const QStringList &keepTop); + + // Распаковывает архив в gameDir. + void restore(const QString &zipPath, const QString &gameDir); + +signals: + void progress(int done, int total, const QString ¤tPath); + void finished(bool ok, const QString &error); + +private: + bool canceled() const { return m_cancel.loadRelaxed() != 0; } + + QAtomicInt m_cancel; +}; diff --git a/buildswitcher.cpp b/buildswitcher.cpp new file mode 100644 index 0000000..98215cf --- /dev/null +++ b/buildswitcher.cpp @@ -0,0 +1,438 @@ +#include "buildswitcher.h" + +#include "buildarchiveworker.h" +#include "launcherpaths.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const QString kIndexFile = QStringLiteral("index.json"); +const QString kStateSwitching = QStringLiteral("switching"); +const QString kStateIdle = QStringLiteral("idle"); + +// Запас на архив: точный размер заранее неизвестен, но упасть на середине +// записи из-за полного диска хуже, чем отказаться заранее. +constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024; + +// Имя файла из названия сборки: оно попадает в файловую систему, поэтому всё +// небезопасное заменяется подчёркиванием. +QString slug(const QString &name) +{ + QString result; + result.reserve(name.size()); + for (const QChar c : name) { + if (c.isLetterOrNumber() || c == u'-' || c == u'_') + result.append(c); + else if (c.isSpace() || c == u'.') + result.append(u'_'); + } + while (result.startsWith(u'_')) + result.remove(0, 1); + return result.isEmpty() ? QStringLiteral("build") : result.left(48); +} + +qint64 directorySize(const QString &path, const QStringList &excludeTop) +{ + qint64 total = 0; + const QDir root(path); + const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot + | QDir::Hidden | QDir::System); + for (const QFileInfo &entry : top) { + if (excludeTop.contains(entry.fileName()) || entry.isSymLink()) + continue; + if (entry.isFile()) { + total += entry.size(); + continue; + } + QDirIterator it(entry.absoluteFilePath(), + QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + total += it.fileInfo().size(); + } + } + return total; +} + +} // namespace + +QStringList BuildSwitcher::sharedTopLevel() +{ + // Общий кэш, одинаковый для всех сборок и всегда восстановимый загрузкой. + // versions/ здесь не случайно: без libraries/ описания версий бесполезны, а + // две сборки на одной версии игры иначе таскали бы по копии клиентского jar. + // Чем сборка запускается, помнит resolvedVersionId, а не набор папок. + return {QStringLiteral("assets"), + QStringLiteral("libraries"), + QStringLiteral("versions"), + QStringLiteral("launcher_profiles.json"), + QStringLiteral("runtime")}; +} + +BuildSwitcher::BuildSwitcher(QObject *parent) + : QObject(parent) + , m_thread(new QThread(this)) + , m_worker(new BuildArchiveWorker) +{ + m_worker->moveToThread(m_thread); + 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); + m_thread->start(); + + sweepTempArchives(); +} + +BuildSwitcher::~BuildSwitcher() +{ + m_worker->requestCancel(); + m_thread->quit(); + m_thread->wait(5000); +} + +double BuildSwitcher::fraction() const +{ + if (m_state == Idle || m_total <= 0) + return -1.0; + return double(m_done) / double(m_total); +} + +QString BuildSwitcher::buildDir(int buildId) const +{ + return LauncherPaths::buildStorageDir() + u'/' + QString::number(buildId); +} + +// На сборку — ровно один архив: имя с датой меняется от переключения к +// переключению, поэтому ищем по расширению, а не по имени. +QString BuildSwitcher::archivePathOf(int buildId) const +{ + const QDir dir(buildDir(buildId)); + const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time); + return zips.isEmpty() ? QString() : dir.absoluteFilePath(zips.first()); +} + +void BuildSwitcher::setStage(const QString &stage) +{ + m_stage = stage; + emit progressChanged(); +} + +void BuildSwitcher::switchTo(int fromBuildId, + const QString &fromName, + int toBuildId, + const QString &toName, + const QString &gameDir) +{ + if (m_state != Idle) { + emit failed(toBuildId, tr("Смена сборки уже выполняется"), true); + return; + } + + QString rootError; + if (!LauncherPaths::ensureRootExists(&rootError)) { + emit failed(toBuildId, rootError, true); + return; + } + + m_fromId = fromBuildId; + m_toId = toBuildId; + m_fromName = fromName; + m_toName = toName; + m_gameDir = gameDir; + m_done = 0; + m_total = 0; + m_worker->clearCancel(); + + // Первая сборка за всё время: архивировать нечего, сразу разворачиваем цель. + if (fromBuildId < 0 || !QDir(gameDir).exists()) { + m_state = Clearing; + beginClearing(); + return; + } + + if (!QDir().mkpath(buildDir(fromBuildId))) { + emit failed(toBuildId, + tr("Не удалось создать %1") + .arg(QDir::toNativeSeparators(buildDir(fromBuildId))), + true); + return; + } + + const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes; + const QStorageInfo storage(LauncherPaths::buildStorageDir()); + if (storage.isValid() && storage.bytesAvailable() < needed) { + emit failed(toBuildId, + tr("Недостаточно места для архива сборки «%1»: нужно около %2 МБ") + .arg(fromName) + .arg(needed / (1024 * 1024)), + true); + return; + } + + m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-") + + QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip"); + + m_state = Archiving; + setStage(tr("Сохранение сборки «%1»").arg(fromName)); + requestArchive(gameDir, m_tempZip, sharedTopLevel()); +} + +void BuildSwitcher::cancel() +{ + // Отмена имеет смысл только на архивации: после очистки папки отступать + // уже некуда, там нужно доводить дело до конца. + if (m_state == Archiving) + m_worker->requestCancel(); +} + +void BuildSwitcher::requestArchive(const QString &gameDir, + const QString &tempZip, + const QStringList &exclude) +{ + QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection, + Q_ARG(QString, gameDir), Q_ARG(QString, tempZip), + Q_ARG(QStringList, exclude)); +} + +void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep) +{ + QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection, + Q_ARG(QString, gameDir), Q_ARG(QStringList, keep)); +} + +void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir) +{ + QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection, + Q_ARG(QString, zipPath), Q_ARG(QString, gameDir)); +} + +void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath) +{ + m_done = done; + m_total = total; + m_status = currentPath; + emit progressChanged(); +} + +void BuildSwitcher::onWorkerFinished(bool ok, const QString &error) +{ + switch (m_state) { + case Idle: + return; + case Archiving: + if (!ok) { + QFile::remove(m_tempZip); + fail(error, /*gameDirIntact=*/true); + return; + } + commitArchive(); + return; + case Clearing: + if (!ok) { + // Папка осталась разобранной: активной сборкой считается прежняя, + // отметка о незавершённом переключении в index.json остаётся. + fail(error, /*gameDirIntact=*/false); + return; + } + beginRestoring(); + return; + case Restoring: + if (!ok) { + fail(error, /*gameDirIntact=*/false); + return; + } + finish(); + return; + } +} + +void BuildSwitcher::commitArchive() +{ + const QString target = buildDir(m_fromId) + u'/' + + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd")) + + u'_' + slug(m_fromName) + QStringLiteral(".zip"); + + // На сборку — один архив: новый встаёт на место, все прежние уходят. + if (QFile::exists(target) && !QFile::remove(target)) { + QFile::remove(m_tempZip); + fail(tr("Не удалось заменить архив %1").arg(QDir::toNativeSeparators(target)), true); + return; + } + if (!QFile::rename(m_tempZip, target)) { + QFile::remove(m_tempZip); + fail(tr("Не удалось сохранить архив %1").arg(QDir::toNativeSeparators(target)), true); + return; + } + + 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) + QFile::remove(dir.absoluteFilePath(name)); + } + + // Отметка ставится до первого разрушающего действия: по ней следующий + // запуск поймёт, что переключение оборвалось на середине. + QJsonObject index = readIndex(); + index.insert(QStringLiteral("state"), kStateSwitching); + index.insert(QStringLiteral("from"), m_fromId); + index.insert(QStringLiteral("to"), m_toId); + QJsonObject builds = index.value(QStringLiteral("builds")).toObject(); + builds.insert(QString::number(m_fromId), + QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()}, + {QStringLiteral("name"), m_fromName}, + {QStringLiteral("savedAt"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}}); + index.insert(QStringLiteral("builds"), builds); + writeIndex(index); + + m_state = Clearing; + beginClearing(); +} + +void BuildSwitcher::beginClearing() +{ + setStage(tr("Подготовка сборки «%1»").arg(m_toName)); + requestClear(m_gameDir, sharedTopLevel()); +} + +void BuildSwitcher::beginRestoring() +{ + const QString archive = archivePathOf(m_toId); + if (archive.isEmpty()) { + // Сборка ещё ни разу не запускалась: пустая .minecraft рядом с общими + // кэшами — это и есть её правильное начальное состояние. + m_state = Restoring; + finish(); + return; + } + + m_state = Restoring; + setStage(tr("Восстановление сборки «%1»").arg(m_toName)); + requestRestore(archive, m_gameDir); +} + +void BuildSwitcher::finish() +{ + QJsonObject index = readIndex(); + index.insert(QStringLiteral("state"), kStateIdle); + index.insert(QStringLiteral("active"), m_toId); + index.remove(QStringLiteral("from")); + index.remove(QStringLiteral("to")); + writeIndex(index); + + 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::fail(const QString &message, bool gameDirIntact) +{ + const int toId = m_toId; + m_state = Idle; + m_stage.clear(); + m_status.clear(); + m_done = m_total = 0; + emit progressChanged(); + emit failed(toId, message, gameDirIntact); +} + +// ── Незавершённое переключение ───────────────────────────────────────────── + +QString BuildSwitcher::interruptedSwitchWarning() const +{ + const QJsonObject index = readIndex(); + if (index.value(QStringLiteral("state")).toString() != kStateSwitching) + return {}; + + const int toId = index.value(QStringLiteral("to")).toInt(-1); + const QJsonObject builds = index.value(QStringLiteral("builds")).toObject(); + const QString name = builds.value(QString::number(toId)) + .toObject() + .value(QStringLiteral("name")) + .toString(); + return tr("Прошлая смена сборки%1 не завершилась — восстанавливаем содержимое .minecraft.") + .arg(name.isEmpty() ? QString() : tr(" на «%1»").arg(name)); +} + +void BuildSwitcher::resumeInterrupted(const QString &gameDir) +{ + const QJsonObject index = readIndex(); + if (index.value(QStringLiteral("state")).toString() != kStateSwitching) + return; + + const int toId = index.value(QStringLiteral("to")).toInt(-1); + if (toId < 0 || m_state != Idle) + return; + + // Архив исходной сборки уже записан целиком — доигрываем только вторую + // половину: очистку и распаковку цели. + m_fromId = -1; + m_toId = toId; + m_toName = index.value(QStringLiteral("builds")) + .toObject() + .value(QString::number(toId)) + .toObject() + .value(QStringLiteral("name")) + .toString(); + m_gameDir = gameDir; + m_done = m_total = 0; + m_worker->clearCancel(); + m_state = Clearing; + beginClearing(); +} + +// ── Реестр архивов ───────────────────────────────────────────────────────── + +QJsonObject BuildSwitcher::readIndex() const +{ + QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile); + if (!file.open(QIODevice::ReadOnly)) + return {}; + return QJsonDocument::fromJson(file.readAll()).object(); +} + +void BuildSwitcher::writeIndex(const QJsonObject &index) const +{ + if (!LauncherPaths::ensureRootExists()) + return; + QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile); + if (!file.open(QIODevice::WriteOnly)) + return; + const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented); + if (file.write(payload) != payload.size()) { + file.cancelWriting(); + return; + } + file.commit(); +} + +// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может +// занимать гигабайты. +void BuildSwitcher::sweepTempArchives() const +{ + QDir root(LauncherPaths::buildStorageDir()); + const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &build : builds) { + QDir dir(root.absoluteFilePath(build)); + const QStringList leftovers = + dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden); + for (const QString &name : leftovers) + QFile::remove(dir.absoluteFilePath(name)); + } +} diff --git a/buildswitcher.h b/buildswitcher.h new file mode 100644 index 0000000..78b491d --- /dev/null +++ b/buildswitcher.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include + +class QThread; +class BuildArchiveWorker; + +// Смена активной сборки: содержимое .minecraft уезжает в архив своей сборки, +// папка чистится, на её место разворачивается архив выбранной. +// +// Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и +// не переименован на место, из .minecraft не удаляется ничего. Отметка о +// начатом переключении пишется в builds/index.json до первого разрушающего +// действия, поэтому обрыв питания или kill -9 всегда обнаружим на следующем +// запуске — см. recoverIfNeeded(). +class BuildSwitcher : public QObject +{ + Q_OBJECT + +public: + explicit BuildSwitcher(QObject *parent = nullptr); + ~BuildSwitcher() override; + + bool isRunning() const { return m_state != Idle; } + QString stage() const { return m_stage; } + QString status() const { return m_status; } + double fraction() const; // 0..1, либо -1 пока итог неизвестен + + // Общие для всех сборок каталоги: они не архивируются и не чистятся. + static QStringList sharedTopLevel(); + + void switchTo(int fromBuildId, + const QString &fromName, + int toBuildId, + const QString &toName, + const QString &gameDir); + void cancel(); + + // Незавершённое переключение с прошлого запуска. Возвращает пустую строку, + // если всё в порядке, иначе — текст для пользователя. + QString interruptedSwitchWarning() const; + // Доигрывает прерванное переключение: очистка и распаковка целевой сборки. + void resumeInterrupted(const QString &gameDir); + +signals: + void progressChanged(); + void finished(int toBuildId); + void failed(int toBuildId, const QString &message, bool gameDirIntact); + +private: + enum State { Idle, Archiving, Clearing, Restoring }; + + // Сигналы к рабочему объекту в другом потоке. + 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 onWorkerFinished(bool ok, const QString &error); + void onWorkerProgress(int done, int total, const QString ¤tPath); + + void commitArchive(); + void beginClearing(); + void beginRestoring(); + void finish(); + void fail(const QString &message, bool gameDirIntact); + + QString buildDir(int buildId) const; + QString archivePathOf(int buildId) const; + void setStage(const QString &stage); + + QJsonObject readIndex() const; + void writeIndex(const QJsonObject &index) const; + void sweepTempArchives() const; + + QThread *m_thread; + BuildArchiveWorker *m_worker; + + State m_state = Idle; + QString m_stage; + QString m_status; + int m_done = 0; + int m_total = 0; + + int m_fromId = -1; + int m_toId = -1; + QString m_fromName; + QString m_toName; + QString m_gameDir; + QString m_tempZip; +}; diff --git a/launcherbackend.cpp b/launcherbackend.cpp index b4a0668..169afe1 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -6,6 +6,9 @@ #include "launcherpaths.h" #include "versioninstaller.h" #include "versionmanifestservice.h" +#include "modloaderinstaller.h" +#include "modloaderversionservice.h" +#include "buildswitcher.h" #include #include @@ -41,6 +44,9 @@ LauncherBackend::LauncherBackend(QObject *parent) , m_launcher(new GameLauncher(this)) , m_manifest(new VersionManifestService(this)) , m_installer(new VersionInstaller(m_manifest, this)) + , m_loaderMeta(new ModLoaderVersionService(this)) + , m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this)) + , m_switcher(new BuildSwitcher(this)) { // Папка лаунчера создаётся при первом запуске и проверяется при каждом // следующем — если её удалили, она появится снова. @@ -49,9 +55,22 @@ LauncherBackend::LauncherBackend(QObject *parent) m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError); loadSettings(); + // Порядок важен: миграция должна успеть до того, как ensureDataFiles() + // создаст пустой customBuilds.json поверх старых данных. + migrateLegacyBuildsFile(); loadData(); ensureDataFiles(); + // Прошлое переключение оборвалось на середине. Доигрываем его сами, а не + // предлагаем пользователю переключиться заново: архив прежней сборки уже + // записан целиком, а вот повторное переключение сначала заархивировало бы + // поверх него нынешнее — наполовину разобранное — содержимое .minecraft. + const QString interrupted = m_switcher->interruptedSwitchWarning(); + if (!interrupted.isEmpty()) { + m_storageIssues << interrupted; + QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); }); + } + connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress); connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress); connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput); @@ -94,6 +113,88 @@ LauncherBackend::LauncherBackend(QObject *parent) emit busyChanged(); emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message)); }); + connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] { + emit switchChanged(); + emit busyChanged(); + }); + connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) { + // Активной сборка становится только здесь: провалившееся переключение + // оставляет активной прежнюю. + m_settings.insert(QStringLiteral("activeBuildId"), toBuildId); + saveSettings(); + invalidateCatalog(); + emit activeBuildChanged(); + emit installedVersionsChanged(); + emit switchChanged(); + emit busyChanged(); + const int index = indexOfBuildId(toBuildId); + emit launchProgress(tr("Активна сборка «%1»") + .arg(index >= 0 ? m_builds[index].name : QString())); + }); + connect(m_switcher, &BuildSwitcher::failed, this, + [this](int, const QString &message, bool gameDirIntact) { + invalidateCatalog(); + emit installedVersionsChanged(); + emit switchChanged(); + emit busyChanged(); + emit launchError(gameDirIntact + ? tr("Сборка не переключена: %1").arg(message) + : tr("Сборка не переключена: %1\nСодержимое .minecraft " + "осталось разобранным — повторите переключение.") + .arg(message)); + }); + + connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged, + this, &LauncherBackend::loaderVersionsChanged); + connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged, + this, &LauncherBackend::downloadChanged); + + // Установщик лоадера делит панель прогресса и флаг занятости с обычным + // установщиком версий — сигналы у них одни и те же. + connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) { + emit busyChanged(); + emit downloadChanged(); + emit launchProgress(tr("Установка %1…").arg(label)); + }); + connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged, + this, &LauncherBackend::downloadChanged); + connect(m_loaderInstaller, &ModLoaderInstaller::log, this, &LauncherBackend::gameOutput); + connect(m_loaderInstaller, &ModLoaderInstaller::finished, this, + [this](const QString &loader, const QString &gameVersion, + const QString &loaderVersion, const QString &producedVersionId) { + const int index = indexOfBuildId(m_loaderInstallBuildId); + m_loaderInstallBuildId = -1; + if (index >= 0) { + updateCustomBuild(index, + {{QStringLiteral("loader"), loader}, + {QStringLiteral("loaderVersion"), loaderVersion}, + {QStringLiteral("resolvedVersionId"), producedVersionId}}); + } + invalidateCatalog(); + emit installedVersionsChanged(); + emit busyChanged(); + emit downloadChanged(); + emit launchProgress(tr("Готово: %1 для Minecraft %2") + .arg(producedVersionId, gameVersion)); + }); + connect(m_loaderInstaller, &ModLoaderInstaller::failed, this, + [this](const QString &label, const QString &message) { + m_loaderInstallBuildId = -1; + invalidateCatalog(); + emit installedVersionsChanged(); + emit busyChanged(); + emit downloadChanged(); + emit launchError(tr("%1: %2").arg(label, message)); + }); + connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) { + m_loaderInstallBuildId = -1; + invalidateCatalog(); + emit installedVersionsChanged(); + emit busyChanged(); + emit downloadChanged(); + emit launchProgress(tr("Установка %1 отменена").arg(label)); + }); + connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) { invalidateCatalog(); emit installedVersionsChanged(); @@ -120,11 +221,11 @@ QStringList LauncherBackend::profileNames() const return names; } -QStringList LauncherBackend::versionNames() const +QStringList LauncherBackend::customBuildNames() const { QStringList names; - for (const auto &v : m_versions) - names << v.name; + for (const auto &b : m_builds) + names << b.name; return names; } @@ -155,12 +256,19 @@ void LauncherBackend::addProfile(const QString &name, emit profilesChanged(); } -void LauncherBackend::addVersion(const QString &name, const QString &serverUrl, const QString &versionId) +void LauncherBackend::addCustomBuild(const QString &name, + const QString &serverUrl, + const QString &minecraftVersion) { - m_versions.append({m_nextVersionId++, name, serverUrl, versionId}); - saveVersions(); - emit versionsChanged(); - maybeInstall(versionIdOf(m_versions.last())); + CustomBuild build; + build.id = m_nextBuildId++; + build.name = name; + build.serverUrl = serverUrl; + build.minecraftVersion = minecraftVersion; + m_builds.append(build); + saveCustomBuilds(); + emit customBuildsChanged(); + maybeInstall(launchVersionIdOf(m_builds.last())); } void LauncherBackend::updateProfile(int index, @@ -190,19 +298,43 @@ void LauncherBackend::updateProfile(int index, emit profilesChanged(); } -void LauncherBackend::updateVersion(int index, - const QString &name, - const QString &serverUrl, - const QString &versionId) +void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields) { - if (index < 0 || index >= m_versions.size()) + if (index < 0 || index >= m_builds.size()) return; - m_versions[index].name = name; - m_versions[index].serverUrl = serverUrl; - m_versions[index].versionId = versionId; - saveVersions(); - emit versionsChanged(); - maybeInstall(versionIdOf(m_versions[index])); + + CustomBuild &build = m_builds[index]; + const auto take = [&fields](const char *key, QString &target) { + const auto it = fields.constFind(QLatin1String(key)); + if (it == fields.constEnd()) + return false; + target = it->toString(); + return true; + }; + + // Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит. + // Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию. + const QString previousMinecraft = build.minecraftVersion; + + take("name", build.name); + take("serverUrl", build.serverUrl); + take("minecraftVersion", build.minecraftVersion); + take("loader", build.loader); + take("loaderVersion", build.loaderVersion); + take("resolvedVersionId", build.resolvedVersionId); + + if (build.minecraftVersion != previousMinecraft + && !fields.contains(QStringLiteral("loaderVersion"))) { + build.loaderVersion.clear(); + build.resolvedVersionId.clear(); + } + + saveCustomBuilds(); + emit customBuildsChanged(); + emit activeBuildChanged(); // имя активной сборки могло измениться + // maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу + // правки, и загрузка стартовала бы почти на каждое действие. Для этого есть + // installCustomBuild(). } QVariantMap LauncherBackend::profileAt(int index) const @@ -216,12 +348,18 @@ QVariantMap LauncherBackend::profileAt(int index) const {"authType", p.authType}}; } -QVariantMap LauncherBackend::versionAt(int index) const +QVariantMap LauncherBackend::customBuildAt(int index) const { - if (index < 0 || index >= m_versions.size()) + if (index < 0 || index >= m_builds.size()) return {}; - const auto &v = m_versions[index]; - return {{"name", v.name}, {"serverUrl", v.serverUrl}, {"versionId", versionIdOf(v)}}; + const auto &b = m_builds[index]; + return {{"name", b.name}, + {"serverUrl", b.serverUrl}, + {"minecraftVersion", b.minecraftVersion}, + {"loader", b.loader}, + {"loaderVersion", b.loaderVersion}, + {"resolvedVersionId", b.resolvedVersionId}, + {"launchVersionId", launchVersionIdOf(b)}}; } void LauncherBackend::removeProfile(int index) @@ -233,37 +371,105 @@ void LauncherBackend::removeProfile(int index) emit profilesChanged(); } -void LauncherBackend::removeVersion(int index) +void LauncherBackend::removeCustomBuild(int index) { - if (index < 0 || index >= m_versions.size()) + if (index < 0 || index >= m_builds.size()) return; - m_versions.removeAt(index); - saveVersions(); - emit versionsChanged(); + m_builds.removeAt(index); + saveCustomBuilds(); + emit customBuildsChanged(); + emit activeBuildChanged(); } -QString LauncherBackend::versionIdOf(const Version &version) const +int LauncherBackend::indexOfBuildId(int buildId) const { - // Пользователь мог назвать запись как угодно — тогда версией считаем само имя. - return version.versionId.isEmpty() ? version.name : version.versionId; + for (int i = 0; i < m_builds.size(); ++i) { + if (m_builds[i].id == buildId) + return i; + } + return -1; } -QStringList LauncherBackend::checkInstallation(int versionIndex) const +int LauncherBackend::activeBuildIndex() const { - if (versionIndex < 0 || versionIndex >= m_versions.size()) - return {tr("Версия не выбрана")}; + if (m_builds.isEmpty()) + return -1; + const int index = indexOfBuildId(m_settings.value(QStringLiteral("activeBuildId")).toInt()); + // Настройка могла указывать на удалённую сборку — тогда активна первая. + return index >= 0 ? index : 0; +} - const Version &entry = m_versions[versionIndex]; - const QString id = versionIdOf(entry); +void LauncherBackend::setActiveBuildIndex(int index) +{ + const int current = activeBuildIndex(); + if (index < 0 || index >= m_builds.size() || index == current) + return; + + if (m_switcher->isRunning()) { + emit launchError(tr("Смена сборки уже выполняется")); + return; + } + if (m_launcher->isRunning()) { + emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся")); + return; + } + if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { + emit launchError(tr("Дождитесь окончания текущей операции")); + return; + } + + // Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в + // её архив, и только потом на его место разворачивается выбранная. + const CustomBuild &target = m_builds[index]; + emit launchProgress(tr("Смена сборки на «%1»…").arg(target.name)); + m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1, + current >= 0 ? m_builds[current].name : QString(), + target.id, + target.name, + gameDir()); + emit switchChanged(); + emit busyChanged(); +} + +QString LauncherBackend::activeBuildName() const +{ + const int index = activeBuildIndex(); + return index < 0 ? QString() : m_builds[index].name; +} + +void LauncherBackend::installCustomBuild(int index) +{ + // Установка сборки — это установка её версии игры и, если выбран, + // модлоадера: они ставятся одной цепочкой. + installLoaderForBuild(index); +} + +QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const +{ + // resolvedVersionId появляется после установки модлоадера. Пользователь мог + // назвать сборку как угодно — тогда версией считаем само имя: так работали + // записи, созданные до появления каталога версий. + if (!build.resolvedVersionId.isEmpty()) + return build.resolvedVersionId; + return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion; +} + +QStringList LauncherBackend::checkInstallation(int buildIndex) const +{ + if (buildIndex < 0 || buildIndex >= m_builds.size()) + return {tr("Сборка не выбрана")}; + + const CustomBuild &entry = m_builds[buildIndex]; + const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) - return {tr("У записи лаунчера не указан ID версии. Проверьте %1") - .arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))}; + return {tr("У сборки не выбрана версия Minecraft. Проверьте %1") + .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))}; QString error; const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error); if (!version.isValid()) { return {error.isEmpty() - ? tr("Версия «%1» из записи «%2» не установлена в %3") + ? tr("Версия «%1» из сборки «%2» не установлена в %3") .arg(id, entry.name, QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions"))) @@ -416,28 +622,43 @@ void LauncherBackend::installVersion(const QString &versionId) void LauncherBackend::cancelDownload() { - m_installer->cancel(); + // Установщик лоадера сам остановит вложенную установку версии. + if (m_loaderInstaller->isRunning()) + m_loaderInstaller->cancel(); + else + m_installer->cancel(); } +// Панель загрузки одна на два установщика: пока идёт установка модлоадера, она +// показывает его стадии, включая вложенную загрузку файлов версии. bool LauncherBackend::downloading() const { - return m_installer->isRunning(); + return m_loaderInstaller->isRunning() || m_installer->isRunning(); } double LauncherBackend::downloadProgress() const { - return m_installer->fraction(); + if (!m_loaderInstaller->isRunning()) + return m_installer->fraction(); + // Внутри установки лоадера файлы качает VersionInstaller — его доля точнее. + return m_installer->isRunning() ? m_installer->fraction() : m_loaderInstaller->fraction(); } QString LauncherBackend::downloadVersion() const { + if (m_loaderInstaller->isRunning()) + return m_loaderInstaller->label(); return m_installer->versionId(); } QString LauncherBackend::downloadStatus() const { - const QString stage = m_installer->stage(); - const QString file = m_installer->currentFile(); + QString stage = m_installer->stage(); + QString file = m_installer->currentFile(); + if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) { + stage = m_loaderInstaller->stage(); + file = m_loaderInstaller->currentFile(); + } if (stage.isEmpty()) return file; if (file.isEmpty()) @@ -447,16 +668,111 @@ QString LauncherBackend::downloadStatus() const qint64 LauncherBackend::downloadBytesDone() const { + if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) + return m_loaderInstaller->bytesDone(); return m_installer->bytesDone(); } qint64 LauncherBackend::downloadBytesTotal() const { + if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) + return m_loaderInstaller->bytesTotal(); return m_installer->bytesTotal(); } -void LauncherBackend::launchGame(int profileIndex, int versionIndex) +// ── Модлоадеры ───────────────────────────────────────────────────────────── + +QVariantList LauncherBackend::loaderVersions(const QString &loaderKey, + const QString &gameVersion) const { + const auto loader = loaderFromKey(loaderKey); + if (!loader) + return {}; + + QVariantList result; + const QList entries = m_loaderMeta->versions(*loader, gameVersion); + result.reserve(entries.size()); + for (const LoaderVersionEntry &entry : entries) { + QString label = entry.loaderVersion; + if (entry.recommended) + label += tr(" — рекомендуемая"); + else if (!entry.stable) + label += tr(" — нестабильная"); + result.append(QVariantMap{{QStringLiteral("version"), entry.loaderVersion}, + {QStringLiteral("label"), label}, + {QStringLiteral("recommended"), entry.recommended}, + {QStringLiteral("stable"), entry.stable}}); + } + return result; +} + +void LauncherBackend::refreshLoaderVersions(const QString &loaderKey, + const QString &gameVersion, + bool force) +{ + const auto loader = loaderFromKey(loaderKey); + if (!loader) + return; + m_loaderMeta->ensureLoaded(*loader, gameVersion, [this, loaderKey, gameVersion]( + bool ok, const QString &warning) { + if (!ok && !warning.isEmpty()) + emit launchError(warning); + // Кэш мог ответить сразу, без сигнала versionsChanged. + emit loaderVersionsChanged(loaderKey, gameVersion); + }, force); +} + +bool LauncherBackend::loaderVersionsLoading(const QString &loaderKey, + const QString &gameVersion) const +{ + const auto loader = loaderFromKey(loaderKey); + return loader && m_loaderMeta->isRefreshing(*loader, gameVersion); +} + +void LauncherBackend::installLoaderForBuild(int index) +{ + if (index < 0 || index >= m_builds.size()) + return; + if (busy()) { + emit launchError(tr("Дождитесь окончания текущей загрузки")); + return; + } + + const CustomBuild &build = m_builds[index]; + if (build.minecraftVersion.trimmed().isEmpty()) { + emit launchError(tr("Сначала выберите версию Minecraft для сборки «%1»").arg(build.name)); + return; + } + + // Лоадер не выбран — сборка ванильная, ставим саму версию игры. + const auto loader = loaderFromKey(build.loader); + if (!loader) { + if (build.resolvedVersionId != build.minecraftVersion) + updateCustomBuild(index, {{QStringLiteral("resolvedVersionId"), QString()}}); + maybeInstall(build.minecraftVersion); + return; + } + if (build.loaderVersion.trimmed().isEmpty()) { + emit launchError(tr("Выберите версию %1 для сборки «%2»") + .arg(loaderTitle(*loader), build.name)); + return; + } + + m_loaderInstallBuildId = build.id; + m_loaderInstaller->install(gameDir(), + *loader, + build.minecraftVersion, + build.loaderVersion, + m_settings.value(QStringLiteral("javaPath")).toString()); +} + +void LauncherBackend::launchGame(int profileIndex, int buildIndex) +{ + if (m_loaderInstaller->isRunning()) { + emit launchError(tr("Идёт установка «%1» — дождитесь окончания") + .arg(m_loaderInstaller->label())); + return; + } if (m_installer->isRunning()) { emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания") .arg(m_installer->versionId())); @@ -474,8 +790,8 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex) emit launchError(tr("Выберите профиль перед запуском")); return; } - if (versionIndex < 0 || versionIndex >= m_versions.size()) { - emit launchError(tr("Выберите версию перед запуском")); + if (buildIndex < 0 || buildIndex >= m_builds.size()) { + emit launchError(tr("Выберите сборку перед запуском")); return; } @@ -488,11 +804,12 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex) return; } - const Version &entry = m_versions[versionIndex]; - const QString id = versionIdOf(entry); + const CustomBuild &entry = m_builds[buildIndex]; + const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) { - emit launchError(tr("У записи лаунчера не указан ID версии. Проверьте %1") - .arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))); + emit launchError(tr("У сборки «%1» не выбрана версия Minecraft. Проверьте %2") + .arg(entry.name, + QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))); return; } @@ -501,7 +818,7 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex) if (!installed.contains(id)) { emit launchError(installed.isEmpty() ? tr("В %1 нет ни одной установленной версии").arg(versionsDir) - : tr("Версия «%1» из записи «%2» не установлена в %3. Доступны: %4") + : tr("Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4") .arg(id, entry.name, versionsDir, @@ -536,7 +853,9 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex) } m_pendingProfile = profileIndex; - m_pendingVersion = versionIndex; + // Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из + // модального окна, и индекс уехал бы на чужую запись. + m_pendingBuild = entry.id; m_pendingInjectorPath.clear(); beginAuthentication(); } @@ -621,20 +940,26 @@ void LauncherBackend::submitTwoFactorCode(const QString &code) void LauncherBackend::cancelPendingLaunch() { m_pendingProfile = -1; - m_pendingVersion = -1; + m_pendingBuild = -1; m_pendingInjectorPath.clear(); setBusy(false); } void LauncherBackend::continueLaunch(const AuthResult &auth) { - if (m_pendingProfile < 0 || m_pendingVersion < 0) { + if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) { failLaunch(tr("Запуск был отменён")); return; } + const int buildIndex = indexOfBuildId(m_pendingBuild); + if (buildIndex < 0) { + failLaunch(tr("Сборка была удалена, пока шла авторизация")); + return; + } + Profile &profile = m_profiles[m_pendingProfile]; - const Version &version = m_versions[m_pendingVersion]; + const CustomBuild &build = m_builds[buildIndex]; // Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем, // чтобы в следующий раз обойтись без пароля. @@ -666,7 +991,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth) options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0); options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0); options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool(); - options.serverAddress = version.serverUrl.trimmed(); + options.serverAddress = build.serverUrl.trimmed(); options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString(); options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString(); @@ -682,8 +1007,8 @@ void LauncherBackend::continueLaunch(const AuthResult &auth) const bool started = m_launcher->launch(options, m_pendingVersionData, &error); const QString profileName = profile.name; - const QString versionName = version.name; - const QString serverUrl = version.serverUrl; + const QString buildName = build.name; + const QString serverUrl = build.serverUrl; cancelPendingLaunch(); if (!started) { @@ -691,7 +1016,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth) return; } emit gameRunningChanged(); - emit launched(profileName, versionName, serverUrl); + emit launched(profileName, buildName, serverUrl); } void LauncherBackend::stopGame() @@ -705,9 +1030,30 @@ void LauncherBackend::failLaunch(const QString &message) emit launchError(message); } +bool LauncherBackend::switching() const +{ + return m_switcher->isRunning(); +} + +double LauncherBackend::switchProgress() const +{ + return m_switcher->fraction(); +} + +QString LauncherBackend::switchStage() const +{ + return m_switcher->stage(); +} + +QString LauncherBackend::switchStatus() const +{ + return m_switcher->status(); +} + bool LauncherBackend::busy() const { - return m_busy || m_installer->isRunning(); + return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning() + || m_switcher->isRunning(); } void LauncherBackend::setBusy(bool busy) @@ -896,27 +1242,81 @@ void LauncherBackend::loadData() std::sort(m_profiles.begin(), m_profiles.end(), [](const Profile &a, const Profile &b) { return a.id < b.id; }); - int skippedVersions = 0; - loadFile(LauncherPaths::versionsFile(), [this, &skippedVersions](const QJsonObject &o) { - const int id = o["id"].toInt(); - const QString name = o["name"].toString(); - const QString versionId = o.value("versionId").toString(); - // Без имени и без ID запись нерабочая: versionIdOf() вернёт пустую строку. - if (name.trimmed().isEmpty() && versionId.trimmed().isEmpty()) { - ++skippedVersions; + int skippedBuilds = 0; + loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) { + CustomBuild build; + build.id = o["id"].toInt(); + build.name = o["name"].toString(); + build.serverUrl = o["serverUrl"].toString(); + build.minecraftVersion = o.value("minecraftVersion").toString(); + // Файл мог остаться от версии лаунчера до переименования или быть + // поправлен руками — старый ключ тоже принимаем. + if (build.minecraftVersion.isEmpty()) + build.minecraftVersion = o.value("versionId").toString(); + build.loader = o.value("loader").toString(); + build.loaderVersion = o.value("loaderVersion").toString(); + build.resolvedVersionId = o.value("resolvedVersionId").toString(); + + // Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт + // пустую строку. + if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) { + ++skippedBuilds; return; } - m_versions.append({id, name, o["serverUrl"].toString(), versionId}); - if (id >= m_nextVersionId) - m_nextVersionId = id + 1; + m_builds.append(build); + if (build.id >= m_nextBuildId) + m_nextBuildId = build.id + 1; }); - if (skippedVersions > 0) { - m_storageIssues << tr("В %1 пропущены записи без имени и ID версии: %2") - .arg(QDir::toNativeSeparators(LauncherPaths::versionsFile())) - .arg(skippedVersions); + if (skippedBuilds > 0) { + m_storageIssues << tr("В %1 пропущены сборки без имени и версии Minecraft: %2") + .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile())) + .arg(skippedBuilds); } - std::sort(m_versions.begin(), m_versions.end(), - [](const Version &a, const Version &b) { return a.id < b.id; }); + std::sort(m_builds.begin(), m_builds.end(), + [](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; }); +} + +// Первый запуск после переименования «версий» в «сборки»: перекладываем +// /versions.json в customBuilds.json. Старый файл не удаляем, а +// переименовываем — он остаётся резервной копией. +bool LauncherBackend::migrateLegacyBuildsFile() +{ + if (QFile::exists(LauncherPaths::customBuildsFile())) + return false; + if (!QFile::exists(LauncherPaths::legacyCustomBuildsFile())) + return false; + + // readArrayFile() сам отводит битый файл в .bak — тогда мигрировать нечего. + const QJsonArray legacy = readArrayFile(LauncherPaths::legacyCustomBuildsFile()); + if (legacy.isEmpty() && !QFile::exists(LauncherPaths::legacyCustomBuildsFile())) + return false; + + QJsonArray migrated; + for (const auto &value : legacy) { + const QJsonObject o = value.toObject(); + migrated.append(QJsonObject{{"id", o.value("id").toInt()}, + {"name", o.value("name").toString()}, + {"serverUrl", o.value("serverUrl").toString()}, + {"minecraftVersion", o.value("versionId").toString()}, + {"loader", QString()}, + {"loaderVersion", QString()}, + {"resolvedVersionId", QString()}}); + } + + // Не удалось записать — молча читаем в этой сессии из старого файла. + // Без данных пользователь не остаётся ни при каком исходе. + if (!writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(migrated))) + return false; + + const QString retired = LauncherPaths::legacyCustomBuildsFile() + QStringLiteral(".migrated"); + QFile::remove(retired); + if (!QFile::rename(LauncherPaths::legacyCustomBuildsFile(), retired)) { + m_storageIssues << tr("Сборки перенесены в %1, но старый файл %2 удалить не удалось") + .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()), + QDir::toNativeSeparators( + LauncherPaths::legacyCustomBuildsFile())); + } + return true; } // Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak — @@ -927,8 +1327,8 @@ void LauncherBackend::ensureDataFiles() saveSettings(); if (!QFile::exists(LauncherPaths::profilesFile())) saveProfiles(); - if (!QFile::exists(LauncherPaths::versionsFile())) - saveVersions(); + if (!QFile::exists(LauncherPaths::customBuildsFile())) + saveCustomBuilds(); } void LauncherBackend::saveProfiles() @@ -949,16 +1349,19 @@ void LauncherBackend::saveProfiles() writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr)); } -void LauncherBackend::saveVersions() +void LauncherBackend::saveCustomBuilds() { QJsonArray arr; - for (const auto &v : m_versions) - arr.append(QJsonObject{{"id", v.id}, - {"name", v.name}, - {"serverUrl", v.serverUrl}, - {"versionId", v.versionId}}); + for (const auto &b : m_builds) + arr.append(QJsonObject{{"id", b.id}, + {"name", b.name}, + {"serverUrl", b.serverUrl}, + {"minecraftVersion", b.minecraftVersion}, + {"loader", b.loader}, + {"loaderVersion", b.loaderVersion}, + {"resolvedVersionId", b.resolvedVersionId}}); - writeJsonFile(LauncherPaths::versionsFile(), QJsonDocument(arr)); + writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr)); } void LauncherBackend::loadSettings() @@ -972,7 +1375,8 @@ void LauncherBackend::loadSettings() {QStringLiteral("windowHeight"), 0}, {QStringLiteral("fullscreen"), false}, {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, - {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}}; + {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}, + {QStringLiteral("activeBuildId"), 0}}; const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile()); for (auto it = stored.constBegin(); it != stored.constEnd(); ++it) diff --git a/launcherbackend.h b/launcherbackend.h index ee010a4..1e98c73 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -15,6 +15,9 @@ class AuthService; class GameLauncher; class VersionManifestService; class VersionInstaller; +class ModLoaderVersionService; +class ModLoaderInstaller; +class BuildSwitcher; class LauncherBackend : public QObject { @@ -22,9 +25,22 @@ class LauncherBackend : public QObject QML_ELEMENT Q_PROPERTY(QStringList profileNames READ profileNames NOTIFY profilesChanged) - Q_PROPERTY(QStringList versionNames READ versionNames NOTIFY versionsChanged) + Q_PROPERTY(QStringList customBuildNames READ customBuildNames NOTIFY customBuildsChanged) + + // Сборка, которую запускает кнопка игры. Хранится по id сборки, а не по + // индексу: удаление соседней записи не должно переназначать активную. + Q_PROPERTY(int activeBuildIndex READ activeBuildIndex WRITE setActiveBuildIndex + NOTIFY activeBuildChanged) + Q_PROPERTY(QString activeBuildName READ activeBuildName NOTIFY activeBuildChanged) Q_PROPERTY(QStringList installedVersions READ installedVersions NOTIFY installedVersionsChanged) Q_PROPERTY(bool gameRunning READ gameRunning NOTIFY gameRunningChanged) + + // Идёт архивация/распаковка .minecraft при смене сборки. Отдельно от busy, + // потому что на это время блокируется ещё и список сборок. + Q_PROPERTY(bool switching READ switching NOTIFY switchChanged) + Q_PROPERTY(double switchProgress READ switchProgress NOTIFY switchChanged) + Q_PROPERTY(QString switchStage READ switchStage NOTIFY switchChanged) + Q_PROPERTY(QString switchStatus READ switchStatus NOTIFY switchChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) // Объединённый список для выпадающего меню версий: установленные, разделитель, @@ -46,9 +62,16 @@ public: ~LauncherBackend() override; QStringList profileNames() const; - QStringList versionNames() const; + QStringList customBuildNames() const; + int activeBuildIndex() const; + void setActiveBuildIndex(int index); + QString activeBuildName() const; QStringList installedVersions() const; bool gameRunning() const; + bool switching() const; + double switchProgress() const; + QString switchStage() const; + QString switchStatus() const; // Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет. bool busy() const; @@ -66,32 +89,35 @@ public: const QString &login, const QString &password, const QString &authType = QStringLiteral("offline")); - Q_INVOKABLE void addVersion(const QString &name, - const QString &serverUrl, - const QString &versionId = QString()); + Q_INVOKABLE void addCustomBuild(const QString &name, + const QString &serverUrl, + const QString &minecraftVersion = QString()); Q_INVOKABLE void updateProfile(int index, const QString &name, const QString &login, const QString &password, const QString &authType = QStringLiteral("offline")); - Q_INVOKABLE void updateVersion(int index, - const QString &name, - const QString &serverUrl, - const QString &versionId = QString()); + // Мержит только присланные ключи: name, serverUrl, minecraftVersion, + // loader, loaderVersion, resolvedVersionId. + Q_INVOKABLE void updateCustomBuild(int index, const QVariantMap &fields); Q_INVOKABLE QVariantMap profileAt(int index) const; - Q_INVOKABLE QVariantMap versionAt(int index) const; + Q_INVOKABLE QVariantMap customBuildAt(int index) const; Q_INVOKABLE void removeProfile(int index); - Q_INVOKABLE void removeVersion(int index); + Q_INVOKABLE void removeCustomBuild(int index); + + // Докачать то, чего не хватает выбранной сборке. Отдельной кнопкой, потому + // что карточка сборки сохраняет правки по ходу редактирования. + Q_INVOKABLE void installCustomBuild(int index); // Главная кнопка. Проверяет выбор профиля и версии, комплектность .minecraft, // при необходимости авторизуется и стартует игру. - Q_INVOKABLE void launchGame(int profileIndex, int versionIndex); + Q_INVOKABLE void launchGame(int profileIndex, int buildIndex); Q_INVOKABLE void submitTwoFactorCode(const QString &code); Q_INVOKABLE void cancelPendingLaunch(); Q_INVOKABLE void stopGame(); // Проверка без запуска — для подсказки в интерфейсе. - Q_INVOKABLE QStringList checkInstallation(int versionIndex) const; + Q_INVOKABLE QStringList checkInstallation(int buildIndex) const; // Вызывается при открытии выпадающего списка версий: свежий кэш — без сети. Q_INVOKABLE void refreshVersionCatalog(bool force = false); @@ -99,6 +125,20 @@ public: Q_INVOKABLE void installVersion(const QString &versionId); Q_INVOKABLE void cancelDownload(); + // Версии модлоадеров для выбранной версии игры. Возвращает список карт + // {version, label, recommended, stable}; несовместимых строк в нём нет — + // отбор заложен в сам источник данных. + Q_INVOKABLE QVariantList loaderVersions(const QString &loaderKey, + const QString &gameVersion) const; + Q_INVOKABLE void refreshLoaderVersions(const QString &loaderKey, + const QString &gameVersion, + bool force = false); + Q_INVOKABLE bool loaderVersionsLoading(const QString &loaderKey, + const QString &gameVersion) const; + // Ставит модлоадер, выбранный в сборке, и записывает получившийся профиль + // в resolvedVersionId. + Q_INVOKABLE void installLoaderForBuild(int index); + Q_INVOKABLE QVariantMap settings() const; Q_INVOKABLE void updateSettings(const QVariantMap &values); Q_INVOKABLE QStringList detectedJava() const; @@ -108,7 +148,10 @@ public: signals: void profilesChanged(); - void versionsChanged(); + void customBuildsChanged(); + void activeBuildChanged(); + void switchChanged(); + void loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion); void installedVersionsChanged(); void settingsChanged(); void gameRunningChanged(); @@ -118,7 +161,7 @@ signals: void downloadChanged(); void launchProgress(const QString &message); - void launched(const QString &profileName, const QString &versionName, const QString &serverUrl); + void launched(const QString &profileName, const QString &buildName, const QString &serverUrl); void launchError(const QString &message); void twoFactorRequired(const QString &profileName); void gameOutput(const QString &line); @@ -132,17 +175,23 @@ private: QString authType = QStringLiteral("offline"); // offline | elyby QString clientToken, accessToken, uuid, resolvedName, userType; }; - struct Version + // Пользовательская сборка: имя, сервер, версия Minecraft и, начиная со + // второго этапа, выбранный модлоадер. + struct CustomBuild { int id = 0; - QString name, serverUrl, versionId; + QString name, serverUrl; + QString minecraftVersion; // 1.20.1 + QString loader; // "" | forge | fabric | neoforge | quilt + QString loaderVersion; // 47.4.0 | 0.19.3 + QString resolvedVersionId; // versions/, который реально запускается }; void loadData(); void loadSettings(); void ensureDataFiles(); void saveProfiles(); - void saveVersions(); + void saveCustomBuilds(); void saveSettings(); // Чтение файлов лаунчера. Отсутствие файла — норма (первый запуск), а вот @@ -155,7 +204,13 @@ private: bool writeJsonFile(const QString &path, const QJsonDocument &document); QString gameDir() const; - QString versionIdOf(const Version &version) const; + // id профиля в /versions, который запускает эта сборка. + QString launchVersionIdOf(const CustomBuild &build) const; + int indexOfBuildId(int buildId) const; + + // Перекладывает /versions.json в customBuilds.json при первом запуске + // после переименования. true — миграция прошла, читать нужно новый файл. + bool migrateLegacyBuildsFile(); void invalidateCatalog(); void rebuildCatalog() const; @@ -168,18 +223,25 @@ private: void continueLaunch(const AuthResult &auth); QList m_profiles; - QList m_versions; + QList m_builds; QVariantMap m_settings; // Проблемы хранилища, замеченные на старте: показываются одним сообщением, // когда интерфейс уже подключился к сигналам. QStringList m_storageIssues; int m_nextProfileId = 1; - int m_nextVersionId = 1; + int m_nextBuildId = 1; AuthService *m_auth; GameLauncher *m_launcher; VersionManifestService *m_manifest; VersionInstaller *m_installer; + ModLoaderVersionService *m_loaderMeta; + ModLoaderInstaller *m_loaderInstaller; + BuildSwitcher *m_switcher; + + // Сборка, для которой сейчас ставится модлоадер: по её id находим запись, + // когда установщик отчитается. + int m_loaderInstallBuildId = -1; // Каталог версий пересобирается только по изменениям: QML читает свойство // помногу раз за кадр, пока открыт список. @@ -190,7 +252,7 @@ private: // Состояние запуска, начатого до асинхронной авторизации. int m_pendingProfile = -1; - int m_pendingVersion = -1; + int m_pendingBuild = -1; MinecraftVersion m_pendingVersionData; QString m_pendingInjectorPath; }; diff --git a/launcherpaths.cpp b/launcherpaths.cpp index 2519aa7..bbf1fa1 100644 --- a/launcherpaths.cpp +++ b/launcherpaths.cpp @@ -51,11 +51,21 @@ QString LauncherPaths::profilesFile() return rootDir() + QStringLiteral("/profiles.json"); } -QString LauncherPaths::versionsFile() +QString LauncherPaths::customBuildsFile() +{ + return rootDir() + QStringLiteral("/customBuilds.json"); +} + +QString LauncherPaths::legacyCustomBuildsFile() { return rootDir() + QStringLiteral("/versions.json"); } +QString LauncherPaths::buildStorageDir() +{ + return rootDir() + QStringLiteral("/builds"); +} + QString LauncherPaths::runtimeDir() { return rootDir() + QStringLiteral("/runtime"); @@ -71,9 +81,20 @@ QString LauncherPaths::versionManifestFile() return cacheDir() + QStringLiteral("/version_manifest.json"); } +QString LauncherPaths::loaderCacheFile(const QString &loaderKey) +{ + return cacheDir() + QStringLiteral("/loader_") + loaderKey + QStringLiteral(".json"); +} + +QString LauncherPaths::loaderDownloadDir() +{ + return cacheDir() + QStringLiteral("/loaders"); +} + bool LauncherPaths::ensureRootExists(QString *error) { - const QStringList required{rootDir(), runtimeDir(), cacheDir()}; + const QStringList required{rootDir(), runtimeDir(), cacheDir(), + loaderDownloadDir(), buildStorageDir()}; for (const QString &path : required) { if (!QDir().mkpath(path)) { if (error) diff --git a/launcherpaths.h b/launcherpaths.h index 21afea4..9d3d47b 100644 --- a/launcherpaths.h +++ b/launcherpaths.h @@ -17,7 +17,16 @@ QString defaultMinecraftDir(); QString settingsFile(); QString profilesFile(); -QString versionsFile(); + +// /customBuilds.json — пользовательские сборки. +QString customBuildsFile(); + +// /versions.json — как сборки назывались до переименования. Читается +// один раз при миграции и больше ни для чего не нужен. +QString legacyCustomBuildsFile(); + +// /builds — архивы содержимого .minecraft по одному на сборку. +QString buildStorageDir(); // Сюда качается authlib-injector. QString runtimeDir(); @@ -28,6 +37,12 @@ QString cacheDir(); // Слепок манифеста версий Mojang с отметкой времени. QString versionManifestFile(); +// Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt. +QString loaderCacheFile(const QString &loaderKey); + +// Сюда качаются installer.jar модлоадеров. +QString loaderDownloadDir(); + // Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед // каждой записью. false и текст в error, если папки нет или в неё не пишется. bool ensureRootExists(QString *error = nullptr); diff --git a/modloader.h b/modloader.h new file mode 100644 index 0000000..b18eb48 --- /dev/null +++ b/modloader.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#include + +// Модлоадеры, которые лаунчер умеет ставить. Взаимоисключающи: игра +// запускается ровно с одним профилем в /versions. +enum class ModLoader { Forge, Fabric, NeoForge, Quilt }; + +// Одна сборка модлоадера под конкретную версию игры. +struct LoaderVersionEntry +{ + QString loaderVersion; // 47.4.0 | 0.19.3 | 21.1.248 + QString gameVersion; // 1.20.1 + QString versionId; // предсказанный versions/; у Forge пусто — + // он выясняется после работы установщика + QUrl installerUrl; // только Forge и NeoForge + bool recommended = false; + bool stable = true; +}; + +// Ключ для интерфейса, кэша на диске и поля loader в customBuilds.json. +inline QString loaderKey(ModLoader loader) +{ + switch (loader) { + case ModLoader::Forge: return QStringLiteral("forge"); + case ModLoader::Fabric: return QStringLiteral("fabric"); + case ModLoader::NeoForge: return QStringLiteral("neoforge"); + case ModLoader::Quilt: return QStringLiteral("quilt"); + } + return {}; +} + +inline QString loaderTitle(ModLoader loader) +{ + switch (loader) { + case ModLoader::Forge: return QStringLiteral("Minecraft Forge"); + case ModLoader::Fabric: return QStringLiteral("Fabric Loader"); + case ModLoader::NeoForge: return QStringLiteral("NeoForge"); + case ModLoader::Quilt: return QStringLiteral("Quilt Loader"); + } + return {}; +} + +inline std::optional loaderFromKey(const QString &key) +{ + if (key == QLatin1String("forge")) return ModLoader::Forge; + if (key == QLatin1String("fabric")) return ModLoader::Fabric; + if (key == QLatin1String("neoforge")) return ModLoader::NeoForge; + if (key == QLatin1String("quilt")) return ModLoader::Quilt; + return std::nullopt; +} diff --git a/modloaderinstaller.cpp b/modloaderinstaller.cpp new file mode 100644 index 0000000..579abf4 --- /dev/null +++ b/modloaderinstaller.cpp @@ -0,0 +1,552 @@ +#include "modloaderinstaller.h" + +#include "javalocator.h" +#include "launcherpaths.h" +#include "minecraftversion.h" +#include "modloaderversionservice.h" +#include "versioninstaller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kTransferTimeoutMs = 30000; +// Установщик Forge после распаковки ещё патчит клиентский jar: на медленной +// машине это несколько минут. Обрываем только совсем зависший процесс. +constexpr int kInstallerTimeoutMs = 15 * 60 * 1000; +constexpr int kLogTailLines = 12; + +const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/"); +const QString kQuiltProfile = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/"); + +// Тот же запрет, что у VersionInstaller: id версии становится именем каталога. +bool isSafeSegment(const QString &value) +{ + if (value.isEmpty() || value.contains(u'/') || value.contains(u'\\') || value.contains(u':')) + return false; + return value != QLatin1String(".") && value != QLatin1String(".."); +} + +QString versionJsonPath(const QString &gameDir, const QString &id) +{ + return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json"); +} + +} // namespace + +ModLoaderInstaller::ModLoaderInstaller(ModLoaderVersionService *meta, + VersionInstaller *versionInstaller, + QObject *parent) + : QObject(parent) + , m_meta(meta) + , m_versionInstaller(versionInstaller) + , m_network(new QNetworkAccessManager(this)) +{ + // Последний шаг обоих путей одинаков: VersionInstaller дотягивает всё, чего + // не хватает получившемуся профилю. + connect(m_versionInstaller, &VersionInstaller::finished, this, [this](const QString &id) { + if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId) + return; + const QString versionId = m_awaitingVersionId; + const QString loader = loaderKey(m_loader); + const QString game = m_gameVersion; + const QString version = m_loaderVersion; + reset(); + emit finished(loader, game, version, versionId); + }); + connect(m_versionInstaller, &VersionInstaller::failed, this, + [this](const QString &id, const QString &message) { + if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId) + return; + fail(message); + }); + connect(m_versionInstaller, &VersionInstaller::canceled, this, [this](const QString &id) { + if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId) + return; + const QString label = m_label; + reset(); + emit canceled(label); + }); +} + +ModLoaderInstaller::~ModLoaderInstaller() +{ + if (m_process && m_process->state() != QProcess::NotRunning) { + m_process->kill(); + m_process->waitForFinished(2000); + } +} + +double ModLoaderInstaller::fraction() const +{ + if (m_bytesTotal <= 0) + return -1.0; // панель нарисует «…» + return double(m_bytesDone) / double(m_bytesTotal); +} + +void ModLoaderInstaller::install(const QString &gameDir, + ModLoader loader, + const QString &gameVersion, + const QString &loaderVersion, + const QString &javaPreference) +{ + if (m_running) { + emit failed(loaderTitle(loader), tr("Установка модлоадера уже идёт")); + return; + } + if (gameVersion.trimmed().isEmpty() || loaderVersion.trimmed().isEmpty()) { + emit failed(loaderTitle(loader), tr("Не выбрана версия %1").arg(loaderTitle(loader))); + return; + } + + m_gameDir = gameDir; + m_loader = loader; + m_gameVersion = gameVersion.trimmed(); + m_loaderVersion = loaderVersion.trimmed(); + m_javaPreference = javaPreference; + m_label = QStringLiteral("%1 %2").arg(loaderTitle(loader), m_loaderVersion); + m_installerLog.clear(); + m_bytesDone = 0; + m_bytesTotal = 0; + m_currentFile.clear(); + m_canceling = false; + m_running = true; + + emit started(m_label); + setStage(tr("Подготовка %1").arg(loaderTitle(loader))); + + // Список версий нужен и для ссылки на installer.jar, и для точного id + // профиля. После перезапуска лаунчера он ещё не прочитан с диска, поэтому + // спрашиваем его до всего остального: попадание в кэш обойдётся без сети. + m_meta->ensureLoaded(loader, m_gameVersion, [this, loader](bool ok, const QString &warning) { + if (!m_running || m_canceling) + return; + if (!ok) { + fail(warning.isEmpty() ? tr("Список версий %1 недоступен").arg(loaderTitle(loader)) + : warning); + return; + } + if (loader == ModLoader::Fabric || loader == ModLoader::Quilt) + beginFabricLike(); + else + beginInstallerJar(); + }); +} + +void ModLoaderInstaller::cancel() +{ + if (!m_running) + return; + m_canceling = true; + + if (m_reply) { + m_reply->abort(); + m_reply = nullptr; + } + if (m_process && m_process->state() != QProcess::NotRunning) + m_process->kill(); + if (!m_awaitingVersionId.isEmpty()) { + m_versionInstaller->cancel(); + return; // финал придёт из VersionInstaller::canceled + } + + const QString label = m_label; + reset(); + emit canceled(label); +} + +// ── Fabric и Quilt ───────────────────────────────────────────────────────── + +void ModLoaderInstaller::beginFabricLike() +{ + const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion); + const QString expectedId = + entry ? entry->versionId + : (m_loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-") + : QStringLiteral("quilt-loader-")) + + m_loaderVersion + u'-' + m_gameVersion; + + if (!isSafeSegment(expectedId)) { + fail(tr("Недопустимый идентификатор версии «%1»").arg(expectedId)); + return; + } + + const QString base = m_loader == ModLoader::Fabric ? kFabricProfile : kQuiltProfile; + const QUrl url(base + m_gameVersion + u'/' + m_loaderVersion + QStringLiteral("/profile/json")); + + setStage(tr("Описание версии %1").arg(loaderTitle(m_loader))); + m_currentFile = expectedId + QStringLiteral(".json"); + emit progressChanged(); + + QNetworkRequest request{url}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kTransferTimeoutMs); + + m_reply = m_network->get(request); + connect(m_reply, &QNetworkReply::finished, this, [this, expectedId] { + QNetworkReply *reply = m_reply; + m_reply = nullptr; + reply->deleteLater(); + if (!m_running || m_canceling) + return; + + if (reply->error() != QNetworkReply::NoError) { + fail(tr("Не удалось получить описание %1: %2") + .arg(loaderTitle(m_loader), reply->errorString())); + return; + } + + const QByteArray payload = reply->readAll(); + const QJsonObject root = QJsonDocument::fromJson(payload).object(); + const QString id = root.value(QStringLiteral("id")).toString(); + const QString inherits = root.value(QStringLiteral("inheritsFrom")).toString(); + + // Описание приходит из сети и превращается в путь на диске — проверяем + // и id, и то, что версия действительно наследует выбранную ванильную. + if (id.isEmpty() || !isSafeSegment(id)) { + fail(tr("Описание %1 испорчено: недопустимый идентификатор версии") + .arg(loaderTitle(m_loader))); + return; + } + if (inherits != m_gameVersion) { + fail(tr("Описание %1 рассчитано на Minecraft %2, а не %3") + .arg(loaderTitle(m_loader), inherits, m_gameVersion)); + return; + } + if (id != expectedId) + emit log(tr("Идентификатор версии: %1").arg(id)); + + const QString directory = m_gameDir + QStringLiteral("/versions/") + id; + if (!QDir().mkpath(directory)) { + fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory))); + return; + } + + QSaveFile file(versionJsonPath(m_gameDir, id)); + if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size() + || !file.commit()) { + file.cancelWriting(); + fail(tr("Не удалось сохранить описание версии «%1»").arg(id)); + return; + } + + handOffToVersionInstaller(id); + }); +} + +// ── Forge и NeoForge ─────────────────────────────────────────────────────── + +void ModLoaderInstaller::beginInstallerJar() +{ + const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion); + if (!entry || !entry->installerUrl.isValid()) { + fail(tr("%1 %2 не подходит к Minecraft %3 — выберите версию из списка") + .arg(loaderTitle(m_loader), m_loaderVersion, m_gameVersion)); + return; + } + if (entry->installerUrl.scheme() != QLatin1String("https")) { + fail(tr("Установщик %1 доступен только по недопустимой ссылке").arg(loaderTitle(m_loader))); + return; + } + + const QUrl installerUrl = entry->installerUrl; + // Установщик патчит versions//.jar — без ванильной версии он не отработает. + ensureBaseVersion([this, installerUrl] { + downloadInstaller(installerUrl, [this](const QString &path) { runInstaller(path); }); + }); +} + +void ModLoaderInstaller::ensureBaseVersion(std::function next) +{ + if (QFile::exists(versionJsonPath(m_gameDir, m_gameVersion)) + && VersionLoader::installedVersions(m_gameDir).contains(m_gameVersion)) { + next(); + return; + } + + setStage(tr("Установка Minecraft %1").arg(m_gameVersion)); + m_currentFile = m_gameVersion; + emit progressChanged(); + + const QString base = m_gameVersion; + auto guard = std::make_shared(); + auto failGuard = std::make_shared(); + + *guard = connect(m_versionInstaller, &VersionInstaller::finished, this, + [this, base, guard, failGuard, next](const QString &id) { + if (id != base) + return; + disconnect(*guard); + disconnect(*failGuard); + if (!m_running || m_canceling) + return; + next(); + }); + *failGuard = connect(m_versionInstaller, &VersionInstaller::failed, this, + [this, base, guard, failGuard](const QString &id, const QString &message) { + if (id != base) + return; + disconnect(*guard); + disconnect(*failGuard); + fail(message); + }); + + m_versionInstaller->install(m_gameDir, base); +} + +void ModLoaderInstaller::downloadInstaller(const QUrl &url, std::function next) +{ + if (!LauncherPaths::ensureRootExists()) { + fail(tr("Нет доступа к папке лаунчера")); + return; + } + + const QString target = LauncherPaths::loaderDownloadDir() + u'/' + loaderKey(m_loader) + u'-' + + QString(m_loaderVersion).replace(u'/', u'_') + + QStringLiteral("-installer.jar"); + + setStage(tr("Загрузка установщика %1").arg(loaderTitle(m_loader))); + m_currentFile = QFileInfo(target).fileName(); + emit progressChanged(); + + QNetworkRequest request{url}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kTransferTimeoutMs); + + m_reply = m_network->get(request); + connect(m_reply, &QNetworkReply::downloadProgress, this, + [this](qint64 received, qint64 total) { + m_bytesDone = received; + m_bytesTotal = total; + emit progressChanged(); + }); + connect(m_reply, &QNetworkReply::finished, this, [this, target, next] { + QNetworkReply *reply = m_reply; + m_reply = nullptr; + reply->deleteLater(); + if (!m_running || m_canceling) + return; + + if (reply->error() != QNetworkReply::NoError) { + fail(tr("Не удалось скачать установщик %1: %2") + .arg(loaderTitle(m_loader), reply->errorString())); + return; + } + + const QByteArray payload = reply->readAll(); + QSaveFile file(target); + if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size() + || !file.commit()) { + file.cancelWriting(); + fail(tr("Не удалось сохранить установщик %1").arg(loaderTitle(m_loader))); + return; + } + next(target); + }); +} + +// Без launcher_profiles.json установщик Forge и NeoForge завершается с +// сообщением про испорченный профиль лаунчера. Свои профили мы там не держим, +// поэтому достаточно заглушки. +bool ModLoaderInstaller::writeLauncherProfilesStub() +{ + const QString path = m_gameDir + QStringLiteral("/launcher_profiles.json"); + if (QFile::exists(path)) + return true; + if (!QDir().mkpath(m_gameDir)) + return false; + + const QJsonObject stub{ + {QStringLiteral("profiles"), QJsonObject{}}, + {QStringLiteral("selectedProfile"), QString()}, + {QStringLiteral("clientToken"), QString()}, + {QStringLiteral("authenticationDatabase"), QJsonObject{}}, + {QStringLiteral("launcherVersion"), + QJsonObject{{QStringLiteral("name"), QString()}, + {QStringLiteral("format"), 21}, + {QStringLiteral("profilesFormat"), 2}}}}; + + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return false; + const QByteArray payload = QJsonDocument(stub).toJson(QJsonDocument::Indented); + if (file.write(payload) != payload.size()) { + file.cancelWriting(); + return false; + } + return file.commit(); +} + +void ModLoaderInstaller::runInstaller(const QString &installerPath) +{ + if (!writeLauncherProfilesStub()) { + fail(tr("Не удалось создать launcher_profiles.json — без него установщик %1 не работает") + .arg(loaderTitle(m_loader))); + return; + } + + // Постобработка установщика гоняет те же классы, что и сама игра, поэтому + // требования к Java берём у ванильной версии. + QString loadError; + const MinecraftVersion base = VersionLoader::load(m_gameDir, m_gameVersion, {}, &loadError); + const int requiredMajor = base.isValid() ? base.javaMajor : 8; + + QString javaError; + const QString java = JavaLocator::select(m_gameDir, requiredMajor, m_javaPreference, &javaError); + if (java.isEmpty()) { + fail(javaError.isEmpty() ? tr("Java не найдена — установщик %1 запустить нечем") + .arg(loaderTitle(m_loader)) + : javaError); + return; + } + + m_versionsBefore = VersionLoader::installedVersions(m_gameDir); + setStage(tr("Установка %1").arg(loaderTitle(m_loader))); + m_currentFile = tr("работает установщик, это может занять несколько минут"); + // Прогресс установщика неизвестен: панель покажет «…». + m_bytesDone = 0; + m_bytesTotal = 0; + emit progressChanged(); + + m_process = new QProcess(this); + m_process->setProgram(java); + m_process->setArguments({QStringLiteral("-jar"), installerPath, + QStringLiteral("--installClient"), QDir::toNativeSeparators(m_gameDir)}); + m_process->setWorkingDirectory(LauncherPaths::loaderDownloadDir()); + m_process->setProcessChannelMode(QProcess::MergedChannels); + + connect(m_process, &QProcess::readyReadStandardOutput, this, [this] { + const QStringList lines = QString::fromLocal8Bit(m_process->readAllStandardOutput()) + .split(u'\n', Qt::SkipEmptyParts); + for (const QString &line : lines) { + m_installerLog << line.trimmed(); + emit log(line.trimmed()); + } + // Хвоста хватает, чтобы показать причину отказа; весь лог не нужен. + while (m_installerLog.size() > kLogTailLines) + m_installerLog.removeFirst(); + }); + connect(m_process, &QProcess::finished, this, + [this](int exitCode, QProcess::ExitStatus status) { + handleInstallerFinished(exitCode, status == QProcess::CrashExit); + }); + connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (m_canceling || error == QProcess::Crashed) + return; // Crashed придёт ещё и в finished + fail(tr("Не удалось запустить установщик %1: %2") + .arg(loaderTitle(m_loader), m_process->errorString())); + }); + + m_process->start(); + if (!m_process->waitForStarted(kTransferTimeoutMs)) { + fail(tr("Установщик %1 не запустился: %2") + .arg(loaderTitle(m_loader), m_process->errorString())); + return; + } + + // Совсем зависший установщик не должен блокировать лаунчер навсегда. + QTimer::singleShot(kInstallerTimeoutMs, this, [this] { + if (m_process && m_process->state() != QProcess::NotRunning) { + m_process->kill(); + fail(tr("Установщик %1 не завершился за отведённое время").arg(loaderTitle(m_loader))); + } + }); +} + +void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) +{ + if (m_process) { + m_process->deleteLater(); + m_process = nullptr; + } + if (!m_running || m_canceling) + return; + + if (crashed || exitCode != 0) { + fail(tr("Установщик %1 завершился с ошибкой (код %2).\n%3") + .arg(loaderTitle(m_loader)) + .arg(exitCode) + .arg(m_installerLog.join(u'\n'))); + return; + } + + // Какой профиль появился — выясняем разницей: id у Forge разные по эпохам + // (1.20.1-forge-47.4.0, 1.12.2-forge-14.23.5.2859, neoforge-21.1.66). + const QStringList after = VersionLoader::installedVersions(m_gameDir); + QStringList added; + for (const QString &id : after) { + if (!m_versionsBefore.contains(id)) + added << id; + } + + if (added.isEmpty()) { + fail(tr("Установщик %1 отработал, но новая версия в %2 не появилась.\n%3") + .arg(loaderTitle(m_loader), + QDir::toNativeSeparators(m_gameDir + QStringLiteral("/versions")), + m_installerLog.join(u'\n'))); + return; + } + if (added.size() > 1) { + // Берём тот, где встречается версия лоадера: остальные — попутно + // доустановленные зависимости. + const auto match = std::find_if(added.cbegin(), added.cend(), [this](const QString &id) { + return id.contains(m_loaderVersion); + }); + added = {match != added.cend() ? *match : added.first()}; + } + + handOffToVersionInstaller(added.first()); +} + +// ── Общий хвост ──────────────────────────────────────────────────────────── + +void ModLoaderInstaller::handOffToVersionInstaller(const QString &versionId) +{ + m_awaitingVersionId = versionId; + setStage(tr("Загрузка файлов версии")); + m_currentFile = versionId; + emit progressChanged(); + m_versionInstaller->install(m_gameDir, versionId); +} + +void ModLoaderInstaller::setStage(const QString &stage) +{ + m_stage = stage; + emit progressChanged(); +} + +void ModLoaderInstaller::reset() +{ + m_running = false; + m_canceling = false; + m_awaitingVersionId.clear(); + m_stage.clear(); + m_currentFile.clear(); + m_bytesDone = 0; + m_bytesTotal = 0; + m_versionsBefore.clear(); + if (m_process) { + m_process->deleteLater(); + m_process = nullptr; + } + emit progressChanged(); +} + +void ModLoaderInstaller::fail(const QString &message) +{ + const QString label = m_label; + reset(); + emit failed(label, message); +} diff --git a/modloaderinstaller.h b/modloaderinstaller.h new file mode 100644 index 0000000..ba4feb1 --- /dev/null +++ b/modloaderinstaller.h @@ -0,0 +1,106 @@ +#pragma once + +#include "modloader.h" + +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; +class QProcess; +class ModLoaderVersionService; +class VersionInstaller; + +// Установка модлоадера в .minecraft. Два разных пути под одним фасадом: +// +// * Fabric и Quilt отдают готовое описание версии — кладём его в +// versions//.json и передаём дальше VersionInstaller, который по +// inheritsFrom сам поставит ванильную версию и библиотеки лоадера. +// +// * Forge и NeoForge так не умеют: их установка — это патч клиентского jar, +// поэтому запускаем официальный installer.jar найденной Java в headless-режиме +// и смотрим, какой профиль появился в versions. +// +// Набор геттеров повторяет VersionInstaller: панель загрузки в интерфейсе +// читает их одинаково, независимо от того, кто сейчас работает. +class ModLoaderInstaller : public QObject +{ + Q_OBJECT + +public: + ModLoaderInstaller(ModLoaderVersionService *meta, + VersionInstaller *versionInstaller, + QObject *parent = nullptr); + ~ModLoaderInstaller() 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 пока итог неизвестен + + void install(const QString &gameDir, + ModLoader loader, + const QString &gameVersion, + const QString &loaderVersion, + const QString &javaPreference); + void cancel(); + +signals: + void started(const QString &label); + void progressChanged(); + void finished(const QString &loaderKey, + const QString &gameVersion, + const QString &loaderVersion, + const QString &producedVersionId); + void failed(const QString &label, const QString &message); + void canceled(const QString &label); + void log(const QString &line); + +private: + void beginFabricLike(); + void beginInstallerJar(); + void ensureBaseVersion(std::function next); + void downloadInstaller(const QUrl &url, std::function next); + bool writeLauncherProfilesStub(); + void runInstaller(const QString &installerPath); + void handleInstallerFinished(int exitCode, bool crashed); + + void handOffToVersionInstaller(const QString &versionId); + void setStage(const QString &stage); + void reset(); + void fail(const QString &message); + + ModLoaderVersionService *m_meta; + VersionInstaller *m_versionInstaller; + QNetworkAccessManager *m_network; + QNetworkReply *m_reply = nullptr; + QProcess *m_process = nullptr; + + QString m_gameDir; + ModLoader m_loader = ModLoader::Fabric; + QString m_gameVersion; + QString m_loaderVersion; + QString m_javaPreference; + QString m_label; + + QString m_stage; + QString m_currentFile; + qint64 m_bytesDone = 0; + qint64 m_bytesTotal = 0; + + // Профили, которые были в versions до запуска installer.jar: новый ищем + // разницей, а не угадыванием строки — id у Forge разные по эпохам. + QStringList m_versionsBefore; + QStringList m_installerLog; + + // Ждём, пока VersionInstaller докачает то, что установщик не положил. + QString m_awaitingVersionId; + + bool m_running = false; + bool m_canceling = false; +}; diff --git a/modloaderversionservice.cpp b/modloaderversionservice.cpp new file mode 100644 index 0000000..6b53c81 --- /dev/null +++ b/modloaderversionservice.cpp @@ -0,0 +1,520 @@ +#include "modloaderversionservice.h" + +#include "launcherpaths.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +// Списки лоадеров меняются реже манифеста Mojang, но шесть часов держим те же: +// одна цифра на весь лаунчер понятнее двух разных. +constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60; +constexpr int kTransferTimeoutMs = 20000; + +const QString kFabricMeta = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/"); +const QString kQuiltMeta = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/"); + +const QString kForgeMaven = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/"); +const QString kForgePromotions = + QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json"); + +const QString kNeoForgeMaven = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/"); +// Под 1.20.1 NeoForge выходил ещё под старым именем артефакта. +const QString kNeoForgeLegacyMaven = + QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/"); + +bool looksUnstable(const QString &version) +{ + const QString lower = version.toLower(); + return lower.contains(QLatin1String("alpha")) || lower.contains(QLatin1String("beta")) + || lower.contains(QLatin1String("-rc")) || lower.contains(QLatin1String("-pre")) + || lower.contains(u'+'); +} + +// Сравнение версий по числовым компонентам: «47.4.23» новее «47.4.2», хотя как +// строка стоит раньше. Нечисловые хвосты (alpha, +snapshot) сравниваются как есть. +bool isNewer(const QString &a, const QString &b) +{ + const QStringList pa = a.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts); + const QStringList pb = b.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts); + for (int i = 0; i < std::max(pa.size(), pb.size()); ++i) { + const QString sa = i < pa.size() ? pa.at(i) : QString(); + const QString sb = i < pb.size() ? pb.at(i) : QString(); + bool okA = false, okB = false; + const int na = sa.toInt(&okA); + const int nb = sb.toInt(&okB); + if (okA && okB) { + if (na != nb) + return na > nb; + continue; + } + // Число всегда «новее» суффикса: 26.1.0.0 идёт впереди 26.1.0.0-alpha.1. + if (okA != okB) + return okA; + if (sa != sb) + return sa > sb; + } + return false; +} + +// «21.1.248» -> «1.21.1», «21.0.167» -> «1.21», «26.1.2.97» -> «26.1.2», +// «26.2.0.68» -> «26.2». До 1.20.2 Minecraft нумеровался как 1.x.y, с 26.1 — +// как x.y, поэтому у NeoForge две схемы: три компонента и четыре. +QString neoForgeGameVersion(const QString &neoVersion) +{ + const QString head = neoVersion.section(u'-', 0, 0).section(u'+', 0, 0); + const QStringList parts = head.split(u'.'); + if (parts.size() == 3) + return parts.at(1) == QLatin1String("0") ? QStringLiteral("1.%1").arg(parts.at(0)) + : QStringLiteral("1.%1.%2").arg(parts.at(0), parts.at(1)); + if (parts.size() >= 4) + return parts.at(2) == QLatin1String("0") + ? QStringLiteral("%1.%2").arg(parts.at(0), parts.at(1)) + : QStringLiteral("%1.%2.%3").arg(parts.at(0), parts.at(1), parts.at(2)); + return {}; +} + +QJsonObject entryToJson(const LoaderVersionEntry &entry) +{ + return {{QStringLiteral("loaderVersion"), entry.loaderVersion}, + {QStringLiteral("gameVersion"), entry.gameVersion}, + {QStringLiteral("versionId"), entry.versionId}, + {QStringLiteral("installerUrl"), entry.installerUrl.toString()}, + {QStringLiteral("recommended"), entry.recommended}, + {QStringLiteral("stable"), entry.stable}}; +} + +LoaderVersionEntry entryFromJson(const QJsonObject &o) +{ + LoaderVersionEntry entry; + entry.loaderVersion = o.value(QStringLiteral("loaderVersion")).toString(); + entry.gameVersion = o.value(QStringLiteral("gameVersion")).toString(); + entry.versionId = o.value(QStringLiteral("versionId")).toString(); + entry.installerUrl = QUrl(o.value(QStringLiteral("installerUrl")).toString()); + entry.recommended = o.value(QStringLiteral("recommended")).toBool(); + entry.stable = o.value(QStringLiteral("stable")).toBool(true); + return entry; +} + +} // namespace + +ModLoaderVersionService::ModLoaderVersionService(QObject *parent) + : QObject(parent) + , m_network(new QNetworkAccessManager(this)) +{ +} + +bool ModLoaderVersionService::isPerGameVersion(ModLoader loader) +{ + return loader == ModLoader::Fabric || loader == ModLoader::Quilt; +} + +ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader) +{ + return m_buckets[static_cast(loader)]; +} + +const ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader) const +{ + static const Bucket empty; + const auto it = m_buckets.constFind(static_cast(loader)); + return it == m_buckets.constEnd() ? empty : it.value(); +} + +QString ModLoaderVersionService::requestKey(ModLoader loader, const QString &gameVersion) +{ + // У Forge и NeoForge один запрос покрывает все версии игры — они делят + // очередь колбэков, чтобы не качать maven-metadata по разу на версию. + return isPerGameVersion(loader) ? loaderKey(loader) + u'/' + gameVersion : loaderKey(loader); +} + +bool ModLoaderVersionService::isFresh(ModLoader loader, const QString &gameVersion) const +{ + const Bucket &b = bucket(loader); + const QDateTime fetched = isPerGameVersion(loader) + ? b.fetchedAtByGame.value(gameVersion) + : b.fetchedAt; + return fetched.isValid() && fetched.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds; +} + +QList ModLoaderVersionService::versions(ModLoader loader, + const QString &gameVersion) const +{ + return bucket(loader).byGame.value(gameVersion); +} + +bool ModLoaderVersionService::isRefreshing(ModLoader loader, const QString &gameVersion) const +{ + return m_inFlight.contains(requestKey(loader, gameVersion)); +} + +std::optional ModLoaderVersionService::find(ModLoader loader, + const QString &gameVersion, + const QString &loaderVersion) const +{ + const QList list = versions(loader, gameVersion); + for (const LoaderVersionEntry &entry : list) { + if (entry.loaderVersion == loaderVersion) + return entry; + } + return std::nullopt; +} + +void ModLoaderVersionService::ensureLoaded(ModLoader loader, + const QString &gameVersion, + Callback callback, + bool forceRefresh) +{ + if (gameVersion.trimmed().isEmpty()) { + if (callback) + QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); + return; + } + + Bucket &b = bucket(loader); + if (!b.cacheRead) { + b.cacheRead = true; + if (readCache(loader)) + emit versionsChanged(loaderKey(loader), gameVersion); + } + + if (!forceRefresh && isFresh(loader, gameVersion)) { + // Колбэк, который то синхронный, то нет, ломает вызывающий код — + // на попадании в кэш отвечаем через событийный цикл. + if (callback) + QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); + return; + } + + const QString key = requestKey(loader, gameVersion); + if (callback) + m_pending[key] << std::move(callback); + if (m_inFlight.contains(key)) + return; + startRefresh(loader, gameVersion); +} + +void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &gameVersion) +{ + const QString key = requestKey(loader, gameVersion); + m_inFlight.insert(key); + emit refreshingChanged(); + + const auto request = [](const QUrl &url) { + QNetworkRequest req{url}; + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + req.setTransferTimeout(kTransferTimeoutMs); + return req; + }; + + if (isPerGameVersion(loader)) { + const QString base = loader == ModLoader::Fabric ? kFabricMeta : kQuiltMeta; + QNetworkReply *reply = m_network->get(request(QUrl(base + gameVersion))); + connect(reply, &QNetworkReply::finished, this, [this, reply, loader, gameVersion] { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + finishRefresh(loader, gameVersion, false, + tr("Не удалось получить список версий %1: %2") + .arg(loaderTitle(loader), reply->errorString())); + return; + } + + const QByteArray payload = reply->readAll(); + // Пустой список — законный ответ: этот лоадер не поддерживает такую + // версию игры. Отличаем его от испорченного ответа по самому JSON. + const QJsonDocument document = QJsonDocument::fromJson(payload); + if (!document.isArray()) { + finishRefresh(loader, gameVersion, false, + tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader))); + return; + } + + Bucket &b = bucket(loader); + b.byGame.insert(gameVersion, parseFabricLike(payload, loader, gameVersion)); + b.fetchedAtByGame.insert(gameVersion, QDateTime::currentDateTimeUtc()); + writeCache(loader); + emit versionsChanged(loaderKey(loader), gameVersion); + finishRefresh(loader, gameVersion, true, {}); + }); + return; + } + + // Forge и NeoForge: основной maven-metadata плюс вспомогательный запрос + // (рекомендованные сборки у Forge, старая схема 1.20.1 у NeoForge). + // Считаем оба и сводим результат, когда придёт последний. + struct Pending + { + int outstanding = 2; + QHash> merged; + QHash recommended; // версия игры -> версия лоадера + bool primaryOk = false; + QString warning; + }; + auto state = std::make_shared(); + + const auto settle = [this, state, loader, gameVersion] { + if (--state->outstanding > 0) + return; + if (!state->primaryOk) { + finishRefresh(loader, gameVersion, false, state->warning); + return; + } + + for (auto it = state->merged.begin(); it != state->merged.end(); ++it) { + const QString promoted = state->recommended.value(it.key()); + if (promoted.isEmpty()) + continue; + for (LoaderVersionEntry &entry : it.value()) + entry.recommended = entry.loaderVersion == promoted; + } + + Bucket &b = bucket(loader); + b.byGame = state->merged; + b.fetchedAt = QDateTime::currentDateTimeUtc(); + writeCache(loader); + emit versionsChanged(loaderKey(loader), gameVersion); + finishRefresh(loader, gameVersion, true, {}); + }; + + const QString primaryUrl = loader == ModLoader::Forge ? kForgeMaven : kNeoForgeMaven; + QNetworkReply *primary = m_network->get(request(QUrl(primaryUrl + QStringLiteral("maven-metadata.xml")))); + connect(primary, &QNetworkReply::finished, this, [this, primary, state, loader, settle] { + primary->deleteLater(); + if (primary->error() != QNetworkReply::NoError) { + state->warning = tr("Не удалось получить список версий %1: %2") + .arg(loaderTitle(loader), primary->errorString()); + settle(); + return; + } + auto parsed = parseMavenMetadata(primary->readAll(), loader, /*legacyNeoForge=*/false); + if (parsed.isEmpty()) { + state->warning = tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader)); + settle(); + return; + } + state->primaryOk = true; + state->merged = std::move(parsed); + settle(); + }); + + if (loader == ModLoader::Forge) { + QNetworkReply *promos = m_network->get(request(QUrl(kForgePromotions))); + connect(promos, &QNetworkReply::finished, this, [promos, state, settle] { + promos->deleteLater(); + if (promos->error() == QNetworkReply::NoError) { + const QJsonObject root = QJsonDocument::fromJson(promos->readAll()).object(); + const QJsonObject promoted = root.value(QStringLiteral("promos")).toObject(); + for (auto it = promoted.constBegin(); it != promoted.constEnd(); ++it) { + if (!it.key().endsWith(QLatin1String("-recommended"))) + continue; + state->recommended.insert(it.key().chopped(int(sizeof("-recommended") - 1)), + it.value().toString()); + } + } + // Метка «рекомендовано» необязательна: без неё список всё равно рабочий. + settle(); + }); + } else { + QNetworkReply *legacy = + m_network->get(request(QUrl(kNeoForgeLegacyMaven + QStringLiteral("maven-metadata.xml")))); + connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] { + legacy->deleteLater(); + if (legacy->error() == QNetworkReply::NoError) { + const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge, + /*legacyNeoForge=*/true); + for (auto it = extra.constBegin(); it != extra.constEnd(); ++it) + state->merged[it.key()] += it.value(); + } + settle(); + }); + } +} + +void ModLoaderVersionService::finishRefresh(ModLoader loader, + const QString &gameVersion, + bool ok, + const QString &warning) +{ + const QString key = requestKey(loader, gameVersion); + m_inFlight.remove(key); + emit refreshingChanged(); + + // Колбэк может снова позвать ensureLoaded — список забираем себе заранее. + const QList pending = m_pending.take(key); + const bool haveData = !bucket(loader).byGame.isEmpty(); + for (const Callback &callback : pending) { + // Сеть недоступна, но старые данные есть — работаем на них. + if (!ok && haveData) + callback(true, warning); + else + callback(ok, warning); + } +} + +QList ModLoaderVersionService::parseFabricLike(const QByteArray &payload, + ModLoader loader, + const QString &gameVersion) +{ + const QString prefix = loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-") + : QStringLiteral("quilt-loader-"); + QList list; + const QJsonArray array = QJsonDocument::fromJson(payload).array(); + list.reserve(array.size()); + + for (const QJsonValue &value : array) { + const QJsonObject object = value.toObject().value(QStringLiteral("loader")).toObject(); + LoaderVersionEntry entry; + entry.loaderVersion = object.value(QStringLiteral("version")).toString(); + if (entry.loaderVersion.isEmpty()) + continue; + entry.gameVersion = gameVersion; + entry.versionId = prefix + entry.loaderVersion + u'-' + gameVersion; + entry.stable = object.value(QStringLiteral("stable")).toBool(!looksUnstable(entry.loaderVersion)); + list << entry; + } + + // Fabric и Quilt отдают новые сверху, но зависеть от этого не стоит. + std::stable_sort(list.begin(), list.end(), + [](const LoaderVersionEntry &a, const LoaderVersionEntry &b) { + return isNewer(a.loaderVersion, b.loaderVersion); + }); + return list; +} + +QHash> ModLoaderVersionService::parseMavenMetadata( + const QByteArray &xml, ModLoader loader, bool legacyNeoForge) +{ + // Версия игры зашита в саму строку версии лоадера, поэтому раскладка по + // версиям игры — это и есть проверка совместимости: несовместимой пары в + // получившемся отображении просто не существует. + const bool splitOnDash = loader == ModLoader::Forge || legacyNeoForge; + const QString base = loader == ModLoader::Forge ? kForgeMaven + : legacyNeoForge ? kNeoForgeLegacyMaven + : kNeoForgeMaven; + const QString artifact = loader == ModLoader::Forge ? QStringLiteral("forge") + : legacyNeoForge ? QStringLiteral("forge") + : QStringLiteral("neoforge"); + + QHash> byGame; + QXmlStreamReader reader(xml); + while (!reader.atEnd()) { + if (reader.readNext() != QXmlStreamReader::StartElement) + continue; + if (reader.name() != QLatin1String("version")) + continue; + + const QString full = reader.readElementText().trimmed(); + if (full.isEmpty()) + continue; + + LoaderVersionEntry entry; + if (splitOnDash) { + const int dash = full.indexOf(u'-'); + if (dash <= 0 || dash + 1 >= full.size()) + continue; + entry.gameVersion = full.left(dash); + entry.loaderVersion = full.mid(dash + 1); + } else { + entry.gameVersion = neoForgeGameVersion(full); + entry.loaderVersion = full; + } + if (entry.gameVersion.isEmpty()) + continue; + + entry.stable = !looksUnstable(full); + entry.installerUrl = QUrl(base + full + u'/' + artifact + u'-' + full + + QStringLiteral("-installer.jar")); + byGame[entry.gameVersion] << entry; + } + if (reader.hasError()) + return {}; + + // maven-metadata приходит неотсортированным: 1.20.1-47.4.23 стоит между + // версиями 26.x. Без сортировки «самой новой» оказалась бы случайная сборка. + for (auto it = byGame.begin(); it != byGame.end(); ++it) { + std::stable_sort(it.value().begin(), it.value().end(), + [](const LoaderVersionEntry &a, const LoaderVersionEntry &b) { + return isNewer(a.loaderVersion, b.loaderVersion); + }); + } + return byGame; +} + +bool ModLoaderVersionService::readCache(ModLoader loader) +{ + QFile file(LauncherPaths::loaderCacheFile(loaderKey(loader))); + if (!file.open(QIODevice::ReadOnly)) + return false; + + const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); + const QJsonObject games = root.value(QStringLiteral("games")).toObject(); + if (games.isEmpty()) + return false; + + Bucket &b = bucket(loader); + for (auto it = games.constBegin(); it != games.constEnd(); ++it) { + const QJsonObject game = it.value().toObject(); + QList entries; + const QJsonArray array = game.value(QStringLiteral("entries")).toArray(); + entries.reserve(array.size()); + for (const QJsonValue &value : array) + entries << entryFromJson(value.toObject()); + b.byGame.insert(it.key(), entries); + const QDateTime fetched = + QDateTime::fromString(game.value(QStringLiteral("fetchedAt")).toString(), Qt::ISODate); + if (fetched.isValid()) + b.fetchedAtByGame.insert(it.key(), fetched); + } + b.fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(), + Qt::ISODate); + return true; +} + +void ModLoaderVersionService::writeCache(ModLoader loader) +{ + // Папку кэша могли удалить уже после запуска лаунчера. + if (!LauncherPaths::ensureRootExists()) + return; + + const Bucket &b = bucket(loader); + QJsonObject games; + for (auto it = b.byGame.constBegin(); it != b.byGame.constEnd(); ++it) { + QJsonArray entries; + for (const LoaderVersionEntry &entry : it.value()) + entries << entryToJson(entry); + games.insert(it.key(), + QJsonObject{{QStringLiteral("fetchedAt"), + b.fetchedAtByGame.value(it.key()).toString(Qt::ISODate)}, + {QStringLiteral("entries"), entries}}); + } + + const QJsonObject root{{QStringLiteral("fetchedAt"), b.fetchedAt.toString(Qt::ISODate)}, + {QStringLiteral("games"), games}}; + + // QSaveFile: оборванная запись не оставит обрезанный кэш, который потом + // пришлось бы отличать от настоящего. + QSaveFile file(LauncherPaths::loaderCacheFile(loaderKey(loader))); + 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/modloaderversionservice.h b/modloaderversionservice.h new file mode 100644 index 0000000..fe78128 --- /dev/null +++ b/modloaderversionservice.h @@ -0,0 +1,96 @@ +#pragma once + +#include "modloader.h" + +#include +#include +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; + +// Списки версий модлоадеров: качает, кэширует в папке лаунчера и отдаёт из +// кэша, пока тот не устарел. Устроен как VersionManifestService и работает в +// GUI-потоке. +// +// Совместимость с версией игры не проверяется отдельной логикой, а заложена в +// структуру данных: Fabric и Quilt отдают список сразу под нужную версию игры, +// а maven-metadata Forge и NeoForge раскладывается по версиям игры при разборе. +// Версии игры, под которую сборок нет, соответствует пустой список — выбрать +// несовместимый лоадер физически нечем. +class ModLoaderVersionService : public QObject +{ + Q_OBJECT + +public: + // ok = true и непустой warning — данные отдали из устаревшего кэша. + using Callback = std::function; + + explicit ModLoaderVersionService(QObject *parent = nullptr); + + // Единственная точка входа. Свежий кэш — колбэк отложенно, иначе один + // сетевой запрос на всех, кто успел попросить. + void ensureLoaded(ModLoader loader, + const QString &gameVersion, + Callback callback, + bool forceRefresh = false); + + // Новые сборки сверху: первая строка — самая свежая для этой версии игры. + QList versions(ModLoader loader, const QString &gameVersion) const; + bool isRefreshing(ModLoader loader, const QString &gameVersion) const; + + // Запись по версии лоадера — из неё установщик берёт ссылку на installer.jar. + std::optional find(ModLoader loader, + const QString &gameVersion, + const QString &loaderVersion) const; + +signals: + void versionsChanged(const QString &loaderKey, const QString &gameVersion); + void refreshingChanged(); + +private: + // Fabric и Quilt спрашиваются по каждой версии игры отдельно, Forge и + // NeoForge — одним maven-metadata на все версии сразу. Отсюда два вида + // ключа свежести: у первых — на версию игры, у вторых — на весь лоадер. + static bool isPerGameVersion(ModLoader loader); + + struct Bucket + { + QHash> byGame; + QHash fetchedAtByGame; // Fabric, Quilt + QDateTime fetchedAt; // Forge, NeoForge + bool cacheRead = false; + }; + + Bucket &bucket(ModLoader loader); + const Bucket &bucket(ModLoader loader) const; + + static QString requestKey(ModLoader loader, const QString &gameVersion); + bool isFresh(ModLoader loader, const QString &gameVersion) const; + + bool readCache(ModLoader loader); + void writeCache(ModLoader loader); + void startRefresh(ModLoader loader, const QString &gameVersion); + void finishRefresh(ModLoader loader, + const QString &gameVersion, + bool ok, + const QString &warning); + + // Разбор ответов. Возвращают пустое при испорченном ответе — хороший кэш им + // не затирается. + static QList parseFabricLike(const QByteArray &payload, + ModLoader loader, + const QString &gameVersion); + static QHash> parseMavenMetadata(const QByteArray &xml, + ModLoader loader, + bool legacyNeoForge); + + QNetworkAccessManager *m_network; + QHash m_buckets; + QHash> m_pending; + QSet m_inFlight; +}; diff --git a/versioninstaller.cpp b/versioninstaller.cpp index f5edc6d..276b539 100644 --- a/versioninstaller.cpp +++ b/versioninstaller.cpp @@ -64,6 +64,11 @@ bool isSafeRelativeSegment(const QString &value) return !value.contains(u'/') && isSafeRelativePath(value); } +QString versionJsonPath(const QString &gameDir, const QString &id) +{ + return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json"); +} + bool isTransient(QNetworkReply::NetworkError error, int httpStatus) { if (httpStatus == 429 || httpStatus >= 500) @@ -234,20 +239,34 @@ void VersionInstaller::resolveManifestEntry() return; if (!ok) { + QFile local(versionJsonPath(m_gameDir, id)); + if (local.open(QIODevice::ReadOnly)) { + adoptVersionJson(local.readAll(), /*alreadySaved=*/true); + return; + } failInstall(warning.isEmpty() ? tr("Список версий Mojang недоступен") : warning); return; } const auto entry = m_manifest->find(id); - if (!entry) { - // Обычно это вручную вписанный id сборки — сообщение не должно - // выглядеть как сетевая ошибка. - failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — " - "установить её автоматически нельзя") - .arg(id)); + if (entry) { + fetchVersionJson(entry->url, entry->sha1); return; } - fetchVersionJson(entry->url, entry->sha1); + + // Версии нет в манифесте, но описание могло уже лечь на диск: так + // ставятся сборки модлоадеров, чьи id Mojang не знает. + QFile local(versionJsonPath(m_gameDir, id)); + if (local.open(QIODevice::ReadOnly)) { + adoptVersionJson(local.readAll(), /*alreadySaved=*/true); + return; + } + + // Иначе это вручную вписанный id сборки — сообщение не должно + // выглядеть как сетевая ошибка. + failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — " + "установить её автоматически нельзя") + .arg(id)); }); } @@ -283,12 +302,23 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected } } - m_versionJson = QJsonDocument::fromJson(payload).object(); - if (m_versionJson.isEmpty()) { - failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id)); - return; - } + adoptVersionJson(payload, /*alreadySaved=*/false); + }); +} +// Общий хвост для описания версии, откуда бы оно ни пришло: из сети или уже +// лежащим на диске (сборки модлоадеров кладут его туда сами). +void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadySaved) +{ + const QString id = m_versionId; + + m_versionJson = QJsonDocument::fromJson(payload).object(); + if (m_versionJson.isEmpty()) { + failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id)); + return; + } + + if (!alreadySaved) { const QString directory = m_gameDir + QStringLiteral("/versions/") + id; if (!QDir().mkpath(directory)) { failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory))); @@ -302,28 +332,27 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected failInstall(tr("Не удалось сохранить описание версии «%1»").arg(id)); return; } + } - // Ванильные версии ничего не наследуют, но сборка могла бы — тогда - // сначала ставим родителя, а потом возвращаемся к этой версии. - const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString(); - if (!parentId.isEmpty() && isSafeRelativeSegment(parentId) - && !QFile::exists(m_gameDir + QStringLiteral("/versions/") + parentId + u'/' + parentId - + QStringLiteral(".json"))) { - if (m_inheritDepth >= kMaxInheritDepth) { - failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id)); - return; - } - ++m_inheritDepth; - m_pendingInstalls.prepend({m_gameDir, id}); - m_pendingInstalls.prepend({m_gameDir, parentId}); - abandonCurrent(); + // Ванильные версии ничего не наследуют, но сборка могла бы — тогда + // сначала ставим родителя, а потом возвращаемся к этой версии. + const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString(); + if (!parentId.isEmpty() && isSafeRelativeSegment(parentId) + && !QFile::exists(versionJsonPath(m_gameDir, parentId))) { + if (m_inheritDepth >= kMaxInheritDepth) { + failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id)); return; } + ++m_inheritDepth; + m_pendingInstalls.prepend({m_gameDir, id}); + m_pendingInstalls.prepend({m_gameDir, parentId}); + abandonCurrent(); + return; + } - m_inheritDepth = 0; - if (prepareTasks()) - pump(); - }); + m_inheritDepth = 0; + if (prepareTasks()) + pump(); } // ── Составление списка файлов ────────────────────────────────────────────── diff --git a/versioninstaller.h b/versioninstaller.h index 3a2801e..91d660b 100644 --- a/versioninstaller.h +++ b/versioninstaller.h @@ -80,6 +80,8 @@ private: void startNext(); void resolveManifestEntry(); void fetchVersionJson(const QUrl &url, const QString &expectedSha1); + // Общий хвост: описание версии пришло из сети или уже лежало на диске. + void adoptVersionJson(const QByteArray &payload, bool alreadySaved); bool prepareTasks(); void enqueue(DownloadTask task);