adding startup progress and style fix

This commit is contained in:
2026-08-22 18:19:13 +03:00
parent 56e56a370f
commit 2c4b594c90
13 changed files with 2699 additions and 67 deletions
+11 -2
View File
@@ -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)
+584 -27
View File
@@ -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
}
}
}
}
+297
View File
@@ -0,0 +1,297 @@
#include "authservice.h"
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUuid>
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<char>((digest[6] & 0x0f) | 0x30); // версия UUID = 3
digest[8] = static_cast<char>((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<void(int, const QJsonObject &, const QString &)> 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<void(const QString &, const QString &)> 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, {});
});
});
}
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <QJsonObject>
#include <QObject>
#include <QString>
#include <functional>
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<void(const AuthResult &)>;
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<void(const QString &path, const QString &error)> callback);
signals:
void progress(const QString &message);
private:
void postJson(const QString &endpoint,
const QJsonObject &body,
std::function<void(int status, const QJsonObject &response, const QString &transportError)> handler);
void authenticate(const QString &login,
const QString &password,
const QString &clientToken,
Callback callback);
QNetworkAccessManager *m_network;
};
+341
View File
@@ -0,0 +1,341 @@
#include "gamelauncher.h"
#include "javalocator.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QtCore/private/qzipreader_p.h>
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<QString, QString> &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<QString, QString> 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<QString, QString> 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<QZipReader::FileInfo> 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();
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include "minecraftversion.h"
#include <QObject>
#include <QStringList>
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:
// <jvm-аргументы> <mainClass> <аргументы игры>.
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;
};
+203
View File
@@ -0,0 +1,203 @@
#include "javalocator.h"
#include <QDir>
#include <QDirIterator>
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QProcessEnvironment>
#include <QObject>
#include <QRegularExpression>
#include <QStandardPaths>
#include <utility>
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/<component>/<os>/<component>/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<QString, int> 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 {};
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <QString>
#include <QStringList>
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
+457 -30
View File
@@ -1,23 +1,60 @@
#include "launcherbackend.h"
#include "authservice.h"
#include "gamelauncher.h"
#include "javalocator.h"
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QUrl>
#include <algorithm>
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<QString> 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());
}
+89 -8
View File
@@ -1,9 +1,15 @@
#pragma once
#include "minecraftversion.h"
#include <QObject>
#include <QQmlEngine>
#include <QStringList>
#include <QVariantMap>
#include <QQmlEngine>
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<Profile> m_profiles;
QList<Version> 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;
};
+5
View File
@@ -1,10 +1,15 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
// Нативный стиль Windows игнорирует пользовательские contentItem/background
// и сыплет предупреждениями, а всё окно стилизовано вручную — берём Basic.
QQuickStyle::setStyle("Basic");
QQmlApplicationEngine engine;
QObject::connect(
&engine,
+466
View File
@@ -0,0 +1,466 @@
#include "minecraftversion.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QObject>
#include <QRegularExpression>
#include <QSysInfo>
#include <algorithm>
#include <utility>
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<QString> &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<QString> &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<qint64>(download.value(QStringLiteral("size")).toDouble());
return library;
}
void parseLibraries(const QJsonObject &root,
QList<MinecraftLibrary> &target,
const QSet<QString> &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<MinecraftLibrary> &libraries)
{
const QString exact = VersionLoader::nativeClassifier();
const QString osPart = VersionLoader::osName() == QStringLiteral("osx")
? QStringLiteral("macos")
: VersionLoader::osName();
const QString base = QStringLiteral("natives-") + osPart;
QHash<QString, QString> 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<QString> &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<QString> &features,
QString *error)
{
MinecraftVersion version;
// Цепочка от самой производной версии к базовой: Fabric -> 1.20.1.
QList<QJsonObject> 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<MinecraftLibrary> collected;
for (const QJsonObject &object : chain)
parseLibraries(object, collected, features);
QSet<QString> 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;
}
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <QJsonArray>
#include <QList>
#include <QSet>
#include <QString>
#include <QStringList>
// Одна библиотека из 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; // Абсолютный путь к <id>.jar (может лежать у родителя)
int javaMajor = 8;
QStringList jvmArgs; // Ещё с неподставленными ${...}
QStringList gameArgs;
QList<MinecraftLibrary> 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 {
// Версии, реально установленные в <gameDir>/versions (есть папка и <id>.json).
QStringList installedVersions(const QString &gameDir);
// Читает и разворачивает версию. При ошибке возвращает невалидный объект и пишет error.
MinecraftVersion load(const QString &gameDir,
const QString &versionId,
const QSet<QString> &features,
QString *error);
// Стандартный алгоритм Mojang: правила применяются по порядку, побеждает последнее совпавшее.
bool rulesAllow(const QJsonArray &rules, const QSet<QString> &features = {});
// natives-windows / natives-macos-arm64 / natives-linux ... для текущей машины.
QString nativeClassifier();
QString osName();
QString osArch();
} // namespace VersionLoader