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