From 2c4b594c902d9ab684521408dae16a994efcc31b Mon Sep 17 00:00:00 2001 From: galeon Date: Sat, 22 Aug 2026 18:19:13 +0300 Subject: [PATCH] adding startup progress and style fix --- CMakeLists.txt | 13 +- Main.qml | 611 +++++++++++++++++++++++++++++++++++++++++-- authservice.cpp | 297 +++++++++++++++++++++ authservice.h | 76 ++++++ gamelauncher.cpp | 341 ++++++++++++++++++++++++ gamelauncher.h | 80 ++++++ javalocator.cpp | 203 ++++++++++++++ javalocator.h | 25 ++ launcherbackend.cpp | 487 +++++++++++++++++++++++++++++++--- launcherbackend.h | 97 ++++++- main.cpp | 5 + minecraftversion.cpp | 466 +++++++++++++++++++++++++++++++++ minecraftversion.h | 65 +++++ 13 files changed, 2699 insertions(+), 67 deletions(-) create mode 100644 authservice.cpp create mode 100644 authservice.h create mode 100644 gamelauncher.cpp create mode 100644 gamelauncher.h create mode 100644 javalocator.cpp create mode 100644 javalocator.h create mode 100644 minecraftversion.cpp create mode 100644 minecraftversion.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f0fe17b..490c7f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,8 @@ project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(Qt6 REQUIRED COMPONENTS Quick Core Gui) +# CorePrivate нужен ради QZipReader — им распаковываются нативные библиотеки LWJGL. +find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Core CorePrivate Gui Network) qt_standard_project_setup(REQUIRES 6.8) @@ -17,6 +18,14 @@ qt_add_qml_module(appMinecraft_launcher SOURCES launcherbackend.h launcherbackend.cpp + minecraftversion.h + minecraftversion.cpp + javalocator.h + javalocator.cpp + authservice.h + authservice.cpp + gamelauncher.h + gamelauncher.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 @@ -35,7 +44,7 @@ set_target_properties(appMinecraft_launcher PROPERTIES ) target_link_libraries(appMinecraft_launcher - PRIVATE Qt6::Quick Qt6::Core Qt6::Gui + PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network ) include(GNUInstallDirs) diff --git a/Main.qml b/Main.qml index 950ede8..f51c219 100644 --- a/Main.qml +++ b/Main.qml @@ -14,14 +14,37 @@ Window { // ── Backend ──────────────────────────────────────────────────────────── LauncherBackend { id: backend + onLaunched: (profileName, versionName, serverUrl) => { - console.log("Запуск: профиль=" + profileName - + " версия=" + versionName - + " сервер=" + serverUrl) + window.showToast(qsTr("Запущено: %1 — %2").arg(profileName).arg(versionName), "#4b7a1f") } - onLaunchError: (message) => { - errorLabel.text = message - errorTimer.restart() + onLaunchProgress: (message) => window.showToast(message, "#3a5a8c", 0) + onLaunchError: (message) => window.showToast(message, "#cc3333", 8000) + onTwoFactorRequired: (profileName) => { + twoFactorDialog.profileName = profileName + twoFactorCode.text = "" + twoFactorDialog.open() + } + onGameFinished: (exitCode, crashed) => { + window.showToast(crashed || exitCode !== 0 + ? qsTr("Игра завершилась с ошибкой (код %1)").arg(exitCode) + : qsTr("Игра закрыта"), + crashed || exitCode !== 0 ? "#cc3333" : "#555555") + } + onGameOutput: (line) => console.log(line) + } + + // Единая всплывающая плашка: сообщения о ходе запуска, ошибки, статус игры. + // timeout === 0 — держим до следующего сообщения. + function showToast(text, color, timeout) { + toastLabel.text = text + toastBackground.color = color + toastTimer.stop() + if (timeout === undefined) + timeout = 4000 + if (timeout > 0) { + toastTimer.interval = timeout + toastTimer.restart() } } @@ -34,28 +57,32 @@ Window { fillMode: Image.Stretch } - // ── Error toast ──────────────────────────────────────────────────────── + // ── Status toast ─────────────────────────────────────────────────────── Rectangle { - visible: errorLabel.text !== "" + id: toastBackground + visible: toastLabel.text !== "" anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: 40 - width: errorLabel.implicitWidth + 32 - height: 40 + width: Math.min(window.width - 80, toastLabel.implicitWidth + 32) + height: toastLabel.implicitHeight + 20 radius: 8 color: "#cc3333" Text { - id: errorLabel + id: toastLabel anchors.centerIn: parent + width: parent.width - 32 color: "#ffffff" font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap } Timer { - id: errorTimer - interval: 3000 - onTriggered: errorLabel.text = "" + id: toastTimer + interval: 4000 + onTriggered: toastLabel.text = "" } } @@ -66,10 +93,12 @@ Window { height: 170 anchors.centerIn: parent hoverEnabled: false + enabled: !backend.busy && !backend.gameRunning background: Image { id: buttonImage source: "images/Play_Button/Play_Active.svg" + opacity: button.enabled ? 1.0 : 0.45 } onPressed: buttonImage.source = "images/Play_Button/Play_pressed.svg" @@ -476,6 +505,30 @@ Window { onClicked: backend.openMinecraftFolder() } + // ── Settings button ──────────────────────────────────────────────────── + Button { + id: settingsButton + width: 70 + height: 40 + anchors.left: folderButton.right + anchors.verticalCenter: folderButton.verticalCenter + anchors.leftMargin: 8 + hoverEnabled: true + + background: Image { + anchors.fill: parent + source: settingsButton.pressed ? "images/Options/Options_Pressed.svg" + : settingsButton.hovered ? "images/Options/Options_active.svg" + : "images/Options/Options_Idle.svg" + fillMode: Image.PreserveAspectFit + } + + ToolTip.visible: hovered + ToolTip.text: qsTr("Настройки запуска: Java, память, папка игры") + + onClicked: settingsDialog.load() + } + // ── Add Profile Dialog ───────────────────────────────────────────────── // Bug fix: header/footer must be Item (not Rectangle) with explicit // implicitHeight so Dialog correctly computes its own total height. @@ -551,7 +604,8 @@ Window { id: pfPassword x: 20 width: parent.width - 40 - placeholderText: "Пароль" + enabled: pfAuth.currentIndex === 1 + placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" @@ -562,6 +616,13 @@ Window { border.width: 1 } } + + DarkCombo { + id: pfAuth + x: 20 + width: parent.width - 40 + model: ["Офлайн (без пароля)", "Ely.by"] + } } footer: Item { @@ -613,13 +674,14 @@ Window { onAccepted: { const name = pfName.text.trim() if (name !== "") { - backend.addProfile(name, pfLogin.text.trim(), pfPassword.text) + backend.addProfile(name, pfLogin.text.trim(), pfPassword.text, + pfAuth.currentIndex === 1 ? "elyby" : "offline") profileBox.currentIndex = backend.profileNames.length - 1 } - pfName.text = ""; pfLogin.text = ""; pfPassword.text = "" + pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0 } onRejected: { - pfName.text = ""; pfLogin.text = ""; pfPassword.text = "" + pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0 } } @@ -680,7 +742,7 @@ Window { id: verServer x: 20 width: parent.width - 40 - placeholderText: "URL сервера загрузки" + placeholderText: "Адрес сервера, host:port (необязательно)" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -690,6 +752,25 @@ Window { border.width: 1 } } + + // Папка внутри .minecraft/versions, которую и будем запускать. + DarkCombo { + id: verInstalled + x: 20 + width: parent.width - 40 + model: backend.installedVersions + } + + Text { + x: 20 + width: parent.width - 40 + text: backend.installedVersions.length === 0 + ? qsTr("В .minecraft/versions нет установленных версий") + : qsTr("Установленная версия из .minecraft") + color: backend.installedVersions.length === 0 ? "#cc6666" : "#888888" + font.pixelSize: 11 + wrapMode: Text.Wrap + } } footer: Item { @@ -738,10 +819,12 @@ Window { } } + onAboutToShow: verInstalled.currentIndex = 0 + onAccepted: { const name = verName.text.trim() if (name !== "") { - backend.addVersion(name, verServer.text.trim()) + backend.addVersion(name, verServer.text.trim(), verInstalled.currentText) versionBox.currentIndex = backend.versionNames.length - 1 } verName.text = ""; verServer.text = "" @@ -767,9 +850,10 @@ Window { function openFor(index) { const data = backend.profileAt(index) editIndex = index - epName.text = data.name || "" - epLogin.text = data.login || "" - epPassword.text = data.password || "" + epName.text = data.name || "" + epLogin.text = data.login || "" + epPassword.text = data.password || "" + epAuth.currentIndex = data.authType === "elyby" ? 1 : 0 profileBox.popup.close() open() } @@ -837,7 +921,8 @@ Window { id: epPassword x: 20 width: parent.width - 40 - placeholderText: "Пароль" + enabled: epAuth.currentIndex === 1 + placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" @@ -848,6 +933,13 @@ Window { border.width: 1 } } + + DarkCombo { + id: epAuth + x: 20 + width: parent.width - 40 + model: ["Офлайн (без пароля)", "Ely.by"] + } } footer: Item { @@ -899,7 +991,8 @@ Window { onAccepted: { const name = epName.text.trim() if (editIndex >= 0 && name !== "") - backend.updateProfile(editIndex, name, epLogin.text.trim(), epPassword.text) + backend.updateProfile(editIndex, name, epLogin.text.trim(), epPassword.text, + epAuth.currentIndex === 1 ? "elyby" : "offline") editIndex = -1 epName.text = ""; epLogin.text = ""; epPassword.text = "" } @@ -927,6 +1020,13 @@ Window { editIndex = index evName.text = data.name || "" evServer.text = data.serverUrl || "" + evInstalled.currentIndex = backend.installedVersions.indexOf(data.versionId || "") + + const problems = backend.checkInstallation(index) + evStatus.text = problems.length === 0 + ? qsTr("Всё на месте, версию можно запускать") + : qsTr("Не хватает файлов (%1): %2").arg(problems.length).arg(problems[0]) + versionBox.popup.close() open() } @@ -979,7 +1079,7 @@ Window { id: evServer x: 20 width: parent.width - 40 - placeholderText: "URL сервера загрузки" + placeholderText: "Адрес сервера, host:port (необязательно)" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -989,6 +1089,23 @@ Window { border.width: 1 } } + + DarkCombo { + id: evInstalled + x: 20 + width: parent.width - 40 + model: backend.installedVersions + } + + // Показываем, чего не хватает в .minecraft именно для этой версии. + Text { + id: evStatus + x: 20 + width: parent.width - 40 + color: text.indexOf("Всё на месте") === 0 ? "#8fbf5a" : "#cc6666" + font.pixelSize: 11 + wrapMode: Text.Wrap + } } footer: Item { @@ -1040,7 +1157,7 @@ Window { onAccepted: { const name = evName.text.trim() if (editIndex >= 0 && name !== "") - backend.updateVersion(editIndex, name, evServer.text.trim()) + backend.updateVersion(editIndex, name, evServer.text.trim(), evInstalled.currentText) editIndex = -1 evName.text = ""; evServer.text = "" } @@ -1049,4 +1166,444 @@ Window { evName.text = ""; evServer.text = "" } } + + // ── Two-factor Dialog ────────────────────────────────────────────────── + // Ely.by отклоняет пароль с пометкой two factor — код добираем здесь и + // продолжаем прерванный запуск. + Dialog { + id: twoFactorDialog + modal: true + width: 320 + x: (window.width - width) / 2 + y: (window.height - height) / 2 + padding: 0 + closePolicy: Popup.NoAutoClose + + property string profileName: "" + + 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: 15 + font.bold: true + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#333333" + } + } + + contentItem: Column { + spacing: 12 + topPadding: 20 + bottomPadding: 20 + + Text { + x: 20 + width: parent.width - 40 + text: qsTr("Введите код из приложения-аутентификатора для «%1»") + .arg(twoFactorDialog.profileName) + color: "#aaaaaa" + font.pixelSize: 12 + wrapMode: Text.Wrap + } + + TextField { + id: twoFactorCode + x: 20 + width: parent.width - 40 + placeholderText: "000000" + inputMethodHints: Qt.ImhDigitsOnly + color: "#ffffff" + placeholderTextColor: "#666666" + background: Rectangle { + color: "#2a2a2a" + radius: 6 + border.color: twoFactorCode.activeFocus ? "#91B315" : "#444444" + border.width: 1 + } + onAccepted: twoFactorDialog.accept() + } + } + + 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: twoFactorDialog.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: twoFactorDialog.accept() + } + } + } + + onAccepted: backend.submitTwoFactorCode(twoFactorCode.text) + onRejected: backend.cancelPendingLaunch() + } + + // ── Settings Dialog ──────────────────────────────────────────────────── + Dialog { + id: settingsDialog + modal: true + width: 460 + x: (window.width - width) / 2 + y: (window.height - height) / 2 + padding: 0 + + function load() { + const s = backend.settings() + stGameDir.text = s.gameDir || "" + stJavaPath.text = s.javaPath || "" + stMinMemory.text = String(s.minMemoryMb) + stMaxMemory.text = String(s.maxMemoryMb) + stJvmArgs.text = s.jvmArgs || "" + stWidth.text = String(s.windowWidth) + stHeight.text = String(s.windowHeight) + stFullscreen.checked = s.fullscreen === true + stResolved.text = qsTr("Папка игры: %1").arg(s.resolvedGameDir) + stJavaList.text = backend.detectedJava().join("\n") || qsTr("Java не найдена") + 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: 10 + topPadding: 18 + bottomPadding: 18 + + LabelledField { + id: stGameDir + x: 20 + width: parent.width - 40 + label: "Папка .minecraft" + placeholder: "по умолчанию" + } + LabelledField { + id: stJavaPath + x: 20 + width: parent.width - 40 + label: "Путь к Java" + placeholder: "автоопределение" + } + + Row { + x: 20 + spacing: 10 + LabelledField { + id: stMinMemory + width: 100 + label: "-Xms, МБ" + validator: IntValidator { bottom: 0; top: 1048576 } + } + LabelledField { + id: stMaxMemory + width: 100 + label: "-Xmx, МБ" + validator: IntValidator { bottom: 0; top: 1048576 } + } + LabelledField { + id: stWidth + width: 100 + label: "Ширина окна" + validator: IntValidator { bottom: 0; top: 16384 } + } + LabelledField { + id: stHeight + width: 100 + label: "Высота окна" + validator: IntValidator { bottom: 0; top: 16384 } + } + } + + LabelledField { + id: stJvmArgs + x: 20 + width: parent.width - 40 + label: "Дополнительные аргументы JVM" + placeholder: "-XX:+UseG1GC …" + } + + CheckBox { + id: stFullscreen + x: 20 + text: "Запускать в полноэкранном режиме" + + // Индикатор Basic-стиля — крупный светлый квадрат, на тёмном + // фоне диалога он не читается. Рисуем свой в палитре окна. + indicator: Rectangle { + implicitWidth: 18 + implicitHeight: 18 + x: stFullscreen.leftPadding + y: (stFullscreen.height - height) / 2 + radius: 4 + color: "#2a2a2a" + border.color: stFullscreen.checked ? "#91B315" : "#444444" + border.width: 1 + + Rectangle { + anchors.centerIn: parent + width: 10; height: 10 + radius: 2 + color: "#91B315" + visible: stFullscreen.checked + } + } + + contentItem: Text { + text: stFullscreen.text + color: "#ffffff" + leftPadding: (stFullscreen.indicator ? stFullscreen.indicator.width : 0) + 6 + verticalAlignment: Text.AlignVCenter + } + } + + Text { + id: stResolved + x: 20 + width: parent.width - 40 + color: "#888888" + font.pixelSize: 11 + wrapMode: Text.Wrap + } + Text { + id: stJavaList + x: 20 + width: parent.width - 40 + color: "#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: settingsDialog.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: settingsDialog.accept() + } + } + } + + onAccepted: backend.updateSettings({ + "gameDir": stGameDir.text.trim(), + "javaPath": stJavaPath.text.trim(), + "minMemoryMb": parseInt(stMinMemory.text) || 0, + "maxMemoryMb": parseInt(stMaxMemory.text) || 0, + "jvmArgs": stJvmArgs.text.trim(), + "windowWidth": parseInt(stWidth.text) || 0, + "windowHeight": parseInt(stHeight.text) || 0, + "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 + } + } + } + + // Подпись + поле ввода одной колонкой — используется в настройках. + 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/authservice.cpp b/authservice.cpp new file mode 100644 index 0000000..4d353fd --- /dev/null +++ b/authservice.cpp @@ -0,0 +1,297 @@ +#include "authservice.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const QString kAuthServer = QStringLiteral("https://authserver.ely.by"); +const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json"); +const QString kInjectorFileName = QStringLiteral("authlib-injector.jar"); + +// Ely.by отдаёт UUID без дефисов, вручную введённый мог прийти и с ними. +QString normalizeUuid(const QString &uuid) +{ + return QString(uuid).remove(u'-'); +} + +QString describeElyError(const QJsonObject &response, int status) +{ + const QString message = response.value(QStringLiteral("errorMessage")).toString(); + if (!message.isEmpty()) + return message; + const QString error = response.value(QStringLiteral("error")).toString(); + if (!error.isEmpty()) + return error; + return QObject::tr("Сервер авторизации вернул код %1").arg(status); +} + +} // namespace + +AuthService::AuthService(QObject *parent) + : QObject(parent) + , m_network(new QNetworkAccessManager(this)) +{ +} + +AuthResult AuthService::offline(const QString &nickname) +{ + AuthResult result; + if (nickname.isEmpty()) { + result.error = tr("Для офлайн-запуска нужен никнейм"); + return result; + } + + QByteArray digest = QCryptographicHash::hash( + QStringLiteral("OfflinePlayer:%1").arg(nickname).toUtf8(), QCryptographicHash::Md5); + digest[6] = static_cast((digest[6] & 0x0f) | 0x30); // версия UUID = 3 + digest[8] = static_cast((digest[8] & 0x3f) | 0x80); // вариант RFC 4122 + + result.ok = true; + result.playerName = nickname; + result.uuid = QString::fromLatin1(digest.toHex()); + result.accessToken = QStringLiteral("0"); + result.clientToken = QStringLiteral("0"); + result.userType = QStringLiteral("legacy"); + return result; +} + +QString AuthService::generateClientToken() +{ + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +void AuthService::postJson( + const QString &endpoint, + const QJsonObject &body, + std::function handler) +{ + QNetworkRequest request{QUrl(kAuthServer + endpoint)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + QNetworkReply *reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() { + reply->deleteLater(); + + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray payload = reply->readAll(); + + // Сетевую ошибку отличаем от ответа сервера: при 401 статус есть, а error — есть всегда. + QString transportError; + if (status == 0 && reply->error() != QNetworkReply::NoError) + transportError = reply->errorString(); + + handler(status, QJsonDocument::fromJson(payload).object(), transportError); + }); +} + +void AuthService::loginElyBy(const QString &login, + const QString &password, + const QString &clientToken, + const QString &accessToken, + Callback callback) +{ + if (login.isEmpty()) { + AuthResult result; + result.error = tr("Укажите логин Ely.by в настройках профиля"); + callback(result); + return; + } + + const QString token = clientToken.isEmpty() ? generateClientToken() : clientToken; + + // Токена ещё нет — сразу за паролем. + if (accessToken.isEmpty()) { + authenticate(login, password, token, std::move(callback)); + return; + } + + emit progress(tr("Проверка сохранённого токена Ely.by…")); + postJson(QStringLiteral("/auth/validate"), + {{QStringLiteral("accessToken"), accessToken}}, + [this, login, password, token, accessToken, callback]( + int status, const QJsonObject &, const QString &transportError) { + if (!transportError.isEmpty()) { + AuthResult result; + result.error = tr("Нет связи с authserver.ely.by: %1").arg(transportError); + callback(result); + return; + } + + if (status == 200) { + AuthResult result; + result.ok = true; + result.accessToken = accessToken; + result.clientToken = token; + result.userType = QStringLiteral("ELYBY"); + callback(result); + return; + } + + // Токен протух — пробуем продлить его, не спрашивая пароль. + emit progress(tr("Обновление токена Ely.by…")); + postJson(QStringLiteral("/auth/refresh"), + {{QStringLiteral("accessToken"), accessToken}, + {QStringLiteral("clientToken"), token}, + {QStringLiteral("requestUser"), true}}, + [this, login, password, token, callback]( + int refreshStatus, const QJsonObject &response, const QString &error) { + if (error.isEmpty() && refreshStatus == 200) { + AuthResult result; + result.ok = true; + result.accessToken = response.value(QStringLiteral("accessToken")).toString(); + result.clientToken = token; + const QJsonObject profile = + response.value(QStringLiteral("selectedProfile")).toObject(); + result.playerName = profile.value(QStringLiteral("name")).toString(); + result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString()); + result.userType = QStringLiteral("ELYBY"); + callback(result); + return; + } + authenticate(login, password, token, callback); + }); + }); +} + +void AuthService::loginElyByWithTotp(const QString &login, + const QString &password, + const QString &totp, + const QString &clientToken, + Callback callback) +{ + // Ely.by ждёт двухфакторный код прямо в пароле, через двоеточие. + authenticate(login, + password + u':' + totp, + clientToken.isEmpty() ? generateClientToken() : clientToken, + std::move(callback)); +} + +void AuthService::authenticate(const QString &login, + const QString &password, + const QString &clientToken, + Callback callback) +{ + if (password.isEmpty()) { + AuthResult result; + result.error = tr("Укажите пароль Ely.by в настройках профиля"); + callback(result); + return; + } + + emit progress(tr("Авторизация на Ely.by…")); + postJson(QStringLiteral("/auth/authenticate"), + {{QStringLiteral("username"), login}, + {QStringLiteral("password"), password}, + {QStringLiteral("clientToken"), clientToken}, + {QStringLiteral("requestUser"), true}}, + [clientToken, callback](int status, const QJsonObject &response, const QString &transportError) { + AuthResult result; + result.clientToken = clientToken; + + if (!transportError.isEmpty()) { + result.error = AuthService::tr("Нет связи с authserver.ely.by: %1").arg(transportError); + callback(result); + return; + } + + if (status != 200) { + const QString message = describeElyError(response, status); + // Единственный случай, когда ошибка лечится вводом кода, а не пароля. + result.twoFactorRequired = message.contains(QStringLiteral("two factor"), + Qt::CaseInsensitive); + result.error = result.twoFactorRequired + ? AuthService::tr("Аккаунт защищён двухфакторной аутентификацией") + : message; + callback(result); + return; + } + + const QJsonObject profile = response.value(QStringLiteral("selectedProfile")).toObject(); + result.playerName = profile.value(QStringLiteral("name")).toString(); + result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString()); + result.accessToken = response.value(QStringLiteral("accessToken")).toString(); + result.userType = QStringLiteral("ELYBY"); + result.ok = !result.accessToken.isEmpty() && !result.playerName.isEmpty(); + if (!result.ok) + result.error = AuthService::tr("Ely.by не вернул игровой профиль для этого аккаунта"); + callback(result); + }); +} + +void AuthService::ensureAuthlibInjector( + const QString &targetDir, + std::function callback) +{ + QDir().mkpath(targetDir); + const QString jarPath = targetDir + u'/' + kInjectorFileName; + if (QFileInfo::exists(jarPath)) { + callback(jarPath, {}); + return; + } + + emit progress(tr("Загрузка authlib-injector…")); + QNetworkRequest manifestRequest{QUrl(kInjectorManifest)}; + manifestRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + QNetworkReply *manifestReply = m_network->get(manifestRequest); + connect(manifestReply, &QNetworkReply::finished, this, [this, manifestReply, jarPath, callback]() { + manifestReply->deleteLater(); + if (manifestReply->error() != QNetworkReply::NoError) { + callback({}, tr("Не удалось получить сведения об authlib-injector: %1") + .arg(manifestReply->errorString())); + return; + } + + const QJsonObject manifest = QJsonDocument::fromJson(manifestReply->readAll()).object(); + const QString url = manifest.value(QStringLiteral("download_url")).toString(); + const QString expectedSha256 = manifest.value(QStringLiteral("checksums")) + .toObject() + .value(QStringLiteral("sha256")) + .toString(); + if (url.isEmpty()) { + callback({}, tr("В манифесте authlib-injector нет ссылки на файл")); + return; + } + + QNetworkRequest jarRequest{QUrl(url)}; + jarRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + QNetworkReply *jarReply = m_network->get(jarRequest); + connect(jarReply, &QNetworkReply::finished, this, [jarReply, jarPath, expectedSha256, callback]() { + jarReply->deleteLater(); + if (jarReply->error() != QNetworkReply::NoError) { + callback({}, AuthService::tr("Не удалось скачать authlib-injector: %1").arg(jarReply->errorString())); + return; + } + + const QByteArray payload = jarReply->readAll(); + if (!expectedSha256.isEmpty()) { + const QString actual = QString::fromLatin1( + QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex()); + if (actual != expectedSha256) { + callback({}, AuthService::tr("Контрольная сумма authlib-injector не совпала")); + return; + } + } + + QFile file(jarPath); + if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) { + callback({}, AuthService::tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(jarPath))); + return; + } + file.close(); + callback(jarPath, {}); + }); + }); +} diff --git a/authservice.h b/authservice.h new file mode 100644 index 0000000..e957419 --- /dev/null +++ b/authservice.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include + +class QNetworkAccessManager; + +// Результат авторизации — то, что подставляется в ${auth_*} аргументы запуска. +struct AuthResult +{ + bool ok = false; + bool twoFactorRequired = false; + QString error; + + QString playerName; // ${auth_player_name} + QString uuid; // ${auth_uuid}, hex без дефисов + QString accessToken; // ${auth_access_token} + QString clientToken; // ${clientid} + QString userType; // ${user_type}: legacy / msa / ELYBY +}; + +// Yggdrasil-клиент Ely.by плюс офлайн-режим. Все сетевые методы асинхронные: +// колбэк вызывается один раз в потоке GUI. +class AuthService : public QObject +{ + Q_OBJECT + +public: + using Callback = std::function; + + explicit AuthService(QObject *parent = nullptr); + + // UUID выводится из ника так же, как это делает сам Minecraft в офлайне: + // UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(UTF_8)). + static AuthResult offline(const QString &nickname); + + // Случайный clientToken лаунчера — генерируется один раз на профиль. + static QString generateClientToken(); + + // Полный цикл: validate -> refresh -> authenticate. password можно оставить + // пустым, если уже есть рабочий accessToken. + void loginElyBy(const QString &login, + const QString &password, + const QString &clientToken, + const QString &accessToken, + Callback callback); + + // Повтор авторизации с одноразовым кодом двухфакторки (пароль:код). + void loginElyByWithTotp(const QString &login, + const QString &password, + const QString &totp, + const QString &clientToken, + Callback callback); + + // Скачивает authlib-injector в targetDir, если его там ещё нет. + // Колбэк получает путь к jar либо текст ошибки. + void ensureAuthlibInjector(const QString &targetDir, + std::function callback); + +signals: + void progress(const QString &message); + +private: + void postJson(const QString &endpoint, + const QJsonObject &body, + std::function handler); + + void authenticate(const QString &login, + const QString &password, + const QString &clientToken, + Callback callback); + + QNetworkAccessManager *m_network; +}; diff --git a/gamelauncher.cpp b/gamelauncher.cpp new file mode 100644 index 0000000..13e2615 --- /dev/null +++ b/gamelauncher.cpp @@ -0,0 +1,341 @@ +#include "gamelauncher.h" + +#include "javalocator.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +#if defined(Q_OS_WIN) +constexpr QChar kClasspathSeparator = u';'; +#else +constexpr QChar kClasspathSeparator = u':'; +#endif + +QString libraryPath(const QString &gameDir, const MinecraftLibrary &library) +{ + return gameDir + QStringLiteral("/libraries/") + library.path; +} + +QString nativeToken(const QString &path) +{ + return QDir::toNativeSeparators(path); +} + +// Подставляет ${...}. Аргументы, в которых после подстановки остались +// неизвестные плейсхолдеры, вызывающий выбрасывает. +QString substitute(QString argument, const QHash &variables) +{ + for (auto it = variables.cbegin(); it != variables.cend(); ++it) + argument.replace(QStringLiteral("${") + it.key() + u'}', it.value()); + return argument; +} + +bool isNativeLibraryFile(const QString &fileName) +{ + static const QStringList suffixes = {QStringLiteral(".dll"), + QStringLiteral(".so"), + QStringLiteral(".dylib"), + QStringLiteral(".jnilib")}; + for (const QString &suffix : suffixes) + if (fileName.endsWith(suffix, Qt::CaseInsensitive)) + return true; + return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1 +} + +} // namespace + +GameLauncher::GameLauncher(QObject *parent) + : QObject(parent) +{ +} + +bool GameLauncher::isRunning() const +{ + return m_process && m_process->state() != QProcess::NotRunning; +} + +QStringList GameLauncher::missingFiles(const LaunchOptions &options, + const MinecraftVersion &version, + int limit) +{ + QStringList missing; + const auto add = [&missing, limit](const QString &path) { + if (missing.size() < limit) + missing << QDir::toNativeSeparators(path); + }; + + if (!QDir(options.gameDir).exists()) + return {QDir::toNativeSeparators(options.gameDir)}; + + if (!QFile::exists(version.clientJarPath)) + add(version.clientJarPath); + + for (const MinecraftLibrary &library : version.libraries) { + const QString path = libraryPath(options.gameDir, library); + if (!QFile::exists(path)) + add(path); + } + + if (!version.assetIndexId.isEmpty()) { + const QString index = options.gameDir + QStringLiteral("/assets/indexes/") + + version.assetIndexId + QStringLiteral(".json"); + if (!QFile::exists(index)) + add(index); + else if (!QDir(options.gameDir + QStringLiteral("/assets/objects")).exists()) + add(options.gameDir + QStringLiteral("/assets/objects")); + } + + return missing; +} + +QStringList GameLauncher::buildArguments(const LaunchOptions &options, + const MinecraftVersion &version, + const QString &nativesDir) +{ + QStringList classpath; + for (const MinecraftLibrary &library : version.libraries) { + if (library.native) + continue; // натив-библиотеки только распаковываются + classpath << nativeToken(libraryPath(options.gameDir, library)); + } + classpath << nativeToken(version.clientJarPath); + + const QString assetsRoot = options.gameDir + QStringLiteral("/assets"); + const bool legacyAssets = version.assetsKind == QLatin1String("legacy") + || version.assetsKind == QLatin1String("pre-1.6"); + + QHash variables; + variables[QStringLiteral("auth_player_name")] = options.playerName; + variables[QStringLiteral("auth_uuid")] = options.uuid; + variables[QStringLiteral("auth_access_token")] = options.accessToken; + variables[QStringLiteral("auth_session")] = + QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid); + variables[QStringLiteral("auth_xuid")] = QStringLiteral("0"); + variables[QStringLiteral("clientid")] = + options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken; + variables[QStringLiteral("user_type")] = options.userType; + variables[QStringLiteral("user_properties")] = QStringLiteral("{}"); + variables[QStringLiteral("version_name")] = version.id; + variables[QStringLiteral("version_type")] = version.type; + variables[QStringLiteral("game_directory")] = nativeToken(options.gameDir); + variables[QStringLiteral("assets_root")] = nativeToken(assetsRoot); + variables[QStringLiteral("assets_index_name")] = version.assetIndexId; + variables[QStringLiteral("game_assets")] = + nativeToken(legacyAssets ? assetsRoot + QStringLiteral("/virtual/legacy") : assetsRoot); + variables[QStringLiteral("natives_directory")] = nativeToken(nativesDir); + variables[QStringLiteral("library_directory")] = + nativeToken(options.gameDir + QStringLiteral("/libraries")); + variables[QStringLiteral("classpath")] = classpath.join(kClasspathSeparator); + variables[QStringLiteral("classpath_separator")] = QString(kClasspathSeparator); + variables[QStringLiteral("primary_jar")] = nativeToken(version.clientJarPath); + variables[QStringLiteral("launcher_name")] = options.launcherName; + variables[QStringLiteral("launcher_version")] = options.launcherVersion; + variables[QStringLiteral("resolution_width")] = QString::number(options.windowWidth); + variables[QStringLiteral("resolution_height")] = QString::number(options.windowHeight); + + QStringList arguments; + + // Свои jvm-аргументы идут первыми — как в строке запуска из примера. + if (!options.authlibInjectorPath.isEmpty()) { + arguments << QStringLiteral("-javaagent:%1=%2") + .arg(nativeToken(options.authlibInjectorPath), options.authlibInjectorApi) + << QStringLiteral("-Dauthlibinjector.side=client"); + } + if (options.minMemoryMb > 0) + arguments << QStringLiteral("-Xms%1M").arg(options.minMemoryMb); + if (options.maxMemoryMb > 0) + arguments << QStringLiteral("-Xmx%1M").arg(options.maxMemoryMb); + arguments += options.extraJvmArgs; + + for (const QString &argument : version.jvmArgs) { + const QString resolved = substitute(argument, variables); + if (!resolved.contains(QStringLiteral("${"))) + arguments << resolved; + } + + if (!version.loggingArgument.isEmpty() && QFile::exists(version.loggingConfigPath)) { + QHash loggingVariable; + loggingVariable[QStringLiteral("path")] = nativeToken(version.loggingConfigPath); + arguments << substitute(version.loggingArgument, loggingVariable); + } + + arguments << version.mainClass; + + for (const QString &argument : version.gameArgs) { + const QString resolved = substitute(argument, variables); + if (!resolved.contains(QStringLiteral("${"))) + arguments << resolved; + } + + // Разрешение окна: у старых версий фич-флага нет, аргументы всё равно понимаются. + if (options.windowWidth > 0 && options.windowHeight > 0 + && !version.hasCustomResolutionArgs && !arguments.contains(QStringLiteral("--width"))) { + arguments << QStringLiteral("--width") << QString::number(options.windowWidth) + << QStringLiteral("--height") << QString::number(options.windowHeight); + } + if (options.fullscreen && !arguments.contains(QStringLiteral("--fullscreen"))) + arguments << QStringLiteral("--fullscreen"); + + if (!options.serverAddress.isEmpty()) { + if (version.supportsQuickPlay) { + arguments << QStringLiteral("--quickPlayMultiplayer") << options.serverAddress; + } else { + const int colon = options.serverAddress.lastIndexOf(u':'); + const QString host = colon > 0 ? options.serverAddress.left(colon) : options.serverAddress; + const QString port = colon > 0 ? options.serverAddress.mid(colon + 1) + : QStringLiteral("25565"); + arguments << QStringLiteral("--server") << host << QStringLiteral("--port") << port; + } + } + + return arguments; +} + +bool GameLauncher::extractNatives(const LaunchOptions &options, + const MinecraftVersion &version, + const QString &nativesDir, + QString *error) +{ + if (!QDir().mkpath(nativesDir)) { + if (error) + *error = tr("Не удалось создать папку %1").arg(QDir::toNativeSeparators(nativesDir)); + return false; + } + + for (const MinecraftLibrary &library : version.libraries) { + if (!library.native) + continue; + + const QString jar = libraryPath(options.gameDir, library); + QZipReader reader(jar); + if (!reader.isReadable()) { + if (error) + *error = tr("Не удалось прочитать натив-библиотеку %1") + .arg(QDir::toNativeSeparators(jar)); + return false; + } + + const QList entries = reader.fileInfoList(); + for (const QZipReader::FileInfo &entry : entries) { + if (!entry.isFile) + continue; + if (entry.filePath.startsWith(QStringLiteral("META-INF/"))) + continue; + + bool excluded = false; + for (const QString &prefix : library.extractExclude) { + if (entry.filePath.startsWith(prefix)) { + excluded = true; + break; + } + } + if (excluded) + continue; + + // java.library.path ищет библиотеки только в корне папки, поэтому + // складываем их плоско, без структуры каталогов внутри jar. + const QString fileName = QFileInfo(entry.filePath).fileName(); + if (!isNativeLibraryFile(fileName)) + continue; + + const QString target = nativesDir + u'/' + fileName; + const QByteArray data = reader.fileData(entry.filePath); + if (QFileInfo(target).size() == data.size() && QFile::exists(target)) + continue; // уже распаковано этим же лаунчером + + QFile file(target); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) { + if (error) + *error = tr("Не удалось распаковать %1 в %2") + .arg(fileName, QDir::toNativeSeparators(nativesDir)); + return false; + } + } + } + + return true; +} + +bool GameLauncher::launch(const LaunchOptions &options, + const MinecraftVersion &version, + QString *error) +{ + if (isRunning()) { + if (error) + *error = tr("Игра уже запущена"); + return false; + } + + emit progress(tr("Проверка файлов игры…")); + const QStringList missing = missingFiles(options, version); + if (!missing.isEmpty()) { + if (error) + *error = tr("В .minecraft не хватает файлов (%1):\n%2") + .arg(missing.size()) + .arg(missing.join(u'\n')); + return false; + } + + emit progress(tr("Распаковка нативных библиотек…")); + const QString nativesDir = options.gameDir + QStringLiteral("/versions/") + version.id + + QStringLiteral("/natives"); + if (!extractNatives(options, version, nativesDir, error)) + return false; + + emit progress(tr("Поиск Java %1…").arg(version.javaMajor)); + const QString java = JavaLocator::select(options.gameDir, version.javaMajor, options.javaPath, error); + if (java.isEmpty()) + return false; + + const QStringList arguments = buildArguments(options, version, nativesDir); + const QString executable = JavaLocator::windowlessVariant(java); + + m_process = new QProcess(this); + m_process->setProgram(executable); + m_process->setArguments(arguments); + m_process->setWorkingDirectory(options.gameDir); + m_process->setProcessChannelMode(QProcess::MergedChannels); + + connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() { + const QString chunk = QString::fromLocal8Bit(m_process->readAllStandardOutput()); + const QStringList lines = chunk.split(u'\n', Qt::SkipEmptyParts); + for (const QString &line : lines) + emit output(line.trimmed()); + }); + connect(m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) { + emit gameFinished(exitCode, status == QProcess::CrashExit); + m_process->deleteLater(); + m_process = nullptr; + }); + // Токен в лог не попадает — маскируем его в отображаемой строке запуска. + QStringList shown = arguments; + const int tokenIndex = shown.indexOf(QStringLiteral("--accessToken")); + if (tokenIndex >= 0 && tokenIndex + 1 < shown.size()) + shown[tokenIndex + 1] = QStringLiteral("<скрыт>"); + + m_process->start(); + if (!m_process->waitForStarted(15000)) { + if (error) + *error = tr("Не удалось запустить %1: %2") + .arg(QDir::toNativeSeparators(executable), m_process->errorString()); + m_process->deleteLater(); + m_process = nullptr; + return false; + } + + emit gameStarted(QDir::toNativeSeparators(executable) + QStringLiteral(" ") + + shown.join(QStringLiteral(" "))); + return true; +} + +void GameLauncher::terminate() +{ + if (isRunning()) + m_process->kill(); +} diff --git a/gamelauncher.h b/gamelauncher.h new file mode 100644 index 0000000..09469e1 --- /dev/null +++ b/gamelauncher.h @@ -0,0 +1,80 @@ +#pragma once + +#include "minecraftversion.h" + +#include +#include + +class QProcess; + +// Всё, что лаунчер знает к моменту нажатия PLAY. +struct LaunchOptions +{ + QString gameDir; // .minecraft + QString versionId; // Папка в versions + + QString playerName; + QString uuid; + QString accessToken; + QString userType; + QString clientToken; + + QString javaPath; // Пусто — ищем сами + int minMemoryMb = 512; + int maxMemoryMb = 4096; + QStringList extraJvmArgs; + + int windowWidth = 0; // 0 — не передавать --width/--height + int windowHeight = 0; + bool fullscreen = false; + + QString serverAddress; // host[:port] — автозаход на сервер + QString authlibInjectorPath; // Пусто — не подключаем + QString authlibInjectorApi = QStringLiteral("ely.by"); + + QString launcherName = QStringLiteral("KishkaLauncher"); + QString launcherVersion = QStringLiteral("1.0"); +}; + +// Готовит и запускает JVM. Один экземпляр — одна игра одновременно. +class GameLauncher : public QObject +{ + Q_OBJECT + +public: + explicit GameLauncher(QObject *parent = nullptr); + + bool isRunning() const; + + // Проверяет .minecraft на комплектность. Пустой список — всё на месте. + // Возвращает описания недостающих файлов, но не больше limit штук. + static QStringList missingFiles(const LaunchOptions &options, + const MinecraftVersion &version, + int limit = 12); + + // Собирает аргументы ровно в том порядке, в котором их ждёт JVM: + // <аргументы игры>. + static QStringList buildArguments(const LaunchOptions &options, + const MinecraftVersion &version, + const QString &nativesDir); + + // Распаковывает .dll/.so/.dylib из натив-библиотек в <версия>/natives. + static bool extractNatives(const LaunchOptions &options, + const MinecraftVersion &version, + const QString &nativesDir, + QString *error); + + // Полный цикл: проверка, натив-библиотеки, поиск java, старт процесса. + bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString *error); + + void terminate(); + +signals: + void progress(const QString &message); + void output(const QString &line); + void gameStarted(const QString &commandLine); + void gameFinished(int exitCode, bool crashed); + +private: + QProcess *m_process = nullptr; +}; diff --git a/javalocator.cpp b/javalocator.cpp new file mode 100644 index 0000000..2c0005d --- /dev/null +++ b/javalocator.cpp @@ -0,0 +1,203 @@ +#include "javalocator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +#if defined(Q_OS_WIN) +constexpr auto kExecutable = "java.exe"; +constexpr auto kWindowless = "javaw.exe"; +#else +constexpr auto kExecutable = "java"; +constexpr auto kWindowless = "java"; +#endif + +// Корни, в которых Windows/Linux/macOS обычно держат JDK и JRE. +QStringList installationRoots() +{ + QStringList roots; +#if defined(Q_OS_WIN) + const QStringList programFiles = {qEnvironmentVariable("ProgramFiles"), + qEnvironmentVariable("ProgramFiles(x86)"), + qEnvironmentVariable("ProgramW6432")}; + const QStringList vendors = {QStringLiteral("Java"), + QStringLiteral("Eclipse Adoptium"), + QStringLiteral("Eclipse Foundation"), + QStringLiteral("AdoptOpenJDK"), + QStringLiteral("Amazon Corretto"), + QStringLiteral("Zulu"), + QStringLiteral("BellSoft"), + QStringLiteral("Microsoft"), + QStringLiteral("Semeru"), + QStringLiteral("Android/Android Studio/jbr")}; + for (const QString &base : programFiles) { + if (base.isEmpty()) + continue; + for (const QString &vendor : vendors) + roots << base + u'/' + vendor; + } +#elif defined(Q_OS_MACOS) + roots << QStringLiteral("/Library/Java/JavaVirtualMachines") + << QDir::homePath() + QStringLiteral("/Library/Java/JavaVirtualMachines"); +#else + roots << QStringLiteral("/usr/lib/jvm") << QStringLiteral("/usr/java") + << QStringLiteral("/opt/java") << QStringLiteral("/opt/jdk"); +#endif + return roots; +} + +// Ищем именно java.exe: у javaw.exe нет вывода, а версию мы определяем запуском. +void appendIfExecutable(QStringList &target, const QString &binDir) +{ + const QFileInfo info(binDir + u'/' + QString::fromLatin1(kExecutable)); + if (!info.isFile() || !info.isExecutable()) + return; + const QString path = info.absoluteFilePath(); + if (!target.contains(path)) + target << path; +} + +} // namespace + +QStringList JavaLocator::findAll(const QString &gameDir) +{ + QStringList result; + + // 1. Рантайм, скачанный самим Minecraft: .minecraft/runtime////bin + const QString runtime = gameDir + QStringLiteral("/runtime"); + if (QDir(runtime).exists()) { + QDirIterator it(runtime, QStringList{QString::fromLatin1(kExecutable)}, + QDir::Files | QDir::Executable, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + appendIfExecutable(result, it.fileInfo().absolutePath()); + } + } + + // 2. JAVA_HOME + const QString javaHome = qEnvironmentVariable("JAVA_HOME"); + if (!javaHome.isEmpty()) + appendIfExecutable(result, javaHome + QStringLiteral("/bin")); + + // 3. PATH + const QString onPath = QStandardPaths::findExecutable(QString::fromLatin1(kExecutable)); + if (!onPath.isEmpty()) + appendIfExecutable(result, QFileInfo(onPath).absolutePath()); + + // 4. Типовые каталоги установки + const QStringList roots = installationRoots(); + for (const QString &root : roots) { + const QDir dir(root); + if (!dir.exists()) + continue; + const QStringList entries = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + for (const QString &entry : entries) { + const QString home = dir.absoluteFilePath(entry); + appendIfExecutable(result, home + QStringLiteral("/bin")); + appendIfExecutable(result, home + QStringLiteral("/Contents/Home/bin")); // macOS + appendIfExecutable(result, home + QStringLiteral("/jre/bin")); + } + } + + return result; +} + +int JavaLocator::majorVersion(const QString &javaPath) +{ + static QHash cache; + const auto cached = cache.constFind(javaPath); + if (cached != cache.constEnd()) + return cached.value(); + + int major = 0; + QProcess process; + process.start(javaPath, {QStringLiteral("-version")}); + if (process.waitForFinished(5000)) { + // java печатает версию в stderr: openjdk version "17.0.2" 2022-01-18 + const QString output = QString::fromLocal8Bit(process.readAllStandardError()) + + QString::fromLocal8Bit(process.readAllStandardOutput()); + static const QRegularExpression re(QStringLiteral("version\\s+\"(\\d+)(?:\\.(\\d+))?")); + const QRegularExpressionMatch match = re.match(output); + if (match.hasMatch()) { + const int first = match.captured(1).toInt(); + // До Java 9 версия выглядела как 1.8.0_301 — мажорная тут вторая цифра. + major = (first == 1) ? match.captured(2).toInt() : first; + } + } + + cache.insert(javaPath, major); + return major; +} + +QString JavaLocator::windowlessVariant(const QString &javaPath) +{ + const QFileInfo info(javaPath); + if (info.fileName() != QLatin1String(kExecutable)) + return javaPath; + const QFileInfo windowless(info.absolutePath() + u'/' + QString::fromLatin1(kWindowless)); + return windowless.isFile() ? windowless.absoluteFilePath() : javaPath; +} + +QString JavaLocator::select(const QString &gameDir, + int requiredMajor, + const QString &preferred, + QString *error) +{ + QStringList candidates; + if (!preferred.isEmpty()) { + QFileInfo info(preferred); + // В настройках можно указать как сам java.exe, так и корень JDK. + if (info.isDir()) + appendIfExecutable(candidates, info.absoluteFilePath() + QStringLiteral("/bin")); + else if (info.isFile()) + candidates << info.absoluteFilePath(); + + if (candidates.isEmpty()) { + if (error) + *error = QObject::tr("Java по указанному пути не найдена: %1") + .arg(QDir::toNativeSeparators(preferred)); + return {}; + } + } + candidates += findAll(gameDir); + + if (candidates.isEmpty()) { + if (error) + *error = QObject::tr("Java не найдена. Установите JDK %1 или укажите путь в настройках.") + .arg(requiredMajor); + return {}; + } + + QString bestBelow; + int bestBelowMajor = 0; + for (const QString &candidate : std::as_const(candidates)) { + const int major = majorVersion(candidate); + if (major >= requiredMajor) + return candidate; + if (major > bestBelowMajor) { + bestBelowMajor = major; + bestBelow = candidate; + } + } + + if (error) { + *error = bestBelowMajor > 0 + ? QObject::tr("Для этой версии нужна Java %1 или новее, найдена только Java %2 (%3).") + .arg(requiredMajor) + .arg(bestBelowMajor) + .arg(QDir::toNativeSeparators(bestBelow)) + : QObject::tr("Не удалось определить версию установленной Java. " + "Укажите путь к JDK %1 в настройках.") + .arg(requiredMajor); + } + return {}; +} diff --git a/javalocator.h b/javalocator.h new file mode 100644 index 0000000..f4b7ea2 --- /dev/null +++ b/javalocator.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace JavaLocator { + +// Все java, которые удалось найти: рантайм самого Minecraft, JAVA_HOME, PATH, +// стандартные каталоги установки JDK/JRE. Без дублей, в порядке приоритета. +QStringList findAll(const QString &gameDir); + +// Мажорная версия (8, 17, 21...) или 0, если запустить не удалось. Результат кешируется. +int majorVersion(const QString &javaPath); + +// java.exe -> javaw.exe, чтобы игра не открывала окно консоли. На Unix возвращает вход. +QString windowlessVariant(const QString &javaPath); + +// Выбирает java не ниже requiredMajor. preferred, если задан, проверяется первым. +// При неудаче возвращает пустую строку и заполняет error. +QString select(const QString &gameDir, + int requiredMajor, + const QString &preferred, + QString *error); + +} // namespace JavaLocator diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 7f52c0c..5614398 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -1,23 +1,60 @@ #include "launcherbackend.h" +#include "authservice.h" +#include "gamelauncher.h" +#include "javalocator.h" + #include #include #include #include #include #include +#include #include #include #include +namespace { + +const QString kOffline = QStringLiteral("offline"); +const QString kElyBy = QStringLiteral("elyby"); + +int settingInt(const QVariantMap &settings, const QString &key, int fallback) +{ + const QVariant value = settings.value(key); + bool ok = false; + const int parsed = value.toInt(&ok); + return ok ? parsed : fallback; +} + +} // namespace + LauncherBackend::LauncherBackend(QObject *parent) : QObject(parent) , m_dataDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) + , m_auth(new AuthService(this)) + , m_launcher(new GameLauncher(this)) { QDir().mkpath(m_dataDir); + loadSettings(); loadData(); + + connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress); + connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress); + connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput); + connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) { + emit gameOutput(commandLine); + emit gameRunningChanged(); + }); + connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) { + emit gameRunningChanged(); + emit gameFinished(exitCode, crashed); + }); } +LauncherBackend::~LauncherBackend() = default; + QStringList LauncherBackend::profileNames() const { QStringList names; @@ -34,37 +71,77 @@ QStringList LauncherBackend::versionNames() const return names; } -void LauncherBackend::addProfile(const QString &name, const QString &login, const QString &password) +QStringList LauncherBackend::installedVersions() const { - m_profiles.append({m_nextProfileId++, name, login, password}); + return VersionLoader::installedVersions(gameDir()); +} + +bool LauncherBackend::gameRunning() const +{ + return m_launcher->isRunning(); +} + +void LauncherBackend::addProfile(const QString &name, + const QString &login, + const QString &password, + const QString &authType) +{ + Profile profile; + profile.id = m_nextProfileId++; + profile.name = name; + profile.login = login; + profile.password = password; + profile.authType = (authType == kElyBy) ? kElyBy : kOffline; + profile.clientToken = AuthService::generateClientToken(); + m_profiles.append(profile); saveProfiles(); emit profilesChanged(); } -void LauncherBackend::addVersion(const QString &name, const QString &serverUrl) +void LauncherBackend::addVersion(const QString &name, const QString &serverUrl, const QString &versionId) { - m_versions.append({m_nextVersionId++, name, serverUrl}); + m_versions.append({m_nextVersionId++, name, serverUrl, versionId}); saveVersions(); emit versionsChanged(); } -void LauncherBackend::updateProfile(int index, const QString &name, const QString &login, const QString &password) +void LauncherBackend::updateProfile(int index, + const QString &name, + const QString &login, + const QString &password, + const QString &authType) { if (index < 0 || index >= m_profiles.size()) return; - m_profiles[index].name = name; - m_profiles[index].login = login; - m_profiles[index].password = password; + + Profile &profile = m_profiles[index]; + // Смена логина или пароля обесценивает сохранённый токен Ely.by. + if (profile.login != login || profile.password != password) { + profile.accessToken.clear(); + profile.uuid.clear(); + profile.resolvedName.clear(); + } + profile.name = name; + profile.login = login; + profile.password = password; + profile.authType = (authType == kElyBy) ? kElyBy : kOffline; + if (profile.clientToken.isEmpty()) + profile.clientToken = AuthService::generateClientToken(); + saveProfiles(); emit profilesChanged(); } -void LauncherBackend::updateVersion(int index, const QString &name, const QString &serverUrl) +void LauncherBackend::updateVersion(int index, + const QString &name, + const QString &serverUrl, + const QString &versionId) { if (index < 0 || index >= m_versions.size()) return; m_versions[index].name = name; m_versions[index].serverUrl = serverUrl; + m_versions[index].versionId = versionId; saveVersions(); emit versionsChanged(); } @@ -74,7 +151,10 @@ QVariantMap LauncherBackend::profileAt(int index) const if (index < 0 || index >= m_profiles.size()) return {}; const auto &p = m_profiles[index]; - return {{"name", p.name}, {"login", p.login}, {"password", p.password}}; + return {{"name", p.name}, + {"login", p.login}, + {"password", p.password}, + {"authType", p.authType}}; } QVariantMap LauncherBackend::versionAt(int index) const @@ -82,7 +162,7 @@ QVariantMap LauncherBackend::versionAt(int index) const if (index < 0 || index >= m_versions.size()) return {}; const auto &v = m_versions[index]; - return {{"name", v.name}, {"serverUrl", v.serverUrl}}; + return {{"name", v.name}, {"serverUrl", v.serverUrl}, {"versionId", versionIdOf(v)}}; } void LauncherBackend::removeProfile(int index) @@ -103,40 +183,333 @@ void LauncherBackend::removeVersion(int index) emit versionsChanged(); } +QString LauncherBackend::versionIdOf(const Version &version) const +{ + // Пользователь мог назвать запись как угодно — тогда версией считаем само имя. + return version.versionId.isEmpty() ? version.name : version.versionId; +} + +QStringList LauncherBackend::checkInstallation(int versionIndex) const +{ + if (versionIndex < 0 || versionIndex >= m_versions.size()) + return {tr("Версия не выбрана")}; + + const QString id = versionIdOf(m_versions[versionIndex]); + QString error; + const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error); + if (!version.isValid()) + return {error.isEmpty() ? tr("Версия «%1» не установлена").arg(id) : error}; + + LaunchOptions options; + options.gameDir = gameDir(); + options.versionId = id; + return GameLauncher::missingFiles(options, version); +} + void LauncherBackend::launchGame(int profileIndex, int versionIndex) { + if (m_busy) { + emit launchError(tr("Запуск уже выполняется")); + return; + } + if (m_launcher->isRunning()) { + emit launchError(tr("Игра уже запущена")); + return; + } if (profileIndex < 0 || profileIndex >= m_profiles.size()) { - emit launchError("Выберите профиль перед запуском"); + emit launchError(tr("Выберите профиль перед запуском")); return; } if (versionIndex < 0 || versionIndex >= m_versions.size()) { - emit launchError("Выберите версию перед запуском"); + emit launchError(tr("Выберите версию перед запуском")); return; } - const auto &profile = m_profiles[profileIndex]; - const auto &version = m_versions[versionIndex]; - emit launched(profile.name, version.name, version.serverUrl); + const QString directory = gameDir(); + if (!QDir(directory).exists()) { + emit launchError(tr("Папка игры не найдена: %1").arg(QDir::toNativeSeparators(directory))); + return; + } + + const QString id = versionIdOf(m_versions[versionIndex]); + const QStringList installed = VersionLoader::installedVersions(directory); + if (!installed.contains(id)) { + emit launchError(installed.isEmpty() + ? tr("В %1 нет ни одной установленной версии") + .arg(QDir::toNativeSeparators(directory + QStringLiteral("/versions"))) + : tr("Версия «%1» не установлена. Доступны: %2") + .arg(id, installed.join(QStringLiteral(", ")))); + return; + } + + setBusy(true); + emit launchProgress(tr("Чтение описания версии «%1»…").arg(id)); + + QSet features; + if (settingInt(m_settings, QStringLiteral("windowWidth"), 0) > 0 + && settingInt(m_settings, QStringLiteral("windowHeight"), 0) > 0) + features << QStringLiteral("has_custom_resolution"); + + QString error; + m_pendingVersionData = VersionLoader::load(directory, id, features, &error); + if (!m_pendingVersionData.isValid()) { + failLaunch(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(id) : error); + return; + } + + LaunchOptions probe; + probe.gameDir = directory; + probe.versionId = id; + const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData); + if (!missing.isEmpty()) { + failLaunch(tr("В папке игры не хватает файлов (%1):\n%2") + .arg(missing.size()) + .arg(missing.join(u'\n'))); + return; + } + + m_pendingProfile = profileIndex; + m_pendingVersion = versionIndex; + m_pendingInjectorPath.clear(); + beginAuthentication(); } -QString LauncherBackend::minecraftDir() const +void LauncherBackend::beginAuthentication() +{ + const Profile &profile = m_profiles[m_pendingProfile]; + + if (profile.authType != kElyBy) { + const QString nickname = profile.login.isEmpty() ? profile.name : profile.login; + const AuthResult result = AuthService::offline(nickname); + if (!result.ok) { + failLaunch(result.error); + return; + } + continueLaunch(result); + return; + } + + // Ely.by работает только вместе с authlib-injector — без него игра пойдёт + // в сессионный сервер Mojang и токен окажется недействительным. + m_auth->ensureAuthlibInjector(m_dataDir, [this](const QString &path, const QString &error) { + if (m_pendingProfile < 0) + return; // запуск отменили, пока качался инжектор + if (!error.isEmpty()) { + failLaunch(error); + return; + } + m_pendingInjectorPath = path; + + const Profile &pending = m_profiles[m_pendingProfile]; + m_auth->loginElyBy(pending.login, + pending.password, + pending.clientToken, + pending.accessToken, + [this](const AuthResult &result) { + if (m_pendingProfile < 0) + return; + if (result.twoFactorRequired) { + setBusy(false); + emit twoFactorRequired(m_profiles[m_pendingProfile].name); + return; + } + if (!result.ok) { + failLaunch(result.error); + return; + } + continueLaunch(result); + }); + }); +} + +void LauncherBackend::submitTwoFactorCode(const QString &code) +{ + if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) { + emit launchError(tr("Запуск уже отменён")); + return; + } + if (code.trimmed().isEmpty()) { + emit launchError(tr("Введите код двухфакторной аутентификации")); + return; + } + + setBusy(true); + const Profile &profile = m_profiles[m_pendingProfile]; + m_auth->loginElyByWithTotp(profile.login, + profile.password, + code.trimmed(), + profile.clientToken, + [this](const AuthResult &result) { + if (m_pendingProfile < 0) + return; + if (!result.ok) { + failLaunch(result.error); + return; + } + continueLaunch(result); + }); +} + +void LauncherBackend::cancelPendingLaunch() +{ + m_pendingProfile = -1; + m_pendingVersion = -1; + m_pendingInjectorPath.clear(); + setBusy(false); +} + +void LauncherBackend::continueLaunch(const AuthResult &auth) +{ + if (m_pendingProfile < 0 || m_pendingVersion < 0) { + failLaunch(tr("Запуск был отменён")); + return; + } + + Profile &profile = m_profiles[m_pendingProfile]; + const Version &version = m_versions[m_pendingVersion]; + + // Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем, + // чтобы в следующий раз обойтись без пароля. + if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) { + profile.accessToken = auth.accessToken; + profile.clientToken = auth.clientToken; + if (!auth.uuid.isEmpty()) + profile.uuid = auth.uuid; + if (!auth.playerName.isEmpty()) + profile.resolvedName = auth.playerName; + profile.userType = auth.userType; + saveProfiles(); + } + + LaunchOptions options; + options.gameDir = gameDir(); + options.versionId = m_pendingVersionData.id; + options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName; + if (options.playerName.isEmpty()) + options.playerName = profile.login.isEmpty() ? profile.name : profile.login; + options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid; + options.accessToken = auth.accessToken; + options.clientToken = auth.clientToken; + options.userType = auth.userType; + + options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString(); + options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512); + options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096); + 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.launcherName = m_settings.value(QStringLiteral("launcherName")).toString(); + options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString(); + + const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed(); + if (!extra.isEmpty()) + options.extraJvmArgs = extra.split(QRegularExpression(QStringLiteral("\\s+")), + Qt::SkipEmptyParts); + + if (profile.authType == kElyBy) + options.authlibInjectorPath = m_pendingInjectorPath; + + QString error; + 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; + cancelPendingLaunch(); + + if (!started) { + emit launchError(error); + return; + } + emit gameRunningChanged(); + emit launched(profileName, versionName, serverUrl); +} + +void LauncherBackend::stopGame() +{ + m_launcher->terminate(); +} + +void LauncherBackend::failLaunch(const QString &message) +{ + cancelPendingLaunch(); + emit launchError(message); +} + +void LauncherBackend::setBusy(bool busy) +{ + if (m_busy == busy) + return; + m_busy = busy; + emit busyChanged(); +} + +QString LauncherBackend::defaultMinecraftDir() const { #if defined(Q_OS_WIN) - return QDir::homePath() + "/AppData/Roaming/.minecraft"; + return QDir::homePath() + QStringLiteral("/AppData/Roaming/.minecraft"); #elif defined(Q_OS_MACOS) - return QDir::homePath() + "/Library/Application Support/minecraft"; + return QDir::homePath() + QStringLiteral("/Library/Application Support/minecraft"); #else // Linux и прочие Unix-системы - return QDir::homePath() + "/.minecraft"; + return QDir::homePath() + QStringLiteral("/.minecraft"); #endif } +QString LauncherBackend::gameDir() const +{ + const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed(); + return configured.isEmpty() ? defaultMinecraftDir() : QDir::fromNativeSeparators(configured); +} + +QVariantMap LauncherBackend::settings() const +{ + QVariantMap result = m_settings; + result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir()); + return result; +} + +void LauncherBackend::updateSettings(const QVariantMap &values) +{ + const QString previousGameDir = gameDir(); + for (auto it = values.cbegin(); it != values.cend(); ++it) + m_settings.insert(it.key(), it.value()); + saveSettings(); + emit settingsChanged(); + if (gameDir() != previousGameDir) + emit installedVersionsChanged(); +} + +QStringList LauncherBackend::detectedJava() const +{ + QStringList result; + const QStringList candidates = JavaLocator::findAll(gameDir()); + for (const QString &candidate : candidates) { + const int major = JavaLocator::majorVersion(candidate); + result << (major > 0 ? QStringLiteral("Java %1 — %2") + .arg(major) + .arg(QDir::toNativeSeparators(candidate)) + : QDir::toNativeSeparators(candidate)); + } + return result; +} + void LauncherBackend::openMinecraftFolder() { - const QString modsDir = minecraftDir() + "/mods"; + const QString modsDir = gameDir() + QStringLiteral("/mods"); QDir().mkpath(modsDir); if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir))) - emit launchError("Не удалось открыть папку с модами"); + emit launchError(tr("Не удалось открыть папку с модами")); +} + +void LauncherBackend::openGameFolder() +{ + const QString directory = gameDir(); + QDir().mkpath(directory); + + if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory))) + emit launchError(tr("Не удалось открыть папку игры")); } void LauncherBackend::loadData() @@ -150,17 +523,30 @@ void LauncherBackend::loadData() }; loadFile(m_dataDir + "/profiles.json", [this](const QJsonObject &o) { - int id = o["id"].toInt(); - m_profiles.append({id, o["name"].toString(), o["login"].toString(), o["password"].toString()}); - if (id >= m_nextProfileId) - m_nextProfileId = id + 1; + Profile profile; + profile.id = o["id"].toInt(); + profile.name = o["name"].toString(); + profile.login = o["login"].toString(); + profile.password = o["password"].toString(); + profile.authType = o.value("authType").toString(kOffline); + profile.clientToken = o.value("clientToken").toString(); + profile.accessToken = o.value("accessToken").toString(); + profile.uuid = o.value("uuid").toString(); + profile.resolvedName = o.value("resolvedName").toString(); + profile.userType = o.value("userType").toString(); + if (profile.clientToken.isEmpty()) + profile.clientToken = AuthService::generateClientToken(); + m_profiles.append(profile); + if (profile.id >= m_nextProfileId) + m_nextProfileId = profile.id + 1; }); std::sort(m_profiles.begin(), m_profiles.end(), [](const Profile &a, const Profile &b) { return a.id < b.id; }); loadFile(m_dataDir + "/versions.json", [this](const QJsonObject &o) { - int id = o["id"].toInt(); - m_versions.append({id, o["name"].toString(), o["serverUrl"].toString()}); + const int id = o["id"].toInt(); + m_versions.append({id, o["name"].toString(), o["serverUrl"].toString(), + o.value("versionId").toString()}); if (id >= m_nextVersionId) m_nextVersionId = id + 1; }); @@ -172,7 +558,16 @@ void LauncherBackend::saveProfiles() { QJsonArray arr; for (const auto &p : m_profiles) - arr.append(QJsonObject{{"id", p.id}, {"name", p.name}, {"login", p.login}, {"password", p.password}}); + arr.append(QJsonObject{{"id", p.id}, + {"name", p.name}, + {"login", p.login}, + {"password", p.password}, + {"authType", p.authType}, + {"clientToken", p.clientToken}, + {"accessToken", p.accessToken}, + {"uuid", p.uuid}, + {"resolvedName", p.resolvedName}, + {"userType", p.userType}}); QFile f(m_dataDir + "/profiles.json"); if (f.open(QIODevice::WriteOnly)) @@ -183,9 +578,41 @@ void LauncherBackend::saveVersions() { QJsonArray arr; for (const auto &v : m_versions) - arr.append(QJsonObject{{"id", v.id}, {"name", v.name}, {"serverUrl", v.serverUrl}}); + arr.append(QJsonObject{{"id", v.id}, + {"name", v.name}, + {"serverUrl", v.serverUrl}, + {"versionId", v.versionId}}); QFile f(m_dataDir + "/versions.json"); if (f.open(QIODevice::WriteOnly)) f.write(QJsonDocument(arr).toJson()); } + +void LauncherBackend::loadSettings() +{ + m_settings = {{QStringLiteral("gameDir"), QString()}, + {QStringLiteral("javaPath"), QString()}, + {QStringLiteral("minMemoryMb"), 512}, + {QStringLiteral("maxMemoryMb"), 4096}, + {QStringLiteral("jvmArgs"), QString()}, + {QStringLiteral("windowWidth"), 0}, + {QStringLiteral("windowHeight"), 0}, + {QStringLiteral("fullscreen"), false}, + {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, + {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}}; + + QFile f(m_dataDir + "/settings.json"); + if (!f.open(QIODevice::ReadOnly)) + return; + + const QJsonObject stored = QJsonDocument::fromJson(f.readAll()).object(); + for (auto it = stored.constBegin(); it != stored.constEnd(); ++it) + m_settings.insert(it.key(), it.value().toVariant()); +} + +void LauncherBackend::saveSettings() +{ + QFile f(m_dataDir + "/settings.json"); + if (f.open(QIODevice::WriteOnly)) + f.write(QJsonDocument(QJsonObject::fromVariantMap(m_settings)).toJson()); +} diff --git a/launcherbackend.h b/launcherbackend.h index b7a3963..5d4d5ac 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -1,9 +1,15 @@ #pragma once +#include "minecraftversion.h" + #include +#include #include #include -#include + +struct AuthResult; +class AuthService; +class GameLauncher; class LauncherBackend : public QObject { @@ -12,42 +18,117 @@ class LauncherBackend : public QObject Q_PROPERTY(QStringList profileNames READ profileNames NOTIFY profilesChanged) Q_PROPERTY(QStringList versionNames READ versionNames NOTIFY versionsChanged) + Q_PROPERTY(QStringList installedVersions READ installedVersions NOTIFY installedVersionsChanged) + Q_PROPERTY(bool gameRunning READ gameRunning NOTIFY gameRunningChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) public: explicit LauncherBackend(QObject *parent = nullptr); + ~LauncherBackend() override; QStringList profileNames() const; QStringList versionNames() const; + QStringList installedVersions() const; + bool gameRunning() const; + bool busy() const { return m_busy; } - Q_INVOKABLE void addProfile(const QString &name, const QString &login, const QString &password); - Q_INVOKABLE void addVersion(const QString &name, const QString &serverUrl); - Q_INVOKABLE void updateProfile(int index, const QString &name, const QString &login, const QString &password); - Q_INVOKABLE void updateVersion(int index, const QString &name, const QString &serverUrl); + Q_INVOKABLE void addProfile(const QString &name, + 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 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()); Q_INVOKABLE QVariantMap profileAt(int index) const; Q_INVOKABLE QVariantMap versionAt(int index) const; Q_INVOKABLE void removeProfile(int index); Q_INVOKABLE void removeVersion(int index); + + // Главная кнопка. Проверяет выбор профиля и версии, комплектность .minecraft, + // при необходимости авторизуется и стартует игру. Q_INVOKABLE void launchGame(int profileIndex, int versionIndex); + Q_INVOKABLE void submitTwoFactorCode(const QString &code); + Q_INVOKABLE void cancelPendingLaunch(); + Q_INVOKABLE void stopGame(); + + // Проверка без запуска — для подсказки в интерфейсе. + Q_INVOKABLE QStringList checkInstallation(int versionIndex) const; + + Q_INVOKABLE QVariantMap settings() const; + Q_INVOKABLE void updateSettings(const QVariantMap &values); + Q_INVOKABLE QStringList detectedJava() const; + Q_INVOKABLE void openMinecraftFolder(); + Q_INVOKABLE void openGameFolder(); signals: void profilesChanged(); void versionsChanged(); + void installedVersionsChanged(); + void settingsChanged(); + void gameRunningChanged(); + void busyChanged(); + + void launchProgress(const QString &message); void launched(const QString &profileName, const QString &versionName, const QString &serverUrl); void launchError(const QString &message); + void twoFactorRequired(const QString &profileName); + void gameOutput(const QString &line); + void gameFinished(int exitCode, bool crashed); private: - struct Profile { int id; QString name, login, password; }; - struct Version { int id; QString name, serverUrl; }; + struct Profile + { + int id = 0; + QString name, login, password; + QString authType = QStringLiteral("offline"); // offline | elyby + QString clientToken, accessToken, uuid, resolvedName, userType; + }; + struct Version + { + int id = 0; + QString name, serverUrl, versionId; + }; void loadData(); + void loadSettings(); void saveProfiles(); void saveVersions(); - QString minecraftDir() const; + void saveSettings(); + + QString defaultMinecraftDir() const; + QString gameDir() const; + QString versionIdOf(const Version &version) const; + + void setBusy(bool busy); + void failLaunch(const QString &message); + void beginAuthentication(); + void continueLaunch(const AuthResult &auth); QList m_profiles; QList m_versions; + QVariantMap m_settings; QString m_dataDir; int m_nextProfileId = 1; int m_nextVersionId = 1; + + AuthService *m_auth; + GameLauncher *m_launcher; + + bool m_busy = false; + + // Состояние запуска, начатого до асинхронной авторизации. + int m_pendingProfile = -1; + int m_pendingVersion = -1; + MinecraftVersion m_pendingVersionData; + QString m_pendingInjectorPath; }; diff --git a/main.cpp b/main.cpp index 290b7cb..1575341 100644 --- a/main.cpp +++ b/main.cpp @@ -1,10 +1,15 @@ #include #include +#include int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); + // Нативный стиль Windows игнорирует пользовательские contentItem/background + // и сыплет предупреждениями, а всё окно стилизовано вручную — берём Basic. + QQuickStyle::setStyle("Basic"); + QQmlApplicationEngine engine; QObject::connect( &engine, diff --git a/minecraftversion.cpp b/minecraftversion.cpp new file mode 100644 index 0000000..efc76a3 --- /dev/null +++ b/minecraftversion.cpp @@ -0,0 +1,466 @@ +#include "minecraftversion.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Больше десяти уровней inheritsFrom не бывает даже у Forge поверх Fabric-подобных сборок. +constexpr int kMaxInheritanceDepth = 10; + +QString versionJsonPath(const QString &gameDir, const QString &id) +{ + return gameDir + "/versions/" + id + "/" + id + ".json"; +} + +QString versionJarPath(const QString &gameDir, const QString &id) +{ + return gameDir + "/versions/" + id + "/" + id + ".jar"; +} + +// org.lwjgl:lwjgl:3.3.1:natives-windows -> org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar +QString mavenToPath(const QString &name) +{ + QString coords = name; + QString extension = QStringLiteral("jar"); + + const int at = coords.indexOf(u'@'); + if (at >= 0) { + extension = coords.mid(at + 1); + coords.truncate(at); + } + + const QStringList parts = coords.split(u':'); + if (parts.size() < 3) + return {}; + + QString file = parts[1] + u'-' + parts[2]; + if (parts.size() > 3) + file += u'-' + parts[3]; + + return QString(parts[0]).replace(u'.', u'/') + u'/' + parts[1] + u'/' + parts[2] + + u'/' + file + u'.' + extension; +} + +// Ключ для дедупликации: у наследника и родителя одна и та же библиотека часто +// разных версий, и побеждать должна версия наследника. +QString libraryKey(const QString &name) +{ + const QStringList parts = name.split(u':'); + if (parts.size() < 2) + return name; + QString key = parts[0] + u':' + parts[1]; + if (parts.size() > 3) + key += u':' + parts[3]; + return key; +} + +QString mavenClassifier(const QString &name) +{ + const QStringList parts = name.split(u':'); + if (parts.size() < 4) + return {}; + QString classifier = parts[3]; + const int at = classifier.indexOf(u'@'); + if (at >= 0) + classifier.truncate(at); + return classifier; +} + +bool ruleMatches(const QJsonObject &rule, const QSet &features) +{ + if (rule.contains(QStringLiteral("features"))) { + const QJsonObject wanted = rule.value(QStringLiteral("features")).toObject(); + for (auto it = wanted.constBegin(); it != wanted.constEnd(); ++it) { + if (it.value().toBool() != features.contains(it.key())) + return false; + } + } + + const QJsonObject os = rule.value(QStringLiteral("os")).toObject(); + if (!os.isEmpty()) { + if (os.contains(QStringLiteral("name")) + && os.value(QStringLiteral("name")).toString() != VersionLoader::osName()) + return false; + if (os.contains(QStringLiteral("arch")) + && os.value(QStringLiteral("arch")).toString() != VersionLoader::osArch()) + return false; + if (os.contains(QStringLiteral("version"))) { + const QRegularExpression re(os.value(QStringLiteral("version")).toString()); + if (!re.isValid() || !re.match(QSysInfo::productVersion()).hasMatch()) + return false; + } + } + + return true; +} + +void appendArguments(const QJsonArray &source, QStringList &target, const QSet &features) +{ + for (const QJsonValue &entry : source) { + if (entry.isString()) { + target << entry.toString(); + continue; + } + + const QJsonObject object = entry.toObject(); + if (!VersionLoader::rulesAllow(object.value(QStringLiteral("rules")).toArray(), features)) + continue; + + const QJsonValue value = object.value(QStringLiteral("value")); + if (value.isString()) + target << value.toString(); + else + for (const QJsonValue &item : value.toArray()) + target << item.toString(); + } +} + +MinecraftLibrary libraryFromDownload(const QString &name, + const QJsonObject &download, + const QString &fallbackPath) +{ + MinecraftLibrary library; + library.name = name; + library.path = download.value(QStringLiteral("path")).toString(); + if (library.path.isEmpty()) + library.path = fallbackPath; + library.url = download.value(QStringLiteral("url")).toString(); + library.sha1 = download.value(QStringLiteral("sha1")).toString(); + library.size = static_cast(download.value(QStringLiteral("size")).toDouble()); + return library; +} + +void parseLibraries(const QJsonObject &root, + QList &target, + const QSet &features) +{ + for (const QJsonValue &entry : root.value(QStringLiteral("libraries")).toArray()) { + const QJsonObject object = entry.toObject(); + if (!VersionLoader::rulesAllow(object.value(QStringLiteral("rules")).toArray(), features)) + continue; + + const QString name = object.value(QStringLiteral("name")).toString(); + if (name.isEmpty()) + continue; + + const QString relativePath = mavenToPath(name); + const QJsonObject downloads = object.value(QStringLiteral("downloads")).toObject(); + const QJsonObject extract = object.value(QStringLiteral("extract")).toObject(); + + QStringList exclude; + for (const QJsonValue &value : extract.value(QStringLiteral("exclude")).toArray()) + exclude << value.toString(); + + // Основной артефакт. В новом формате (1.19+) натив-библиотеки — это + // отдельные записи с классификатором natives-*, у них тоже есть artifact. + if (downloads.contains(QStringLiteral("artifact")) || !downloads.contains(QStringLiteral("classifiers"))) { + MinecraftLibrary library = libraryFromDownload( + name, downloads.value(QStringLiteral("artifact")).toObject(), relativePath); + + // У Fabric у части библиотек нет блока downloads вообще — только + // name и адрес Maven-репозитория, путь собираем из координат. + if (library.url.isEmpty()) { + const QString repository = object.value(QStringLiteral("url")).toString(); + if (!repository.isEmpty()) + library.url = repository.endsWith(u'/') ? repository + library.path + : repository + u'/' + library.path; + } + + if (!library.path.isEmpty()) { + library.native = mavenClassifier(name).startsWith(QStringLiteral("natives-")); + library.extractExclude = exclude; + target << library; + } + } + + // Старый формат (до 1.18): отдельная карта natives + downloads.classifiers. + const QJsonObject natives = object.value(QStringLiteral("natives")).toObject(); + if (natives.isEmpty()) + continue; + + QString classifier = natives.value(VersionLoader::osName()).toString(); + if (classifier.isEmpty()) + continue; + classifier.replace(QStringLiteral("${arch}"), + VersionLoader::osArch() == QStringLiteral("x86") ? QStringLiteral("32") + : QStringLiteral("64")); + + const QJsonObject classifiers = downloads.value(QStringLiteral("classifiers")).toObject(); + MinecraftLibrary library = libraryFromDownload( + name + u':' + classifier, + classifiers.value(classifier).toObject(), + mavenToPath(name + u':' + classifier)); + if (library.path.isEmpty()) + continue; + + library.native = true; + library.extractExclude = exclude; + target << library; + } +} + +// Для 1.19+ в списке лежат natives-windows, natives-windows-x86 и natives-windows-arm64 +// одновременно — правила фильтруют только по ОС. Оставляем по одной на артефакт. +void pickNativesForCurrentArch(QList &libraries) +{ + const QString exact = VersionLoader::nativeClassifier(); + const QString osPart = VersionLoader::osName() == QStringLiteral("osx") + ? QStringLiteral("macos") + : VersionLoader::osName(); + const QString base = QStringLiteral("natives-") + osPart; + + QHash chosen; // group:artifact -> классификатор + for (const MinecraftLibrary &library : libraries) { + if (!library.native) + continue; + const QStringList parts = library.name.split(u':'); + if (parts.size() < 4) + continue; + const QString artifact = parts[0] + u':' + parts[1]; + const QString classifier = mavenClassifier(library.name); + const QString current = chosen.value(artifact); + if (current == exact) + continue; + if (classifier == exact || current.isEmpty() || classifier == base) + chosen.insert(artifact, classifier); + } + + libraries.removeIf([&](const MinecraftLibrary &library) { + if (!library.native) + return false; + const QStringList parts = library.name.split(u':'); + if (parts.size() < 4) + return false; + return chosen.value(parts[0] + u':' + parts[1]) != mavenClassifier(library.name); + }); +} + +} // namespace + +QString VersionLoader::osName() +{ +#if defined(Q_OS_WIN) + return QStringLiteral("windows"); +#elif defined(Q_OS_MACOS) + return QStringLiteral("osx"); +#else + return QStringLiteral("linux"); +#endif +} + +QString VersionLoader::osArch() +{ + const QString arch = QSysInfo::currentCpuArchitecture(); + if (arch == QLatin1String("i386") || arch == QLatin1String("i686")) + return QStringLiteral("x86"); + return arch; // x86_64 / arm64 — совпадают с тем, что пишет Mojang +} + +QString VersionLoader::nativeClassifier() +{ + const QString arch = osArch(); + const QString os = osName(); + + if (os == QLatin1String("windows")) { + if (arch == QLatin1String("arm64")) + return QStringLiteral("natives-windows-arm64"); + if (arch == QLatin1String("x86")) + return QStringLiteral("natives-windows-x86"); + return QStringLiteral("natives-windows"); + } + if (os == QLatin1String("osx")) + return arch == QLatin1String("arm64") ? QStringLiteral("natives-macos-arm64") + : QStringLiteral("natives-macos"); + return arch == QLatin1String("arm64") ? QStringLiteral("natives-linux-arm64") + : QStringLiteral("natives-linux"); +} + +bool VersionLoader::rulesAllow(const QJsonArray &rules, const QSet &features) +{ + if (rules.isEmpty()) + return true; + + bool allowed = false; + for (const QJsonValue &entry : rules) { + const QJsonObject rule = entry.toObject(); + if (ruleMatches(rule, features)) + allowed = rule.value(QStringLiteral("action")).toString() != QLatin1String("disallow"); + } + return allowed; +} + +QStringList VersionLoader::installedVersions(const QString &gameDir) +{ + QStringList result; + const QDir versions(gameDir + QStringLiteral("/versions")); + const QStringList entries = versions.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + for (const QString &id : entries) { + if (QFile::exists(versionJsonPath(gameDir, id))) + result << id; + } + return result; +} + +MinecraftVersion VersionLoader::load(const QString &gameDir, + const QString &versionId, + const QSet &features, + QString *error) +{ + MinecraftVersion version; + + // Цепочка от самой производной версии к базовой: Fabric -> 1.20.1. + QList chain; + QStringList chainIds; + QString currentId = versionId; + + while (!currentId.isEmpty()) { + if (chainIds.contains(currentId)) { + if (error) + *error = QObject::tr("Циклическая зависимость inheritsFrom в версии «%1»").arg(currentId); + return version; + } + if (chain.size() >= kMaxInheritanceDepth) { + if (error) + *error = QObject::tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(versionId); + return version; + } + + const QString path = versionJsonPath(gameDir, currentId); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (error) + *error = QObject::tr("Не найден файл версии: %1").arg(QDir::toNativeSeparators(path)); + return version; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + if (error) + *error = QObject::tr("Повреждён файл версии %1: %2") + .arg(QDir::toNativeSeparators(path), parseError.errorString()); + return version; + } + + const QJsonObject object = document.object(); + chain << object; + chainIds << currentId; + currentId = object.value(QStringLiteral("inheritsFrom")).toString(); + } + + version.id = versionId; + + // Скаляры: идём от базовой версии к производной, наследник перекрывает родителя. + for (int i = chain.size() - 1; i >= 0; --i) { + const QJsonObject &object = chain[i]; + + if (object.contains(QStringLiteral("mainClass"))) + version.mainClass = object.value(QStringLiteral("mainClass")).toString(); + if (object.contains(QStringLiteral("type"))) + version.type = object.value(QStringLiteral("type")).toString(); + if (object.contains(QStringLiteral("assets"))) + version.assetsKind = object.value(QStringLiteral("assets")).toString(); + if (object.contains(QStringLiteral("assetIndex"))) + version.assetIndexId = object.value(QStringLiteral("assetIndex")) + .toObject() + .value(QStringLiteral("id")) + .toString(); + if (object.contains(QStringLiteral("javaVersion"))) + version.javaMajor = object.value(QStringLiteral("javaVersion")) + .toObject() + .value(QStringLiteral("majorVersion")) + .toInt(version.javaMajor); + + const QJsonObject logging = object.value(QStringLiteral("logging")) + .toObject() + .value(QStringLiteral("client")) + .toObject(); + if (!logging.isEmpty()) { + version.loggingArgument = logging.value(QStringLiteral("argument")).toString(); + const QString fileId = logging.value(QStringLiteral("file")) + .toObject() + .value(QStringLiteral("id")) + .toString(); + if (!fileId.isEmpty()) + version.loggingConfigPath = gameDir + QStringLiteral("/assets/log_configs/") + fileId; + } + + // minecraftArguments (до 1.13) заменяет список целиком, arguments.game — дополняет. + if (object.contains(QStringLiteral("minecraftArguments"))) { + version.gameArgs = object.value(QStringLiteral("minecraftArguments")) + .toString() + .split(QRegularExpression(QStringLiteral("\\s+")), + Qt::SkipEmptyParts); + } + + const QJsonObject arguments = object.value(QStringLiteral("arguments")).toObject(); + appendArguments(arguments.value(QStringLiteral("jvm")).toArray(), version.jvmArgs, features); + appendArguments(arguments.value(QStringLiteral("game")).toArray(), version.gameArgs, features); + + const QByteArray raw = QJsonDocument(arguments.value(QStringLiteral("game")).toArray()).toJson(); + if (raw.contains("quickPlayMultiplayer")) + version.supportsQuickPlay = true; + if (raw.contains("resolution_width")) + version.hasCustomResolutionArgs = true; + } + + if (version.assetIndexId.isEmpty()) + version.assetIndexId = version.assetsKind; + if (version.assetsKind.isEmpty()) + version.assetsKind = version.assetIndexId; + if (version.type.isEmpty()) + version.type = QStringLiteral("release"); + + // Библиотеки: наследник первым, дубли по group:artifact:classifier отбрасываем. + QList collected; + for (const QJsonObject &object : chain) + parseLibraries(object, collected, features); + + QSet seen; + for (const MinecraftLibrary &library : std::as_const(collected)) { + const QString key = libraryKey(library.name); + if (seen.contains(key)) + continue; + seen.insert(key); + version.libraries << library; + } + pickNativesForCurrentArch(version.libraries); + + // До 1.13 jvm-аргументов в json нет — собираем их сами. + const bool hasClasspathArg = std::any_of( + version.jvmArgs.cbegin(), version.jvmArgs.cend(), [](const QString &argument) { + return argument == QLatin1String("-cp") || argument == QLatin1String("-classpath"); + }); + if (!hasClasspathArg) { + version.jvmArgs << QStringLiteral("-Djava.library.path=${natives_directory}") + << QStringLiteral("-Dminecraft.launcher.brand=${launcher_name}") + << QStringLiteral("-Dminecraft.launcher.version=${launcher_version}") + << QStringLiteral("-cp") << QStringLiteral("${classpath}"); + } + + // Клиентский jar: у Fabric-версии он свой, у Forge обычно только у родителя. + for (const QString &id : std::as_const(chainIds)) { + const QString jar = versionJarPath(gameDir, id); + if (QFile::exists(jar)) { + version.clientJarPath = jar; + break; + } + } + if (version.clientJarPath.isEmpty()) + version.clientJarPath = versionJarPath(gameDir, chainIds.constLast()); + + if (version.mainClass.isEmpty() && error) + *error = QObject::tr("В файле версии «%1» не указан mainClass").arg(versionId); + + return version; +} diff --git a/minecraftversion.h b/minecraftversion.h new file mode 100644 index 0000000..5d0e27f --- /dev/null +++ b/minecraftversion.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Одна библиотека из client.json уже с разрешёнными правилами. +struct MinecraftLibrary +{ + QString name; // Maven-координаты: org.lwjgl:lwjgl:3.3.1:natives-windows + QString path; // Путь относительно .minecraft/libraries + QString url; // Откуда качать, если файла нет (может быть пустым) + QString sha1; + qint64 size = 0; + bool native = false; // Распаковывается в natives, а не кладётся в classpath + QStringList extractExclude; // Префиксы, которые не распаковываем +}; + +// client.json, «схлопнутый» по всей цепочке inheritsFrom. +struct MinecraftVersion +{ + QString id; // Имя папки в versions, оно же --version + QString mainClass; + QString type; // release / snapshot / modified — идёт в --versionType + QString assetIndexId; // --assetIndex + QString assetsKind; // Поле "assets": legacy, pre-1.6 либо тот же id + QString clientJarPath; // Абсолютный путь к .jar (может лежать у родителя) + int javaMajor = 8; + + QStringList jvmArgs; // Ещё с неподставленными ${...} + QStringList gameArgs; + QList libraries; + + QString loggingArgument; // -Dlog4j.configurationFile=${path} + QString loggingConfigPath; // Абсолютный путь к xml, пустой если конфига нет + + bool supportsQuickPlay = false; // 1.20+ понимает --quickPlayMultiplayer + bool hasCustomResolutionArgs = false; + + bool isValid() const { return !id.isEmpty() && !mainClass.isEmpty(); } +}; + +namespace VersionLoader { + +// Версии, реально установленные в /versions (есть папка и .json). +QStringList installedVersions(const QString &gameDir); + +// Читает и разворачивает версию. При ошибке возвращает невалидный объект и пишет error. +MinecraftVersion load(const QString &gameDir, + const QString &versionId, + const QSet &features, + QString *error); + +// Стандартный алгоритм Mojang: правила применяются по порядку, побеждает последнее совпавшее. +bool rulesAllow(const QJsonArray &rules, const QSet &features = {}); + +// natives-windows / natives-macos-arm64 / natives-linux ... для текущей машины. +QString nativeClassifier(); + +QString osName(); +QString osArch(); + +} // namespace VersionLoader