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);