adding startup progress and style fix
This commit is contained in:
+11
-2
@@ -4,7 +4,8 @@ project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX)
|
|||||||
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
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)
|
qt_standard_project_setup(REQUIRES 6.8)
|
||||||
|
|
||||||
@@ -17,6 +18,14 @@ qt_add_qml_module(appMinecraft_launcher
|
|||||||
SOURCES
|
SOURCES
|
||||||
launcherbackend.h
|
launcherbackend.h
|
||||||
launcherbackend.cpp
|
launcherbackend.cpp
|
||||||
|
minecraftversion.h
|
||||||
|
minecraftversion.cpp
|
||||||
|
javalocator.h
|
||||||
|
javalocator.cpp
|
||||||
|
authservice.h
|
||||||
|
authservice.cpp
|
||||||
|
gamelauncher.h
|
||||||
|
gamelauncher.cpp
|
||||||
QML_FILES
|
QML_FILES
|
||||||
Main.qml
|
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
|
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
|
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)
|
include(GNUInstallDirs)
|
||||||
|
|||||||
@@ -14,14 +14,37 @@ Window {
|
|||||||
// ── Backend ────────────────────────────────────────────────────────────
|
// ── Backend ────────────────────────────────────────────────────────────
|
||||||
LauncherBackend {
|
LauncherBackend {
|
||||||
id: backend
|
id: backend
|
||||||
|
|
||||||
onLaunched: (profileName, versionName, serverUrl) => {
|
onLaunched: (profileName, versionName, serverUrl) => {
|
||||||
console.log("Запуск: профиль=" + profileName
|
window.showToast(qsTr("Запущено: %1 — %2").arg(profileName).arg(versionName), "#4b7a1f")
|
||||||
+ " версия=" + versionName
|
|
||||||
+ " сервер=" + serverUrl)
|
|
||||||
}
|
}
|
||||||
onLaunchError: (message) => {
|
onLaunchProgress: (message) => window.showToast(message, "#3a5a8c", 0)
|
||||||
errorLabel.text = message
|
onLaunchError: (message) => window.showToast(message, "#cc3333", 8000)
|
||||||
errorTimer.restart()
|
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
|
fillMode: Image.Stretch
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Error toast ────────────────────────────────────────────────────────
|
// ── Status toast ───────────────────────────────────────────────────────
|
||||||
Rectangle {
|
Rectangle {
|
||||||
visible: errorLabel.text !== ""
|
id: toastBackground
|
||||||
|
visible: toastLabel.text !== ""
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.bottomMargin: 40
|
anchors.bottomMargin: 40
|
||||||
width: errorLabel.implicitWidth + 32
|
width: Math.min(window.width - 80, toastLabel.implicitWidth + 32)
|
||||||
height: 40
|
height: toastLabel.implicitHeight + 20
|
||||||
radius: 8
|
radius: 8
|
||||||
color: "#cc3333"
|
color: "#cc3333"
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
id: errorLabel
|
id: toastLabel
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 32
|
||||||
color: "#ffffff"
|
color: "#ffffff"
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.Wrap
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: errorTimer
|
id: toastTimer
|
||||||
interval: 3000
|
interval: 4000
|
||||||
onTriggered: errorLabel.text = ""
|
onTriggered: toastLabel.text = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +93,12 @@ Window {
|
|||||||
height: 170
|
height: 170
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
hoverEnabled: false
|
hoverEnabled: false
|
||||||
|
enabled: !backend.busy && !backend.gameRunning
|
||||||
|
|
||||||
background: Image {
|
background: Image {
|
||||||
id: buttonImage
|
id: buttonImage
|
||||||
source: "images/Play_Button/Play_Active.svg"
|
source: "images/Play_Button/Play_Active.svg"
|
||||||
|
opacity: button.enabled ? 1.0 : 0.45
|
||||||
}
|
}
|
||||||
|
|
||||||
onPressed: buttonImage.source = "images/Play_Button/Play_pressed.svg"
|
onPressed: buttonImage.source = "images/Play_Button/Play_pressed.svg"
|
||||||
@@ -476,6 +505,30 @@ Window {
|
|||||||
onClicked: backend.openMinecraftFolder()
|
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 ─────────────────────────────────────────────────
|
// ── Add Profile Dialog ─────────────────────────────────────────────────
|
||||||
// Bug fix: header/footer must be Item (not Rectangle) with explicit
|
// Bug fix: header/footer must be Item (not Rectangle) with explicit
|
||||||
// implicitHeight so Dialog correctly computes its own total height.
|
// implicitHeight so Dialog correctly computes its own total height.
|
||||||
@@ -551,7 +604,8 @@ Window {
|
|||||||
id: pfPassword
|
id: pfPassword
|
||||||
x: 20
|
x: 20
|
||||||
width: parent.width - 40
|
width: parent.width - 40
|
||||||
placeholderText: "Пароль"
|
enabled: pfAuth.currentIndex === 1
|
||||||
|
placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне"
|
||||||
echoMode: TextInput.Password
|
echoMode: TextInput.Password
|
||||||
color: "#ffffff"
|
color: "#ffffff"
|
||||||
placeholderTextColor: "#666666"
|
placeholderTextColor: "#666666"
|
||||||
@@ -562,6 +616,13 @@ Window {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DarkCombo {
|
||||||
|
id: pfAuth
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
model: ["Офлайн (без пароля)", "Ely.by"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
footer: Item {
|
footer: Item {
|
||||||
@@ -613,13 +674,14 @@ Window {
|
|||||||
onAccepted: {
|
onAccepted: {
|
||||||
const name = pfName.text.trim()
|
const name = pfName.text.trim()
|
||||||
if (name !== "") {
|
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
|
profileBox.currentIndex = backend.profileNames.length - 1
|
||||||
}
|
}
|
||||||
pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""
|
pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0
|
||||||
}
|
}
|
||||||
onRejected: {
|
onRejected: {
|
||||||
pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""
|
pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,7 +742,7 @@ Window {
|
|||||||
id: verServer
|
id: verServer
|
||||||
x: 20
|
x: 20
|
||||||
width: parent.width - 40
|
width: parent.width - 40
|
||||||
placeholderText: "URL сервера загрузки"
|
placeholderText: "Адрес сервера, host:port (необязательно)"
|
||||||
color: "#ffffff"
|
color: "#ffffff"
|
||||||
placeholderTextColor: "#666666"
|
placeholderTextColor: "#666666"
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -690,6 +752,25 @@ Window {
|
|||||||
border.width: 1
|
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 {
|
footer: Item {
|
||||||
@@ -738,10 +819,12 @@ Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAboutToShow: verInstalled.currentIndex = 0
|
||||||
|
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
const name = verName.text.trim()
|
const name = verName.text.trim()
|
||||||
if (name !== "") {
|
if (name !== "") {
|
||||||
backend.addVersion(name, verServer.text.trim())
|
backend.addVersion(name, verServer.text.trim(), verInstalled.currentText)
|
||||||
versionBox.currentIndex = backend.versionNames.length - 1
|
versionBox.currentIndex = backend.versionNames.length - 1
|
||||||
}
|
}
|
||||||
verName.text = ""; verServer.text = ""
|
verName.text = ""; verServer.text = ""
|
||||||
@@ -770,6 +853,7 @@ Window {
|
|||||||
epName.text = data.name || ""
|
epName.text = data.name || ""
|
||||||
epLogin.text = data.login || ""
|
epLogin.text = data.login || ""
|
||||||
epPassword.text = data.password || ""
|
epPassword.text = data.password || ""
|
||||||
|
epAuth.currentIndex = data.authType === "elyby" ? 1 : 0
|
||||||
profileBox.popup.close()
|
profileBox.popup.close()
|
||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
@@ -837,7 +921,8 @@ Window {
|
|||||||
id: epPassword
|
id: epPassword
|
||||||
x: 20
|
x: 20
|
||||||
width: parent.width - 40
|
width: parent.width - 40
|
||||||
placeholderText: "Пароль"
|
enabled: epAuth.currentIndex === 1
|
||||||
|
placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне"
|
||||||
echoMode: TextInput.Password
|
echoMode: TextInput.Password
|
||||||
color: "#ffffff"
|
color: "#ffffff"
|
||||||
placeholderTextColor: "#666666"
|
placeholderTextColor: "#666666"
|
||||||
@@ -848,6 +933,13 @@ Window {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DarkCombo {
|
||||||
|
id: epAuth
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
model: ["Офлайн (без пароля)", "Ely.by"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
footer: Item {
|
footer: Item {
|
||||||
@@ -899,7 +991,8 @@ Window {
|
|||||||
onAccepted: {
|
onAccepted: {
|
||||||
const name = epName.text.trim()
|
const name = epName.text.trim()
|
||||||
if (editIndex >= 0 && name !== "")
|
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
|
editIndex = -1
|
||||||
epName.text = ""; epLogin.text = ""; epPassword.text = ""
|
epName.text = ""; epLogin.text = ""; epPassword.text = ""
|
||||||
}
|
}
|
||||||
@@ -927,6 +1020,13 @@ Window {
|
|||||||
editIndex = index
|
editIndex = index
|
||||||
evName.text = data.name || ""
|
evName.text = data.name || ""
|
||||||
evServer.text = data.serverUrl || ""
|
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()
|
versionBox.popup.close()
|
||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
@@ -979,7 +1079,7 @@ Window {
|
|||||||
id: evServer
|
id: evServer
|
||||||
x: 20
|
x: 20
|
||||||
width: parent.width - 40
|
width: parent.width - 40
|
||||||
placeholderText: "URL сервера загрузки"
|
placeholderText: "Адрес сервера, host:port (необязательно)"
|
||||||
color: "#ffffff"
|
color: "#ffffff"
|
||||||
placeholderTextColor: "#666666"
|
placeholderTextColor: "#666666"
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -989,6 +1089,23 @@ Window {
|
|||||||
border.width: 1
|
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 {
|
footer: Item {
|
||||||
@@ -1040,7 +1157,7 @@ Window {
|
|||||||
onAccepted: {
|
onAccepted: {
|
||||||
const name = evName.text.trim()
|
const name = evName.text.trim()
|
||||||
if (editIndex >= 0 && name !== "")
|
if (editIndex >= 0 && name !== "")
|
||||||
backend.updateVersion(editIndex, name, evServer.text.trim())
|
backend.updateVersion(editIndex, name, evServer.text.trim(), evInstalled.currentText)
|
||||||
editIndex = -1
|
editIndex = -1
|
||||||
evName.text = ""; evServer.text = ""
|
evName.text = ""; evServer.text = ""
|
||||||
}
|
}
|
||||||
@@ -1049,4 +1166,444 @@ Window {
|
|||||||
evName.text = ""; evServer.text = ""
|
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
@@ -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, {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -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
@@ -1,23 +1,60 @@
|
|||||||
#include "launcherbackend.h"
|
#include "launcherbackend.h"
|
||||||
|
|
||||||
|
#include "authservice.h"
|
||||||
|
#include "gamelauncher.h"
|
||||||
|
#include "javalocator.h"
|
||||||
|
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <algorithm>
|
#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)
|
LauncherBackend::LauncherBackend(QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, m_dataDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation))
|
, m_dataDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation))
|
||||||
|
, m_auth(new AuthService(this))
|
||||||
|
, m_launcher(new GameLauncher(this))
|
||||||
{
|
{
|
||||||
QDir().mkpath(m_dataDir);
|
QDir().mkpath(m_dataDir);
|
||||||
|
loadSettings();
|
||||||
loadData();
|
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 LauncherBackend::profileNames() const
|
||||||
{
|
{
|
||||||
QStringList names;
|
QStringList names;
|
||||||
@@ -34,37 +71,77 @@ QStringList LauncherBackend::versionNames() const
|
|||||||
return names;
|
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();
|
saveProfiles();
|
||||||
emit profilesChanged();
|
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();
|
saveVersions();
|
||||||
emit versionsChanged();
|
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())
|
if (index < 0 || index >= m_profiles.size())
|
||||||
return;
|
return;
|
||||||
m_profiles[index].name = name;
|
|
||||||
m_profiles[index].login = login;
|
Profile &profile = m_profiles[index];
|
||||||
m_profiles[index].password = password;
|
// Смена логина или пароля обесценивает сохранённый токен 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();
|
saveProfiles();
|
||||||
emit profilesChanged();
|
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())
|
if (index < 0 || index >= m_versions.size())
|
||||||
return;
|
return;
|
||||||
m_versions[index].name = name;
|
m_versions[index].name = name;
|
||||||
m_versions[index].serverUrl = serverUrl;
|
m_versions[index].serverUrl = serverUrl;
|
||||||
|
m_versions[index].versionId = versionId;
|
||||||
saveVersions();
|
saveVersions();
|
||||||
emit versionsChanged();
|
emit versionsChanged();
|
||||||
}
|
}
|
||||||
@@ -74,7 +151,10 @@ QVariantMap LauncherBackend::profileAt(int index) const
|
|||||||
if (index < 0 || index >= m_profiles.size())
|
if (index < 0 || index >= m_profiles.size())
|
||||||
return {};
|
return {};
|
||||||
const auto &p = m_profiles[index];
|
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
|
QVariantMap LauncherBackend::versionAt(int index) const
|
||||||
@@ -82,7 +162,7 @@ QVariantMap LauncherBackend::versionAt(int index) const
|
|||||||
if (index < 0 || index >= m_versions.size())
|
if (index < 0 || index >= m_versions.size())
|
||||||
return {};
|
return {};
|
||||||
const auto &v = m_versions[index];
|
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)
|
void LauncherBackend::removeProfile(int index)
|
||||||
@@ -103,40 +183,333 @@ void LauncherBackend::removeVersion(int index)
|
|||||||
emit versionsChanged();
|
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)
|
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()) {
|
if (profileIndex < 0 || profileIndex >= m_profiles.size()) {
|
||||||
emit launchError("Выберите профиль перед запуском");
|
emit launchError(tr("Выберите профиль перед запуском"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (versionIndex < 0 || versionIndex >= m_versions.size()) {
|
if (versionIndex < 0 || versionIndex >= m_versions.size()) {
|
||||||
emit launchError("Выберите версию перед запуском");
|
emit launchError(tr("Выберите версию перед запуском"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto &profile = m_profiles[profileIndex];
|
const QString directory = gameDir();
|
||||||
const auto &version = m_versions[versionIndex];
|
if (!QDir(directory).exists()) {
|
||||||
emit launched(profile.name, version.name, version.serverUrl);
|
emit launchError(tr("Папка игры не найдена: %1").arg(QDir::toNativeSeparators(directory)));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString LauncherBackend::minecraftDir() const
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
#if defined(Q_OS_WIN)
|
||||||
return QDir::homePath() + "/AppData/Roaming/.minecraft";
|
return QDir::homePath() + QStringLiteral("/AppData/Roaming/.minecraft");
|
||||||
#elif defined(Q_OS_MACOS)
|
#elif defined(Q_OS_MACOS)
|
||||||
return QDir::homePath() + "/Library/Application Support/minecraft";
|
return QDir::homePath() + QStringLiteral("/Library/Application Support/minecraft");
|
||||||
#else // Linux и прочие Unix-системы
|
#else // Linux и прочие Unix-системы
|
||||||
return QDir::homePath() + "/.minecraft";
|
return QDir::homePath() + QStringLiteral("/.minecraft");
|
||||||
#endif
|
#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()
|
void LauncherBackend::openMinecraftFolder()
|
||||||
{
|
{
|
||||||
const QString modsDir = minecraftDir() + "/mods";
|
const QString modsDir = gameDir() + QStringLiteral("/mods");
|
||||||
QDir().mkpath(modsDir);
|
QDir().mkpath(modsDir);
|
||||||
|
|
||||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(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()
|
void LauncherBackend::loadData()
|
||||||
@@ -150,17 +523,30 @@ void LauncherBackend::loadData()
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadFile(m_dataDir + "/profiles.json", [this](const QJsonObject &o) {
|
loadFile(m_dataDir + "/profiles.json", [this](const QJsonObject &o) {
|
||||||
int id = o["id"].toInt();
|
Profile profile;
|
||||||
m_profiles.append({id, o["name"].toString(), o["login"].toString(), o["password"].toString()});
|
profile.id = o["id"].toInt();
|
||||||
if (id >= m_nextProfileId)
|
profile.name = o["name"].toString();
|
||||||
m_nextProfileId = id + 1;
|
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(),
|
std::sort(m_profiles.begin(), m_profiles.end(),
|
||||||
[](const Profile &a, const Profile &b) { return a.id < b.id; });
|
[](const Profile &a, const Profile &b) { return a.id < b.id; });
|
||||||
|
|
||||||
loadFile(m_dataDir + "/versions.json", [this](const QJsonObject &o) {
|
loadFile(m_dataDir + "/versions.json", [this](const QJsonObject &o) {
|
||||||
int id = o["id"].toInt();
|
const int id = o["id"].toInt();
|
||||||
m_versions.append({id, o["name"].toString(), o["serverUrl"].toString()});
|
m_versions.append({id, o["name"].toString(), o["serverUrl"].toString(),
|
||||||
|
o.value("versionId").toString()});
|
||||||
if (id >= m_nextVersionId)
|
if (id >= m_nextVersionId)
|
||||||
m_nextVersionId = id + 1;
|
m_nextVersionId = id + 1;
|
||||||
});
|
});
|
||||||
@@ -172,7 +558,16 @@ void LauncherBackend::saveProfiles()
|
|||||||
{
|
{
|
||||||
QJsonArray arr;
|
QJsonArray arr;
|
||||||
for (const auto &p : m_profiles)
|
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");
|
QFile f(m_dataDir + "/profiles.json");
|
||||||
if (f.open(QIODevice::WriteOnly))
|
if (f.open(QIODevice::WriteOnly))
|
||||||
@@ -183,9 +578,41 @@ void LauncherBackend::saveVersions()
|
|||||||
{
|
{
|
||||||
QJsonArray arr;
|
QJsonArray arr;
|
||||||
for (const auto &v : m_versions)
|
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");
|
QFile f(m_dataDir + "/versions.json");
|
||||||
if (f.open(QIODevice::WriteOnly))
|
if (f.open(QIODevice::WriteOnly))
|
||||||
f.write(QJsonDocument(arr).toJson());
|
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
@@ -1,9 +1,15 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
#include <QQmlEngine>
|
|
||||||
|
struct AuthResult;
|
||||||
|
class AuthService;
|
||||||
|
class GameLauncher;
|
||||||
|
|
||||||
class LauncherBackend : public QObject
|
class LauncherBackend : public QObject
|
||||||
{
|
{
|
||||||
@@ -12,42 +18,117 @@ class LauncherBackend : public QObject
|
|||||||
|
|
||||||
Q_PROPERTY(QStringList profileNames READ profileNames NOTIFY profilesChanged)
|
Q_PROPERTY(QStringList profileNames READ profileNames NOTIFY profilesChanged)
|
||||||
Q_PROPERTY(QStringList versionNames READ versionNames NOTIFY versionsChanged)
|
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:
|
public:
|
||||||
explicit LauncherBackend(QObject *parent = nullptr);
|
explicit LauncherBackend(QObject *parent = nullptr);
|
||||||
|
~LauncherBackend() override;
|
||||||
|
|
||||||
QStringList profileNames() const;
|
QStringList profileNames() const;
|
||||||
QStringList versionNames() 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 addProfile(const QString &name,
|
||||||
Q_INVOKABLE void addVersion(const QString &name, const QString &serverUrl);
|
const QString &login,
|
||||||
Q_INVOKABLE void updateProfile(int index, const QString &name, const QString &login, const QString &password);
|
const QString &password,
|
||||||
Q_INVOKABLE void updateVersion(int index, const QString &name, const QString &serverUrl);
|
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 profileAt(int index) const;
|
||||||
Q_INVOKABLE QVariantMap versionAt(int index) const;
|
Q_INVOKABLE QVariantMap versionAt(int index) const;
|
||||||
Q_INVOKABLE void removeProfile(int index);
|
Q_INVOKABLE void removeProfile(int index);
|
||||||
Q_INVOKABLE void removeVersion(int index);
|
Q_INVOKABLE void removeVersion(int index);
|
||||||
|
|
||||||
|
// Главная кнопка. Проверяет выбор профиля и версии, комплектность .minecraft,
|
||||||
|
// при необходимости авторизуется и стартует игру.
|
||||||
Q_INVOKABLE void launchGame(int profileIndex, int versionIndex);
|
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 openMinecraftFolder();
|
||||||
|
Q_INVOKABLE void openGameFolder();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void profilesChanged();
|
void profilesChanged();
|
||||||
void versionsChanged();
|
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 launched(const QString &profileName, const QString &versionName, const QString &serverUrl);
|
||||||
void launchError(const QString &message);
|
void launchError(const QString &message);
|
||||||
|
void twoFactorRequired(const QString &profileName);
|
||||||
|
void gameOutput(const QString &line);
|
||||||
|
void gameFinished(int exitCode, bool crashed);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Profile { int id; QString name, login, password; };
|
struct Profile
|
||||||
struct Version { int id; QString name, serverUrl; };
|
{
|
||||||
|
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 loadData();
|
||||||
|
void loadSettings();
|
||||||
void saveProfiles();
|
void saveProfiles();
|
||||||
void saveVersions();
|
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<Profile> m_profiles;
|
||||||
QList<Version> m_versions;
|
QList<Version> m_versions;
|
||||||
|
QVariantMap m_settings;
|
||||||
QString m_dataDir;
|
QString m_dataDir;
|
||||||
int m_nextProfileId = 1;
|
int m_nextProfileId = 1;
|
||||||
int m_nextVersionId = 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
|
#include <QQuickStyle>
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
QGuiApplication app(argc, argv);
|
QGuiApplication app(argc, argv);
|
||||||
|
|
||||||
|
// Нативный стиль Windows игнорирует пользовательские contentItem/background
|
||||||
|
// и сыплет предупреждениями, а всё окно стилизовано вручную — берём Basic.
|
||||||
|
QQuickStyle::setStyle("Basic");
|
||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
QObject::connect(
|
QObject::connect(
|
||||||
&engine,
|
&engine,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user