new builds menu and modloaders implementation
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 2.15
|
||||
|
||||
// Пользовательские сборки одним окном: слева список со сменой активной,
|
||||
// справа карточка выбранной — имя, сервер, версия Minecraft и модлоадеры.
|
||||
//
|
||||
// Карточка сохраняет правки по ходу редактирования, отдельной кнопки
|
||||
// «Сохранить» нет: иначе появляется неочевидное несохранённое состояние,
|
||||
// пока пользователь переключается между сборками в левом списке.
|
||||
Dialog {
|
||||
id: buildsDialog
|
||||
|
||||
required property var backend
|
||||
|
||||
// Сборка, открытая в карточке справа. С активной не связана: активную
|
||||
// меняет отдельная кнопка, чтобы случайный клик по списку не запускал
|
||||
// архивацию .minecraft.
|
||||
property int editIndex: -1
|
||||
// Пока карточка заполняется, обработчики полей не должны писать обратно.
|
||||
property bool loading: false
|
||||
|
||||
modal: true
|
||||
padding: 0
|
||||
width: 880
|
||||
height: 600
|
||||
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
function selectBuild(index) {
|
||||
const data = buildsDialog.backend.customBuildAt(index)
|
||||
buildsDialog.loading = true
|
||||
buildsDialog.editIndex = index
|
||||
bdName.text = data.name || ""
|
||||
bdServer.text = data.serverUrl || ""
|
||||
bdMinecraft.selectedId = data.minecraftVersion || ""
|
||||
loaderPanel.applyBuild(data.loader || "", data.loaderVersion || "")
|
||||
buildsDialog.loading = false
|
||||
buildsDialog.refreshStatus()
|
||||
}
|
||||
|
||||
function commit(fields) {
|
||||
if (buildsDialog.loading || buildsDialog.editIndex < 0)
|
||||
return
|
||||
buildsDialog.backend.updateCustomBuild(buildsDialog.editIndex, fields)
|
||||
buildsDialog.refreshStatus()
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
if (buildsDialog.editIndex < 0) {
|
||||
bdStatus.text = ""
|
||||
return
|
||||
}
|
||||
const problems = buildsDialog.backend.checkInstallation(buildsDialog.editIndex)
|
||||
bdStatus.ok = problems.length === 0
|
||||
bdStatus.text = problems.length === 0
|
||||
? qsTr("Всё на месте, сборку можно запускать")
|
||||
: qsTr("Не хватает файлов (%1): %2").arg(problems.length).arg(problems[0])
|
||||
}
|
||||
|
||||
function newBuildName() {
|
||||
const taken = buildsDialog.backend.customBuildNames
|
||||
for (var n = 1; ; ++n) {
|
||||
const candidate = qsTr("Сборка %1").arg(n)
|
||||
if (taken.indexOf(candidate) === -1)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Установка версии или модлоадера меняет комплектность сборки — строка
|
||||
// состояния должна это заметить, не дожидаясь переоткрытия окна.
|
||||
Connections {
|
||||
target: buildsDialog.backend
|
||||
function onInstalledVersionsChanged() { buildsDialog.refreshStatus() }
|
||||
}
|
||||
|
||||
onAboutToShow: {
|
||||
buildsDialog.backend.refreshVersionCatalog()
|
||||
const names = buildsDialog.backend.customBuildNames
|
||||
if (names.length === 0)
|
||||
buildsDialog.editIndex = -1
|
||||
else
|
||||
buildsDialog.selectBuild(Math.max(0, buildsDialog.backend.activeBuildIndex))
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("Пользовательские сборки")
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 0
|
||||
|
||||
// ── Список сборок ───────────────────────────────────────────────
|
||||
Item {
|
||||
Layout.preferredWidth: 260
|
||||
Layout.fillHeight: true
|
||||
enabled: !buildsDialog.backend.switching
|
||||
|
||||
ListView {
|
||||
id: buildList
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: addBuildRow.top
|
||||
anchors.margins: 8
|
||||
clip: true
|
||||
spacing: 2
|
||||
model: buildsDialog.backend.customBuildNames
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: buildRow
|
||||
required property string modelData
|
||||
required property int index
|
||||
|
||||
width: buildList.width
|
||||
height: 40
|
||||
|
||||
contentItem: Item {
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.right: activeBadge.left
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: buildRow.modelData
|
||||
color: "#ffffff"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Text {
|
||||
id: activeBadge
|
||||
anchors.right: buildTrash.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||
text: qsTr("активна")
|
||||
color: "#91B315"
|
||||
font.pixelSize: 10
|
||||
}
|
||||
Image {
|
||||
id: buildTrash
|
||||
width: 16; height: 16
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: buildRow.hovered
|
||||
opacity: buildTrashArea.containsMouse ? 1.0 : 0.7
|
||||
source: "images/Trash.svg"
|
||||
sourceSize.width: 16; sourceSize.height: 16
|
||||
fillMode: Image.PreserveAspectFit
|
||||
|
||||
MouseArea {
|
||||
id: buildTrashArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
buildsDialog.backend.removeCustomBuild(buildRow.index)
|
||||
const left = buildsDialog.backend.customBuildNames.length
|
||||
if (left === 0)
|
||||
buildsDialog.editIndex = -1
|
||||
else
|
||||
buildsDialog.selectBuild(Math.min(buildRow.index, left - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||
? "#91B315" : "#232323"
|
||||
radius: 5
|
||||
}
|
||||
|
||||
onClicked: buildsDialog.selectBuild(buildRow.index)
|
||||
}
|
||||
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: addBuildRow
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
height: 34
|
||||
radius: 5
|
||||
color: addBuildArea.containsPress ? "#2d2d2d" : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("+ Новая сборка")
|
||||
color: "#91B315"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: addBuildArea
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
buildsDialog.backend.addCustomBuild(buildsDialog.newBuildName(), "", "")
|
||||
buildsDialog.selectBuild(buildsDialog.backend.customBuildNames.length - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 1
|
||||
Layout.fillHeight: true
|
||||
color: "#333333"
|
||||
}
|
||||
|
||||
// ── Карточка выбранной сборки ───────────────────────────────────
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.switching
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
contentHeight: detailsColumn.height
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
|
||||
Column {
|
||||
id: detailsColumn
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
LabelledField {
|
||||
id: bdName
|
||||
width: parent.width
|
||||
label: qsTr("Название сборки")
|
||||
placeholder: qsTr("как её видно в списке")
|
||||
onEditingFinished: buildsDialog.commit({"name": bdName.text.trim()})
|
||||
}
|
||||
|
||||
LabelledField {
|
||||
id: bdServer
|
||||
width: parent.width
|
||||
label: qsTr("Адрес сервера")
|
||||
placeholder: qsTr("host:port (необязательно)")
|
||||
onEditingFinished: buildsDialog.commit({"serverUrl": bdServer.text.trim()})
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Версия Minecraft")
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
VersionCatalogCombo {
|
||||
id: bdMinecraft
|
||||
width: parent.width
|
||||
catalog: buildsDialog.backend.versionCatalog
|
||||
onAboutToOpen: buildsDialog.backend.refreshVersionCatalog()
|
||||
onSelectedIdChanged: buildsDialog.commit({"minecraftVersion":
|
||||
bdMinecraft.selectedId})
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Модлоадер")
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
// Лоадеры несовместимы между собой: игра запускается ровно с
|
||||
// одним профилем versions/<id>. Поэтому отметка одного снимает
|
||||
// остальные, а «ничего не отмечено» — это чистая ваниль.
|
||||
Column {
|
||||
id: loaderPanel
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
property var rows: [bdForge, bdFabric, bdNeoForge, bdQuilt]
|
||||
|
||||
function applyBuild(loader, loaderVersion) {
|
||||
for (var i = 0; i < loaderPanel.rows.length; ++i)
|
||||
loaderPanel.rows[i].applyBuild(loader, loaderVersion)
|
||||
}
|
||||
|
||||
function selectedRow() {
|
||||
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||
if (loaderPanel.rows[i].checked)
|
||||
return loaderPanel.rows[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function keepOnly(row) {
|
||||
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||
if (loaderPanel.rows[i] === row)
|
||||
continue
|
||||
loaderPanel.rows[i].applyBuild("", "")
|
||||
}
|
||||
}
|
||||
|
||||
function commitSelection() {
|
||||
const row = loaderPanel.selectedRow()
|
||||
buildsDialog.commit({
|
||||
"loader": row ? row.loaderKey : "",
|
||||
"loaderVersion": row ? row.selectedVersion : "",
|
||||
// Профиль появится только после установки: пока
|
||||
// сборка запускается на чистой ванили.
|
||||
"resolvedVersionId": ""
|
||||
})
|
||||
}
|
||||
|
||||
LoaderRow {
|
||||
id: bdForge
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "forge"
|
||||
title: qsTr("Minecraft Forge")
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdForge)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
LoaderRow {
|
||||
id: bdFabric
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "fabric"
|
||||
title: qsTr("Fabric Loader")
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdFabric)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
LoaderRow {
|
||||
id: bdNeoForge
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "neoforge"
|
||||
title: qsTr("NeoForge")
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdNeoForge)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
LoaderRow {
|
||||
id: bdQuilt
|
||||
width: parent.width
|
||||
backend: buildsDialog.backend
|
||||
loaderKey: "quilt"
|
||||
title: qsTr("Quilt Loader")
|
||||
gameVersion: bdMinecraft.selectedId
|
||||
onUserChecked: loaderPanel.keepOnly(bdQuilt)
|
||||
onChanged: loaderPanel.commitSelection()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: bdStatus
|
||||
property bool ok: false
|
||||
width: parent.width
|
||||
color: bdStatus.ok ? "#8fbf5a" : "#cc6666"
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
implicitHeight: 60
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "#333333"
|
||||
}
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: qsTr("Установить")
|
||||
width: 140; height: 36
|
||||
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.busy
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: buildsDialog.backend.installCustomBuild(buildsDialog.editIndex)
|
||||
}
|
||||
|
||||
Button {
|
||||
text: qsTr("Сделать активной")
|
||||
width: 170; height: 36
|
||||
enabled: buildsDialog.editIndex >= 0
|
||||
&& buildsDialog.editIndex !== buildsDialog.backend.activeBuildIndex
|
||||
&& !buildsDialog.backend.busy
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: buildsDialog.backend.activeBuildIndex = buildsDialog.editIndex
|
||||
}
|
||||
|
||||
Button {
|
||||
text: qsTr("Закрыть")
|
||||
width: 120; height: 36
|
||||
enabled: !buildsDialog.backend.switching
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: "#ffffff"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? "#444444" : "#333333"
|
||||
radius: 6
|
||||
}
|
||||
onClicked: buildsDialog.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -32,9 +32,23 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
versionmanifestservice.cpp
|
||||
versioninstaller.h
|
||||
versioninstaller.cpp
|
||||
modloader.h
|
||||
modloaderversionservice.h
|
||||
modloaderversionservice.cpp
|
||||
modloaderinstaller.h
|
||||
modloaderinstaller.cpp
|
||||
buildarchiveworker.h
|
||||
buildarchiveworker.cpp
|
||||
buildswitcher.h
|
||||
buildswitcher.cpp
|
||||
QML_FILES
|
||||
Main.qml
|
||||
RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg
|
||||
DarkCombo.qml
|
||||
VersionCatalogCombo.qml
|
||||
LabelledField.qml
|
||||
BuildsDialog.qml
|
||||
LoaderRow.qml
|
||||
ProgressPanel.qml
|
||||
RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Комбобокс в общем тёмном стиле окна.
|
||||
ComboBox {
|
||||
id: darkCombo
|
||||
|
||||
// Та же стрелка, что у комбобоксов профиля и версии на главном окне,
|
||||
// вместо двойного шеврона Basic-стиля.
|
||||
indicator: Image {
|
||||
width: 10; height: 10
|
||||
x: darkCombo.width - width - 12
|
||||
y: (darkCombo.height - height) / 2
|
||||
source: darkCombo.down ? "images/Profile_Box/Asset_23.svg"
|
||||
: "images/Profile_Box/Asset_24.svg"
|
||||
rotation: 180
|
||||
sourceSize.width: 10; sourceSize.height: 10
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: 10
|
||||
rightPadding: darkCombo.indicator.width + 22
|
||||
text: darkCombo.displayText
|
||||
color: "#ffffff"
|
||||
font: darkCombo.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: darkCombo.activeFocus ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: darkCombo.width
|
||||
highlighted: darkCombo.highlightedIndex === index
|
||||
contentItem: Text {
|
||||
text: modelData
|
||||
color: "#ffffff"
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle { color: highlighted ? "#3a3a3a" : "#2a2a2a" }
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: darkCombo.height
|
||||
width: darkCombo.width
|
||||
implicitHeight: Math.min(contentItem.implicitHeight, 220)
|
||||
padding: 1
|
||||
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: darkCombo.delegateModel
|
||||
currentIndex: darkCombo.highlightedIndex
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
}
|
||||
background: Rectangle {
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Подпись + поле ввода одной колонкой — используется в настройках.
|
||||
Column {
|
||||
id: labelled
|
||||
|
||||
property alias text: field.text
|
||||
property alias validator: field.validator
|
||||
property string label: ""
|
||||
property string placeholder: ""
|
||||
|
||||
// Правка закончена: поле потеряло фокус или пользователь нажал Enter.
|
||||
signal editingFinished()
|
||||
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
text: labelled.label
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: field
|
||||
width: labelled.width
|
||||
onEditingFinished: labelled.editingFinished()
|
||||
height: 32
|
||||
placeholderText: labelled.placeholder
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
background: Rectangle {
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: field.activeFocus ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Один модлоадер в карточке сборки: чекбокс и, когда он отмечен, список версий
|
||||
// именно под выбранную версию Minecraft.
|
||||
//
|
||||
// Совместимость проверять не нужно: backend.loaderVersions() отдаёт список уже
|
||||
// под конкретную версию игры, поэтому несовместимой строки в нём не бывает.
|
||||
// Пустой список — это «лоадер не поддерживает эту версию игры», и он тоже
|
||||
// показывается словами, а не пустым выпадающим списком.
|
||||
Column {
|
||||
id: loaderRow
|
||||
|
||||
required property var backend
|
||||
required property string loaderKey
|
||||
required property string title
|
||||
|
||||
// Версия Minecraft, выбранная в карточке. Её смена сбрасывает выбор.
|
||||
property string gameVersion: ""
|
||||
|
||||
property alias checked: box.checked
|
||||
property string selectedVersion: ""
|
||||
property var entries: []
|
||||
|
||||
// Пользователь щёлкнул чекбокс — карточка снимет отметки с остальных
|
||||
// лоадеров: одновременно в .minecraft может жить только один.
|
||||
signal userChecked()
|
||||
// Отметка или версия изменились — карточке пора сохранить сборку.
|
||||
signal changed()
|
||||
|
||||
// Ставится, пока карточка заполняет строку данными сборки: в это время
|
||||
// обработчики не должны считать изменения пользовательскими.
|
||||
property bool applying: false
|
||||
|
||||
spacing: 4
|
||||
|
||||
function versionIndex(version) {
|
||||
for (var i = 0; i < loaderRow.entries.length; ++i) {
|
||||
if (loaderRow.entries[i].version === version)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Самая свежая стабильная сборка, а если стабильных нет — просто самая
|
||||
// свежая. Список отсортирован новыми вперёд, поэтому хватает первого
|
||||
// совпадения.
|
||||
function preferredIndex() {
|
||||
for (var i = 0; i < loaderRow.entries.length; ++i) {
|
||||
if (loaderRow.entries[i].recommended)
|
||||
return i
|
||||
}
|
||||
for (var j = 0; j < loaderRow.entries.length; ++j) {
|
||||
if (loaderRow.entries[j].stable)
|
||||
return j
|
||||
}
|
||||
return loaderRow.entries.length > 0 ? 0 : -1
|
||||
}
|
||||
|
||||
function syncCombo() {
|
||||
const index = loaderRow.versionIndex(loaderRow.selectedVersion)
|
||||
combo.currentIndex = index
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (!box.checked || loaderRow.gameVersion === "") {
|
||||
loaderRow.entries = []
|
||||
return
|
||||
}
|
||||
// Сначала из кэша — список появляется мгновенно, — потом обновление сети.
|
||||
loaderRow.entries = loaderRow.backend.loaderVersions(loaderRow.loaderKey,
|
||||
loaderRow.gameVersion)
|
||||
loaderRow.syncCombo()
|
||||
loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||
}
|
||||
|
||||
// Заполнение из сохранённой сборки: без сигналов changed().
|
||||
function applyBuild(loader, loaderVersion) {
|
||||
loaderRow.applying = true
|
||||
box.checked = loader === loaderRow.loaderKey
|
||||
loaderRow.selectedVersion = box.checked ? loaderVersion : ""
|
||||
loaderRow.reload()
|
||||
loaderRow.applying = false
|
||||
}
|
||||
|
||||
onGameVersionChanged: {
|
||||
// Сборка лоадера привязана к версии игры — при её смене выбор
|
||||
// сбрасывается, и список перезапрашивается под новую версию.
|
||||
loaderRow.entries = []
|
||||
loaderRow.selectedVersion = ""
|
||||
combo.currentIndex = -1
|
||||
loaderRow.reload()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: loaderRow.backend
|
||||
function onLoaderVersionsChanged(key, game) {
|
||||
if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked)
|
||||
return
|
||||
loaderRow.entries = loaderRow.backend.loaderVersions(key, game)
|
||||
if (loaderRow.selectedVersion === "" || loaderRow.versionIndex(loaderRow.selectedVersion) < 0) {
|
||||
const preferred = loaderRow.preferredIndex()
|
||||
if (preferred >= 0) {
|
||||
loaderRow.selectedVersion = loaderRow.entries[preferred].version
|
||||
if (!loaderRow.applying)
|
||||
loaderRow.changed()
|
||||
}
|
||||
}
|
||||
loaderRow.syncCombo()
|
||||
}
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: box
|
||||
text: loaderRow.title
|
||||
enabled: loaderRow.gameVersion !== ""
|
||||
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
x: 0
|
||||
y: (box.height - height) / 2
|
||||
radius: 4
|
||||
color: box.checked ? "#91B315" : "#2a2a2a"
|
||||
border.color: box.checked ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: box.checked
|
||||
text: "✓"
|
||||
color: "#1e1e1e"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: box.indicator.width + 8
|
||||
text: box.text
|
||||
color: box.enabled ? "#ffffff" : "#666666"
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
onToggled: {
|
||||
if (box.checked)
|
||||
loaderRow.userChecked()
|
||||
else
|
||||
loaderRow.selectedVersion = ""
|
||||
loaderRow.reload()
|
||||
loaderRow.changed()
|
||||
}
|
||||
}
|
||||
|
||||
DarkCombo {
|
||||
id: combo
|
||||
visible: box.checked && loaderRow.entries.length > 0
|
||||
width: loaderRow.width - 26
|
||||
height: 32
|
||||
x: 26
|
||||
model: loaderRow.entries.map(entry => entry.label)
|
||||
|
||||
onActivated: (index) => {
|
||||
if (index < 0 || index >= loaderRow.entries.length)
|
||||
return
|
||||
loaderRow.selectedVersion = loaderRow.entries[index].version
|
||||
loaderRow.changed()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: box.checked && loaderRow.entries.length === 0
|
||||
x: 26
|
||||
width: loaderRow.width - 26
|
||||
wrapMode: Text.Wrap
|
||||
font.pixelSize: 11
|
||||
color: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||
? "#888888" : "#cc6666"
|
||||
text: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||
? qsTr("Загрузка списка версий %1…").arg(loaderRow.title)
|
||||
: qsTr("%1 не поддерживает Minecraft %2").arg(loaderRow.title)
|
||||
.arg(loaderRow.gameVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Плашка хода долгой операции в левом нижнем углу: не задевает кнопку запуска,
|
||||
// сообщение по центру и кнопки папки с настройками.
|
||||
Rectangle {
|
||||
id: panel
|
||||
|
||||
property string title: ""
|
||||
property string status: ""
|
||||
// -1 — итог ещё неизвестен, показываем «…» вместо процентов.
|
||||
property double fraction: -1
|
||||
// Необязательная вторая строка: мегабайты у загрузки, путь у архивации.
|
||||
property string detail: ""
|
||||
property bool cancellable: true
|
||||
|
||||
signal cancelRequested()
|
||||
|
||||
width: 320
|
||||
height: 72
|
||||
radius: 8
|
||||
color: "#1e1e1e"
|
||||
opacity: 0.94
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
x: 12; y: 8
|
||||
width: parent.width - 70
|
||||
elide: Text.ElideRight
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
text: panel.title
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
y: 8
|
||||
color: "#91B315"
|
||||
font.pixelSize: 12
|
||||
text: panel.fraction < 0 ? "…" : Math.round(panel.fraction * 100) + "%"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
x: 12; y: 30
|
||||
width: parent.width - 24
|
||||
height: 6
|
||||
radius: 3
|
||||
color: "#2a2a2a"
|
||||
|
||||
Rectangle {
|
||||
width: panel.fraction > 0 ? track.width * panel.fraction : 0
|
||||
height: parent.height
|
||||
radius: 3
|
||||
color: "#91B315"
|
||||
Behavior on width { NumberAnimation { duration: 120 } }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 12; y: 44
|
||||
width: parent.width - 40
|
||||
elide: Text.ElideMiddle
|
||||
color: "#888888"
|
||||
font.pixelSize: 10
|
||||
text: panel.detail !== "" ? panel.detail : panel.status
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 10
|
||||
anchors.bottomMargin: 6
|
||||
visible: panel.cancellable
|
||||
text: "✕"
|
||||
color: cancelArea.containsMouse ? "#cc6666" : "#666666"
|
||||
font.pixelSize: 12
|
||||
|
||||
MouseArea {
|
||||
id: cancelArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
hoverEnabled: true
|
||||
onClicked: panel.cancelRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
// Выпадающий список версий: сверху установленные, ниже через разделитель
|
||||
// весь каталог Mojang, сверху поле поиска.
|
||||
//
|
||||
// Это Item с Popup, а не ComboBox: editable-ComboBox привязывает поле ввода
|
||||
// к currentText и запускает своё автодополнение, а при активном фильтре
|
||||
// currentIndex перестаёт указывать в исходную модель.
|
||||
Item {
|
||||
id: vcombo
|
||||
|
||||
property var catalog: []
|
||||
property string selectedId: ""
|
||||
property string placeholder: qsTr("Выберите версию")
|
||||
property string filterText: ""
|
||||
signal aboutToOpen()
|
||||
|
||||
implicitHeight: 36
|
||||
height: implicitHeight
|
||||
|
||||
// Фильтр по номеру версии. ~900 строк за нажатие — доли миллисекунды,
|
||||
// а делегаты ListView создаёт только для видимых строк.
|
||||
function filterEntries(needle) {
|
||||
const query = needle.trim().toLowerCase()
|
||||
if (query === "")
|
||||
return vcombo.catalog
|
||||
const out = []
|
||||
for (var i = 0; i < vcombo.catalog.length; ++i) {
|
||||
const entry = vcombo.catalog[i]
|
||||
if (entry.selectable === false)
|
||||
continue
|
||||
if (entry.search.indexOf(query) !== -1)
|
||||
out.push(entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
readonly property var visibleEntries: vcombo.filterEntries(vcombo.filterText)
|
||||
|
||||
function openPopup() {
|
||||
vcombo.aboutToOpen()
|
||||
vcombo.filterText = ""
|
||||
vcomboFilter.text = ""
|
||||
vcomboPopup.open()
|
||||
vcomboFilter.forceActiveFocus()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: vcomboField
|
||||
anchors.fill: parent
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: vcomboPopup.opened ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
leftPadding: 10
|
||||
rightPadding: 32
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
color: vcombo.selectedId === "" ? "#666666" : "#ffffff"
|
||||
text: vcombo.selectedId === "" ? vcombo.placeholder : vcombo.selectedId
|
||||
}
|
||||
|
||||
// Та же стрелка, что у остальных списков окна.
|
||||
Image {
|
||||
width: 10; height: 10
|
||||
x: vcomboField.width - width - 12
|
||||
y: (vcomboField.height - height) / 2
|
||||
source: vcomboPopup.opened ? "images/Profile_Box/Asset_23.svg"
|
||||
: "images/Profile_Box/Asset_24.svg"
|
||||
rotation: 180
|
||||
sourceSize.width: 10; sourceSize.height: 10
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: vcombo.openPopup()
|
||||
}
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: vcomboPopup
|
||||
y: vcombo.height + 2
|
||||
width: vcombo.width
|
||||
padding: 1
|
||||
height: 44 + Math.min(vcomboList.contentHeight, 240)
|
||||
|
||||
background: Rectangle {
|
||||
color: "#2a2a2a"
|
||||
radius: 6
|
||||
border.color: "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
TextField {
|
||||
id: vcomboFilter
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 4
|
||||
height: 30
|
||||
placeholderText: qsTr("Поиск версии…")
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
background: Rectangle {
|
||||
color: "#232323"
|
||||
radius: 5
|
||||
border.color: vcomboFilter.activeFocus ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
}
|
||||
onTextChanged: {
|
||||
vcombo.filterText = text
|
||||
vcomboList.currentIndex = vcomboList.firstSelectable(0, 1)
|
||||
}
|
||||
Keys.onEscapePressed: vcomboPopup.close()
|
||||
Keys.onDownPressed: vcomboList.step(1)
|
||||
Keys.onUpPressed: vcomboList.step(-1)
|
||||
Keys.onReturnPressed: vcomboList.acceptCurrent()
|
||||
Keys.onEnterPressed: vcomboList.acceptCurrent()
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: vcomboList
|
||||
anchors.top: vcomboFilter.bottom
|
||||
anchors.topMargin: 4
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
clip: true
|
||||
model: vcombo.visibleEntries
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
|
||||
// Разделитель и служебные строки клавишами пропускаем.
|
||||
function firstSelectable(from, delta) {
|
||||
for (var i = from; i >= 0 && i < count; i += delta) {
|
||||
if (model[i].selectable !== false)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
function step(delta) {
|
||||
const next = firstSelectable(currentIndex + delta, delta)
|
||||
if (next >= 0) {
|
||||
currentIndex = next
|
||||
positionViewAtIndex(next, ListView.Contain)
|
||||
}
|
||||
}
|
||||
function acceptCurrent() {
|
||||
if (currentIndex < 0 || currentIndex >= count)
|
||||
return
|
||||
const entry = model[currentIndex]
|
||||
if (entry.selectable === false)
|
||||
return
|
||||
vcombo.selectedId = entry.id
|
||||
vcomboPopup.close()
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: vcomboRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: vcomboList.width
|
||||
height: modelData.kind === "separator" ? 26
|
||||
: modelData.kind === "status" ? 30 : 34
|
||||
|
||||
// Разделитель между установленными и каталогом.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: vcomboRow.modelData.kind === "separator"
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
height: 1
|
||||
color: "#3a3a3a"
|
||||
}
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
color: "#2a2a2a"
|
||||
width: separatorLabel.implicitWidth + 12
|
||||
height: 18
|
||||
Text {
|
||||
id: separatorLabel
|
||||
anchors.centerIn: parent
|
||||
text: vcomboRow.modelData.label
|
||||
color: "#888888"
|
||||
font.pixelSize: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// «Загрузка списка…» или сообщение о недоступности.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: vcomboRow.modelData.kind === "status"
|
||||
text: vcomboRow.modelData.label
|
||||
color: "#888888"
|
||||
font.pixelSize: 11
|
||||
font.italic: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: vcomboRow.modelData.selectable !== false
|
||||
color: (vcomboRowArea.containsMouse || vcomboList.currentIndex === vcomboRow.index)
|
||||
? "#3a3a3a" : "#2a2a2a"
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 100
|
||||
elide: Text.ElideRight
|
||||
text: vcomboRow.modelData.label
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: vcomboRow.modelData.installed ? qsTr("установлена")
|
||||
: vcomboRow.modelData.type
|
||||
color: vcomboRow.modelData.installed ? "#91B315" : "#666666"
|
||||
font.pixelSize: 10
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: vcomboRowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
vcombo.selectedId = vcomboRow.modelData.id
|
||||
vcomboPopup.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
#include "buildarchiveworker.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
#include <QtCore/private/qzipreader_p.h>
|
||||
#include <QtCore/private/qzipwriter_p.h>
|
||||
|
||||
namespace {
|
||||
|
||||
// Имена внутри архива приходят из файла и превращаются в пути на диске —
|
||||
// «../..» увёл бы распаковку за пределы .minecraft. Правило то же, что у
|
||||
// установщика версий.
|
||||
bool isSafeRelativePath(const QString &path)
|
||||
{
|
||||
if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
|
||||
return false;
|
||||
const QStringList parts = path.split(u'/');
|
||||
for (const QString &part : parts) {
|
||||
if (part == QLatin1String(".") || part == QLatin1String(".."))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Файлы, которые попадут в архив, — относительными путями от gameDir.
|
||||
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
|
||||
{
|
||||
QStringList result;
|
||||
const QDir root(gameDir);
|
||||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||
| QDir::Hidden | QDir::System);
|
||||
for (const QFileInfo &entry : top) {
|
||||
if (excludeTop.contains(entry.fileName()))
|
||||
continue;
|
||||
if (entry.isSymLink())
|
||||
continue; // ссылку в архив не положить, а идти по ней опасно
|
||||
if (entry.isFile()) {
|
||||
result << entry.fileName();
|
||||
continue;
|
||||
}
|
||||
if (!entry.isDir())
|
||||
continue;
|
||||
|
||||
QDirIterator it(entry.absoluteFilePath(),
|
||||
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||
QDirIterator::Subdirectories);
|
||||
while (it.hasNext())
|
||||
result << root.relativeFilePath(it.next());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BuildArchiveWorker::BuildArchiveWorker(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::archive(const QString &gameDir,
|
||||
const QString &tempZipPath,
|
||||
const QStringList &excludeTop)
|
||||
{
|
||||
const QDir root(gameDir);
|
||||
if (!root.exists()) {
|
||||
emit finished(false, tr("Папка игры не найдена: %1").arg(QDir::toNativeSeparators(gameDir)));
|
||||
return;
|
||||
}
|
||||
|
||||
const QStringList files = collectFiles(gameDir, excludeTop);
|
||||
emit progress(0, files.size(), {});
|
||||
|
||||
{
|
||||
QZipWriter writer(tempZipPath);
|
||||
if (writer.status() != QZipWriter::NoError) {
|
||||
emit finished(false,
|
||||
tr("Не удалось создать архив %1")
|
||||
.arg(QDir::toNativeSeparators(tempZipPath)));
|
||||
return;
|
||||
}
|
||||
// Миры и конфиги сжимаются хорошо, уже сжатые jar-моды — нет: пусть
|
||||
// решает эвристика, а не мы.
|
||||
writer.setCompressionPolicy(QZipWriter::AutoCompress);
|
||||
|
||||
int done = 0;
|
||||
for (const QString &relative : files) {
|
||||
if (canceled()) {
|
||||
writer.close();
|
||||
QFile::remove(tempZipPath);
|
||||
emit finished(false, tr("Архивация отменена"));
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(root.absoluteFilePath(relative));
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
// Один нечитаемый файл не повод потерять всю сборку, но и молча
|
||||
// выкидывать его нельзя — иначе пользователь узнает о пропаже
|
||||
// уже после очистки .minecraft.
|
||||
writer.close();
|
||||
QFile::remove(tempZipPath);
|
||||
emit finished(false,
|
||||
tr("Не удалось прочитать %1: %2")
|
||||
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||
return;
|
||||
}
|
||||
writer.addFile(relative, &file);
|
||||
file.close();
|
||||
|
||||
if (writer.status() != QZipWriter::NoError) {
|
||||
writer.close();
|
||||
QFile::remove(tempZipPath);
|
||||
emit finished(false,
|
||||
tr("Ошибка записи архива на файле %1")
|
||||
.arg(QDir::toNativeSeparators(relative)));
|
||||
return;
|
||||
}
|
||||
emit progress(++done, files.size(), relative);
|
||||
}
|
||||
writer.close();
|
||||
}
|
||||
|
||||
emit finished(true, {});
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop)
|
||||
{
|
||||
QDir root(gameDir);
|
||||
if (!root.exists()) {
|
||||
emit finished(true, {}); // чистить нечего
|
||||
return;
|
||||
}
|
||||
|
||||
QFileInfoList victims;
|
||||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||
| QDir::Hidden | QDir::System);
|
||||
for (const QFileInfo &entry : top) {
|
||||
if (!keepTop.contains(entry.fileName()))
|
||||
victims << entry;
|
||||
}
|
||||
|
||||
emit progress(0, victims.size(), {});
|
||||
int done = 0;
|
||||
for (const QFileInfo &entry : victims) {
|
||||
// Отмена посреди очистки оставила бы полупустую папку, которую нечем
|
||||
// склеить обратно, — на этом шаге её не слушаем.
|
||||
const bool ok = entry.isDir() && !entry.isSymLink()
|
||||
? QDir(entry.absoluteFilePath()).removeRecursively()
|
||||
: QFile::remove(entry.absoluteFilePath());
|
||||
if (!ok) {
|
||||
emit finished(false,
|
||||
tr("Не удалось удалить %1")
|
||||
.arg(QDir::toNativeSeparators(entry.absoluteFilePath())));
|
||||
return;
|
||||
}
|
||||
emit progress(++done, victims.size(), entry.fileName());
|
||||
}
|
||||
|
||||
emit finished(true, {});
|
||||
}
|
||||
|
||||
void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
|
||||
{
|
||||
QZipReader reader(zipPath);
|
||||
if (!reader.isReadable()) {
|
||||
emit finished(false,
|
||||
tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||
emit progress(0, entries.size(), {});
|
||||
|
||||
const QDir root(gameDir);
|
||||
if (!QDir().mkpath(gameDir)) {
|
||||
emit finished(false,
|
||||
tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir)));
|
||||
return;
|
||||
}
|
||||
|
||||
int done = 0;
|
||||
for (const QZipReader::FileInfo &entry : entries) {
|
||||
if (canceled()) {
|
||||
emit finished(false, tr("Распаковка отменена"));
|
||||
return;
|
||||
}
|
||||
if (!entry.isFile) {
|
||||
emit progress(++done, entries.size(), entry.filePath);
|
||||
continue;
|
||||
}
|
||||
if (!isSafeRelativePath(entry.filePath)) {
|
||||
emit finished(false,
|
||||
tr("Архив повреждён: недопустимый путь «%1»").arg(entry.filePath));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString target = root.absoluteFilePath(entry.filePath);
|
||||
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||
emit finished(false,
|
||||
tr("Не удалось создать %1")
|
||||
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(target);
|
||||
const QByteArray data = reader.fileData(entry.filePath);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||
emit finished(false,
|
||||
tr("Не удалось записать %1: %2")
|
||||
.arg(QDir::toNativeSeparators(entry.filePath), file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
emit progress(++done, entries.size(), entry.filePath);
|
||||
}
|
||||
|
||||
emit finished(true, {});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAtomicInt>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
// Три операции над содержимым .minecraft, выполняемые в отдельном потоке:
|
||||
// упаковать, вычистить, распаковать. Гигабайты модов и миров нельзя гонять в
|
||||
// GUI-потоке — окно бы замерзало на всё время смены сборки.
|
||||
//
|
||||
// Объект живёт в своём QThread, вызывается только через очередь сигналов и
|
||||
// сам ничего не знает ни о сборках, ни о путях лаунчера: порядок шагов и
|
||||
// восстановление после сбоя — дело BuildSwitcher.
|
||||
class BuildArchiveWorker : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit BuildArchiveWorker(QObject *parent = nullptr);
|
||||
|
||||
// Вызывается из GUI-потока во время работы: отмена проверяется между файлами.
|
||||
void requestCancel() { m_cancel.storeRelaxed(1); }
|
||||
void clearCancel() { m_cancel.storeRelaxed(0); }
|
||||
|
||||
public slots:
|
||||
// Упаковывает всё содержимое gameDir, кроме элементов верхнего уровня из
|
||||
// excludeTop, во временный файл tempZipPath.
|
||||
void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop);
|
||||
|
||||
// Удаляет из gameDir всё, кроме элементов верхнего уровня из keepTop.
|
||||
void clear(const QString &gameDir, const QStringList &keepTop);
|
||||
|
||||
// Распаковывает архив в gameDir.
|
||||
void restore(const QString &zipPath, const QString &gameDir);
|
||||
|
||||
signals:
|
||||
void progress(int done, int total, const QString ¤tPath);
|
||||
void finished(bool ok, const QString &error);
|
||||
|
||||
private:
|
||||
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
|
||||
|
||||
QAtomicInt m_cancel;
|
||||
};
|
||||
@@ -0,0 +1,438 @@
|
||||
#include "buildswitcher.h"
|
||||
|
||||
#include "buildarchiveworker.h"
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
#include <QThread>
|
||||
#include <QUuid>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kIndexFile = QStringLiteral("index.json");
|
||||
const QString kStateSwitching = QStringLiteral("switching");
|
||||
const QString kStateIdle = QStringLiteral("idle");
|
||||
|
||||
// Запас на архив: точный размер заранее неизвестен, но упасть на середине
|
||||
// записи из-за полного диска хуже, чем отказаться заранее.
|
||||
constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024;
|
||||
|
||||
// Имя файла из названия сборки: оно попадает в файловую систему, поэтому всё
|
||||
// небезопасное заменяется подчёркиванием.
|
||||
QString slug(const QString &name)
|
||||
{
|
||||
QString result;
|
||||
result.reserve(name.size());
|
||||
for (const QChar c : name) {
|
||||
if (c.isLetterOrNumber() || c == u'-' || c == u'_')
|
||||
result.append(c);
|
||||
else if (c.isSpace() || c == u'.')
|
||||
result.append(u'_');
|
||||
}
|
||||
while (result.startsWith(u'_'))
|
||||
result.remove(0, 1);
|
||||
return result.isEmpty() ? QStringLiteral("build") : result.left(48);
|
||||
}
|
||||
|
||||
qint64 directorySize(const QString &path, const QStringList &excludeTop)
|
||||
{
|
||||
qint64 total = 0;
|
||||
const QDir root(path);
|
||||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||
| QDir::Hidden | QDir::System);
|
||||
for (const QFileInfo &entry : top) {
|
||||
if (excludeTop.contains(entry.fileName()) || entry.isSymLink())
|
||||
continue;
|
||||
if (entry.isFile()) {
|
||||
total += entry.size();
|
||||
continue;
|
||||
}
|
||||
QDirIterator it(entry.absoluteFilePath(),
|
||||
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||
QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
total += it.fileInfo().size();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QStringList BuildSwitcher::sharedTopLevel()
|
||||
{
|
||||
// Общий кэш, одинаковый для всех сборок и всегда восстановимый загрузкой.
|
||||
// versions/ здесь не случайно: без libraries/ описания версий бесполезны, а
|
||||
// две сборки на одной версии игры иначе таскали бы по копии клиентского jar.
|
||||
// Чем сборка запускается, помнит resolvedVersionId, а не набор папок.
|
||||
return {QStringLiteral("assets"),
|
||||
QStringLiteral("libraries"),
|
||||
QStringLiteral("versions"),
|
||||
QStringLiteral("launcher_profiles.json"),
|
||||
QStringLiteral("runtime")};
|
||||
}
|
||||
|
||||
BuildSwitcher::BuildSwitcher(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thread(new QThread(this))
|
||||
, m_worker(new BuildArchiveWorker)
|
||||
{
|
||||
m_worker->moveToThread(m_thread);
|
||||
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
|
||||
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
|
||||
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
|
||||
m_thread->start();
|
||||
|
||||
sweepTempArchives();
|
||||
}
|
||||
|
||||
BuildSwitcher::~BuildSwitcher()
|
||||
{
|
||||
m_worker->requestCancel();
|
||||
m_thread->quit();
|
||||
m_thread->wait(5000);
|
||||
}
|
||||
|
||||
double BuildSwitcher::fraction() const
|
||||
{
|
||||
if (m_state == Idle || m_total <= 0)
|
||||
return -1.0;
|
||||
return double(m_done) / double(m_total);
|
||||
}
|
||||
|
||||
QString BuildSwitcher::buildDir(int buildId) const
|
||||
{
|
||||
return LauncherPaths::buildStorageDir() + u'/' + QString::number(buildId);
|
||||
}
|
||||
|
||||
// На сборку — ровно один архив: имя с датой меняется от переключения к
|
||||
// переключению, поэтому ищем по расширению, а не по имени.
|
||||
QString BuildSwitcher::archivePathOf(int buildId) const
|
||||
{
|
||||
const QDir dir(buildDir(buildId));
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
|
||||
return zips.isEmpty() ? QString() : dir.absoluteFilePath(zips.first());
|
||||
}
|
||||
|
||||
void BuildSwitcher::setStage(const QString &stage)
|
||||
{
|
||||
m_stage = stage;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void BuildSwitcher::switchTo(int fromBuildId,
|
||||
const QString &fromName,
|
||||
int toBuildId,
|
||||
const QString &toName,
|
||||
const QString &gameDir)
|
||||
{
|
||||
if (m_state != Idle) {
|
||||
emit failed(toBuildId, tr("Смена сборки уже выполняется"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
QString rootError;
|
||||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||
emit failed(toBuildId, rootError, true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_fromId = fromBuildId;
|
||||
m_toId = toBuildId;
|
||||
m_fromName = fromName;
|
||||
m_toName = toName;
|
||||
m_gameDir = gameDir;
|
||||
m_done = 0;
|
||||
m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
|
||||
// Первая сборка за всё время: архивировать нечего, сразу разворачиваем цель.
|
||||
if (fromBuildId < 0 || !QDir(gameDir).exists()) {
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!QDir().mkpath(buildDir(fromBuildId))) {
|
||||
emit failed(toBuildId,
|
||||
tr("Не удалось создать %1")
|
||||
.arg(QDir::toNativeSeparators(buildDir(fromBuildId))),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes;
|
||||
const QStorageInfo storage(LauncherPaths::buildStorageDir());
|
||||
if (storage.isValid() && storage.bytesAvailable() < needed) {
|
||||
emit failed(toBuildId,
|
||||
tr("Недостаточно места для архива сборки «%1»: нужно около %2 МБ")
|
||||
.arg(fromName)
|
||||
.arg(needed / (1024 * 1024)),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-")
|
||||
+ QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip");
|
||||
|
||||
m_state = Archiving;
|
||||
setStage(tr("Сохранение сборки «%1»").arg(fromName));
|
||||
requestArchive(gameDir, m_tempZip, sharedTopLevel());
|
||||
}
|
||||
|
||||
void BuildSwitcher::cancel()
|
||||
{
|
||||
// Отмена имеет смысл только на архивации: после очистки папки отступать
|
||||
// уже некуда, там нужно доводить дело до конца.
|
||||
if (m_state == Archiving)
|
||||
m_worker->requestCancel();
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestArchive(const QString &gameDir,
|
||||
const QString &tempZip,
|
||||
const QStringList &exclude)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection,
|
||||
Q_ARG(QString, gameDir), Q_ARG(QString, tempZip),
|
||||
Q_ARG(QStringList, exclude));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection,
|
||||
Q_ARG(QString, gameDir), Q_ARG(QStringList, keep));
|
||||
}
|
||||
|
||||
void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir)
|
||||
{
|
||||
QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection,
|
||||
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
|
||||
}
|
||||
|
||||
void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath)
|
||||
{
|
||||
m_done = done;
|
||||
m_total = total;
|
||||
m_status = currentPath;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
|
||||
{
|
||||
switch (m_state) {
|
||||
case Idle:
|
||||
return;
|
||||
case Archiving:
|
||||
if (!ok) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(error, /*gameDirIntact=*/true);
|
||||
return;
|
||||
}
|
||||
commitArchive();
|
||||
return;
|
||||
case Clearing:
|
||||
if (!ok) {
|
||||
// Папка осталась разобранной: активной сборкой считается прежняя,
|
||||
// отметка о незавершённом переключении в index.json остаётся.
|
||||
fail(error, /*gameDirIntact=*/false);
|
||||
return;
|
||||
}
|
||||
beginRestoring();
|
||||
return;
|
||||
case Restoring:
|
||||
if (!ok) {
|
||||
fail(error, /*gameDirIntact=*/false);
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void BuildSwitcher::commitArchive()
|
||||
{
|
||||
const QString target = buildDir(m_fromId) + u'/'
|
||||
+ QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))
|
||||
+ u'_' + slug(m_fromName) + QStringLiteral(".zip");
|
||||
|
||||
// На сборку — один архив: новый встаёт на место, все прежние уходят.
|
||||
if (QFile::exists(target) && !QFile::remove(target)) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(tr("Не удалось заменить архив %1").arg(QDir::toNativeSeparators(target)), true);
|
||||
return;
|
||||
}
|
||||
if (!QFile::rename(m_tempZip, target)) {
|
||||
QFile::remove(m_tempZip);
|
||||
fail(tr("Не удалось сохранить архив %1").arg(QDir::toNativeSeparators(target)), true);
|
||||
return;
|
||||
}
|
||||
|
||||
const QDir dir(buildDir(m_fromId));
|
||||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
|
||||
for (const QString &name : zips) {
|
||||
if (dir.absoluteFilePath(name) != target)
|
||||
QFile::remove(dir.absoluteFilePath(name));
|
||||
}
|
||||
|
||||
// Отметка ставится до первого разрушающего действия: по ней следующий
|
||||
// запуск поймёт, что переключение оборвалось на середине.
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateSwitching);
|
||||
index.insert(QStringLiteral("from"), m_fromId);
|
||||
index.insert(QStringLiteral("to"), m_toId);
|
||||
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
builds.insert(QString::number(m_fromId),
|
||||
QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()},
|
||||
{QStringLiteral("name"), m_fromName},
|
||||
{QStringLiteral("savedAt"),
|
||||
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||||
index.insert(QStringLiteral("builds"), builds);
|
||||
writeIndex(index);
|
||||
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginClearing()
|
||||
{
|
||||
setStage(tr("Подготовка сборки «%1»").arg(m_toName));
|
||||
requestClear(m_gameDir, sharedTopLevel());
|
||||
}
|
||||
|
||||
void BuildSwitcher::beginRestoring()
|
||||
{
|
||||
const QString archive = archivePathOf(m_toId);
|
||||
if (archive.isEmpty()) {
|
||||
// Сборка ещё ни разу не запускалась: пустая .minecraft рядом с общими
|
||||
// кэшами — это и есть её правильное начальное состояние.
|
||||
m_state = Restoring;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
m_state = Restoring;
|
||||
setStage(tr("Восстановление сборки «%1»").arg(m_toName));
|
||||
requestRestore(archive, m_gameDir);
|
||||
}
|
||||
|
||||
void BuildSwitcher::finish()
|
||||
{
|
||||
QJsonObject index = readIndex();
|
||||
index.insert(QStringLiteral("state"), kStateIdle);
|
||||
index.insert(QStringLiteral("active"), m_toId);
|
||||
index.remove(QStringLiteral("from"));
|
||||
index.remove(QStringLiteral("to"));
|
||||
writeIndex(index);
|
||||
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit finished(toId);
|
||||
}
|
||||
|
||||
void BuildSwitcher::fail(const QString &message, bool gameDirIntact)
|
||||
{
|
||||
const int toId = m_toId;
|
||||
m_state = Idle;
|
||||
m_stage.clear();
|
||||
m_status.clear();
|
||||
m_done = m_total = 0;
|
||||
emit progressChanged();
|
||||
emit failed(toId, message, gameDirIntact);
|
||||
}
|
||||
|
||||
// ── Незавершённое переключение ─────────────────────────────────────────────
|
||||
|
||||
QString BuildSwitcher::interruptedSwitchWarning() const
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
|
||||
return {};
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
const QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||
const QString name = builds.value(QString::number(toId))
|
||||
.toObject()
|
||||
.value(QStringLiteral("name"))
|
||||
.toString();
|
||||
return tr("Прошлая смена сборки%1 не завершилась — восстанавливаем содержимое .minecraft.")
|
||||
.arg(name.isEmpty() ? QString() : tr(" на «%1»").arg(name));
|
||||
}
|
||||
|
||||
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||||
{
|
||||
const QJsonObject index = readIndex();
|
||||
if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
|
||||
return;
|
||||
|
||||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||
if (toId < 0 || m_state != Idle)
|
||||
return;
|
||||
|
||||
// Архив исходной сборки уже записан целиком — доигрываем только вторую
|
||||
// половину: очистку и распаковку цели.
|
||||
m_fromId = -1;
|
||||
m_toId = toId;
|
||||
m_toName = index.value(QStringLiteral("builds"))
|
||||
.toObject()
|
||||
.value(QString::number(toId))
|
||||
.toObject()
|
||||
.value(QStringLiteral("name"))
|
||||
.toString();
|
||||
m_gameDir = gameDir;
|
||||
m_done = m_total = 0;
|
||||
m_worker->clearCancel();
|
||||
m_state = Clearing;
|
||||
beginClearing();
|
||||
}
|
||||
|
||||
// ── Реестр архивов ─────────────────────────────────────────────────────────
|
||||
|
||||
QJsonObject BuildSwitcher::readIndex() const
|
||||
{
|
||||
QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
return QJsonDocument::fromJson(file.readAll()).object();
|
||||
}
|
||||
|
||||
void BuildSwitcher::writeIndex(const QJsonObject &index) const
|
||||
{
|
||||
if (!LauncherPaths::ensureRootExists())
|
||||
return;
|
||||
QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return;
|
||||
}
|
||||
file.commit();
|
||||
}
|
||||
|
||||
// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может
|
||||
// занимать гигабайты.
|
||||
void BuildSwitcher::sweepTempArchives() const
|
||||
{
|
||||
QDir root(LauncherPaths::buildStorageDir());
|
||||
const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
for (const QString &build : builds) {
|
||||
QDir dir(root.absoluteFilePath(build));
|
||||
const QStringList leftovers =
|
||||
dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden);
|
||||
for (const QString &name : leftovers)
|
||||
QFile::remove(dir.absoluteFilePath(name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class QThread;
|
||||
class BuildArchiveWorker;
|
||||
|
||||
// Смена активной сборки: содержимое .minecraft уезжает в архив своей сборки,
|
||||
// папка чистится, на её место разворачивается архив выбранной.
|
||||
//
|
||||
// Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и
|
||||
// не переименован на место, из .minecraft не удаляется ничего. Отметка о
|
||||
// начатом переключении пишется в builds/index.json до первого разрушающего
|
||||
// действия, поэтому обрыв питания или kill -9 всегда обнаружим на следующем
|
||||
// запуске — см. recoverIfNeeded().
|
||||
class BuildSwitcher : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit BuildSwitcher(QObject *parent = nullptr);
|
||||
~BuildSwitcher() override;
|
||||
|
||||
bool isRunning() const { return m_state != Idle; }
|
||||
QString stage() const { return m_stage; }
|
||||
QString status() const { return m_status; }
|
||||
double fraction() const; // 0..1, либо -1 пока итог неизвестен
|
||||
|
||||
// Общие для всех сборок каталоги: они не архивируются и не чистятся.
|
||||
static QStringList sharedTopLevel();
|
||||
|
||||
void switchTo(int fromBuildId,
|
||||
const QString &fromName,
|
||||
int toBuildId,
|
||||
const QString &toName,
|
||||
const QString &gameDir);
|
||||
void cancel();
|
||||
|
||||
// Незавершённое переключение с прошлого запуска. Возвращает пустую строку,
|
||||
// если всё в порядке, иначе — текст для пользователя.
|
||||
QString interruptedSwitchWarning() const;
|
||||
// Доигрывает прерванное переключение: очистка и распаковка целевой сборки.
|
||||
void resumeInterrupted(const QString &gameDir);
|
||||
|
||||
signals:
|
||||
void progressChanged();
|
||||
void finished(int toBuildId);
|
||||
void failed(int toBuildId, const QString &message, bool gameDirIntact);
|
||||
|
||||
private:
|
||||
enum State { Idle, Archiving, Clearing, Restoring };
|
||||
|
||||
// Сигналы к рабочему объекту в другом потоке.
|
||||
void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude);
|
||||
void requestClear(const QString &gameDir, const QStringList &keep);
|
||||
void requestRestore(const QString &zipPath, const QString &gameDir);
|
||||
|
||||
void onWorkerFinished(bool ok, const QString &error);
|
||||
void onWorkerProgress(int done, int total, const QString ¤tPath);
|
||||
|
||||
void commitArchive();
|
||||
void beginClearing();
|
||||
void beginRestoring();
|
||||
void finish();
|
||||
void fail(const QString &message, bool gameDirIntact);
|
||||
|
||||
QString buildDir(int buildId) const;
|
||||
QString archivePathOf(int buildId) const;
|
||||
void setStage(const QString &stage);
|
||||
|
||||
QJsonObject readIndex() const;
|
||||
void writeIndex(const QJsonObject &index) const;
|
||||
void sweepTempArchives() const;
|
||||
|
||||
QThread *m_thread;
|
||||
BuildArchiveWorker *m_worker;
|
||||
|
||||
State m_state = Idle;
|
||||
QString m_stage;
|
||||
QString m_status;
|
||||
int m_done = 0;
|
||||
int m_total = 0;
|
||||
|
||||
int m_fromId = -1;
|
||||
int m_toId = -1;
|
||||
QString m_fromName;
|
||||
QString m_toName;
|
||||
QString m_gameDir;
|
||||
QString m_tempZip;
|
||||
};
|
||||
+492
-88
@@ -6,6 +6,9 @@
|
||||
#include "launcherpaths.h"
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
#include "modloaderinstaller.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "buildswitcher.h"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
@@ -41,6 +44,9 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
, m_launcher(new GameLauncher(this))
|
||||
, m_manifest(new VersionManifestService(this))
|
||||
, m_installer(new VersionInstaller(m_manifest, this))
|
||||
, m_loaderMeta(new ModLoaderVersionService(this))
|
||||
, m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this))
|
||||
, m_switcher(new BuildSwitcher(this))
|
||||
{
|
||||
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
|
||||
// следующем — если её удалили, она появится снова.
|
||||
@@ -49,9 +55,22 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError);
|
||||
|
||||
loadSettings();
|
||||
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
||||
// создаст пустой customBuilds.json поверх старых данных.
|
||||
migrateLegacyBuildsFile();
|
||||
loadData();
|
||||
ensureDataFiles();
|
||||
|
||||
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
||||
// предлагаем пользователю переключиться заново: архив прежней сборки уже
|
||||
// записан целиком, а вот повторное переключение сначала заархивировало бы
|
||||
// поверх него нынешнее — наполовину разобранное — содержимое .minecraft.
|
||||
const QString interrupted = m_switcher->interruptedSwitchWarning();
|
||||
if (!interrupted.isEmpty()) {
|
||||
m_storageIssues << interrupted;
|
||||
QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); });
|
||||
}
|
||||
|
||||
connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress);
|
||||
connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
|
||||
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
|
||||
@@ -94,6 +113,88 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit busyChanged();
|
||||
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
|
||||
});
|
||||
connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
|
||||
emit switchChanged();
|
||||
emit busyChanged();
|
||||
});
|
||||
connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) {
|
||||
// Активной сборка становится только здесь: провалившееся переключение
|
||||
// оставляет активной прежнюю.
|
||||
m_settings.insert(QStringLiteral("activeBuildId"), toBuildId);
|
||||
saveSettings();
|
||||
invalidateCatalog();
|
||||
emit activeBuildChanged();
|
||||
emit installedVersionsChanged();
|
||||
emit switchChanged();
|
||||
emit busyChanged();
|
||||
const int index = indexOfBuildId(toBuildId);
|
||||
emit launchProgress(tr("Активна сборка «%1»")
|
||||
.arg(index >= 0 ? m_builds[index].name : QString()));
|
||||
});
|
||||
connect(m_switcher, &BuildSwitcher::failed, this,
|
||||
[this](int, const QString &message, bool gameDirIntact) {
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
emit switchChanged();
|
||||
emit busyChanged();
|
||||
emit launchError(gameDirIntact
|
||||
? tr("Сборка не переключена: %1").arg(message)
|
||||
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
|
||||
"осталось разобранным — повторите переключение.")
|
||||
.arg(message));
|
||||
});
|
||||
|
||||
connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
|
||||
this, &LauncherBackend::loaderVersionsChanged);
|
||||
connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged,
|
||||
this, &LauncherBackend::downloadChanged);
|
||||
|
||||
// Установщик лоадера делит панель прогресса и флаг занятости с обычным
|
||||
// установщиком версий — сигналы у них одни и те же.
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) {
|
||||
emit busyChanged();
|
||||
emit downloadChanged();
|
||||
emit launchProgress(tr("Установка %1…").arg(label));
|
||||
});
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged,
|
||||
this, &LauncherBackend::downloadChanged);
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::log, this, &LauncherBackend::gameOutput);
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::finished, this,
|
||||
[this](const QString &loader, const QString &gameVersion,
|
||||
const QString &loaderVersion, const QString &producedVersionId) {
|
||||
const int index = indexOfBuildId(m_loaderInstallBuildId);
|
||||
m_loaderInstallBuildId = -1;
|
||||
if (index >= 0) {
|
||||
updateCustomBuild(index,
|
||||
{{QStringLiteral("loader"), loader},
|
||||
{QStringLiteral("loaderVersion"), loaderVersion},
|
||||
{QStringLiteral("resolvedVersionId"), producedVersionId}});
|
||||
}
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
emit busyChanged();
|
||||
emit downloadChanged();
|
||||
emit launchProgress(tr("Готово: %1 для Minecraft %2")
|
||||
.arg(producedVersionId, gameVersion));
|
||||
});
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
|
||||
[this](const QString &label, const QString &message) {
|
||||
m_loaderInstallBuildId = -1;
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
emit busyChanged();
|
||||
emit downloadChanged();
|
||||
emit launchError(tr("%1: %2").arg(label, message));
|
||||
});
|
||||
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
|
||||
m_loaderInstallBuildId = -1;
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
emit busyChanged();
|
||||
emit downloadChanged();
|
||||
emit launchProgress(tr("Установка %1 отменена").arg(label));
|
||||
});
|
||||
|
||||
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
@@ -120,11 +221,11 @@ QStringList LauncherBackend::profileNames() const
|
||||
return names;
|
||||
}
|
||||
|
||||
QStringList LauncherBackend::versionNames() const
|
||||
QStringList LauncherBackend::customBuildNames() const
|
||||
{
|
||||
QStringList names;
|
||||
for (const auto &v : m_versions)
|
||||
names << v.name;
|
||||
for (const auto &b : m_builds)
|
||||
names << b.name;
|
||||
return names;
|
||||
}
|
||||
|
||||
@@ -155,12 +256,19 @@ void LauncherBackend::addProfile(const QString &name,
|
||||
emit profilesChanged();
|
||||
}
|
||||
|
||||
void LauncherBackend::addVersion(const QString &name, const QString &serverUrl, const QString &versionId)
|
||||
void LauncherBackend::addCustomBuild(const QString &name,
|
||||
const QString &serverUrl,
|
||||
const QString &minecraftVersion)
|
||||
{
|
||||
m_versions.append({m_nextVersionId++, name, serverUrl, versionId});
|
||||
saveVersions();
|
||||
emit versionsChanged();
|
||||
maybeInstall(versionIdOf(m_versions.last()));
|
||||
CustomBuild build;
|
||||
build.id = m_nextBuildId++;
|
||||
build.name = name;
|
||||
build.serverUrl = serverUrl;
|
||||
build.minecraftVersion = minecraftVersion;
|
||||
m_builds.append(build);
|
||||
saveCustomBuilds();
|
||||
emit customBuildsChanged();
|
||||
maybeInstall(launchVersionIdOf(m_builds.last()));
|
||||
}
|
||||
|
||||
void LauncherBackend::updateProfile(int index,
|
||||
@@ -190,19 +298,43 @@ void LauncherBackend::updateProfile(int index,
|
||||
emit profilesChanged();
|
||||
}
|
||||
|
||||
void LauncherBackend::updateVersion(int index,
|
||||
const QString &name,
|
||||
const QString &serverUrl,
|
||||
const QString &versionId)
|
||||
void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
|
||||
{
|
||||
if (index < 0 || index >= m_versions.size())
|
||||
if (index < 0 || index >= m_builds.size())
|
||||
return;
|
||||
m_versions[index].name = name;
|
||||
m_versions[index].serverUrl = serverUrl;
|
||||
m_versions[index].versionId = versionId;
|
||||
saveVersions();
|
||||
emit versionsChanged();
|
||||
maybeInstall(versionIdOf(m_versions[index]));
|
||||
|
||||
CustomBuild &build = m_builds[index];
|
||||
const auto take = [&fields](const char *key, QString &target) {
|
||||
const auto it = fields.constFind(QLatin1String(key));
|
||||
if (it == fields.constEnd())
|
||||
return false;
|
||||
target = it->toString();
|
||||
return true;
|
||||
};
|
||||
|
||||
// Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит.
|
||||
// Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию.
|
||||
const QString previousMinecraft = build.minecraftVersion;
|
||||
|
||||
take("name", build.name);
|
||||
take("serverUrl", build.serverUrl);
|
||||
take("minecraftVersion", build.minecraftVersion);
|
||||
take("loader", build.loader);
|
||||
take("loaderVersion", build.loaderVersion);
|
||||
take("resolvedVersionId", build.resolvedVersionId);
|
||||
|
||||
if (build.minecraftVersion != previousMinecraft
|
||||
&& !fields.contains(QStringLiteral("loaderVersion"))) {
|
||||
build.loaderVersion.clear();
|
||||
build.resolvedVersionId.clear();
|
||||
}
|
||||
|
||||
saveCustomBuilds();
|
||||
emit customBuildsChanged();
|
||||
emit activeBuildChanged(); // имя активной сборки могло измениться
|
||||
// maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу
|
||||
// правки, и загрузка стартовала бы почти на каждое действие. Для этого есть
|
||||
// installCustomBuild().
|
||||
}
|
||||
|
||||
QVariantMap LauncherBackend::profileAt(int index) const
|
||||
@@ -216,12 +348,18 @@ QVariantMap LauncherBackend::profileAt(int index) const
|
||||
{"authType", p.authType}};
|
||||
}
|
||||
|
||||
QVariantMap LauncherBackend::versionAt(int index) const
|
||||
QVariantMap LauncherBackend::customBuildAt(int index) const
|
||||
{
|
||||
if (index < 0 || index >= m_versions.size())
|
||||
if (index < 0 || index >= m_builds.size())
|
||||
return {};
|
||||
const auto &v = m_versions[index];
|
||||
return {{"name", v.name}, {"serverUrl", v.serverUrl}, {"versionId", versionIdOf(v)}};
|
||||
const auto &b = m_builds[index];
|
||||
return {{"name", b.name},
|
||||
{"serverUrl", b.serverUrl},
|
||||
{"minecraftVersion", b.minecraftVersion},
|
||||
{"loader", b.loader},
|
||||
{"loaderVersion", b.loaderVersion},
|
||||
{"resolvedVersionId", b.resolvedVersionId},
|
||||
{"launchVersionId", launchVersionIdOf(b)}};
|
||||
}
|
||||
|
||||
void LauncherBackend::removeProfile(int index)
|
||||
@@ -233,37 +371,105 @@ void LauncherBackend::removeProfile(int index)
|
||||
emit profilesChanged();
|
||||
}
|
||||
|
||||
void LauncherBackend::removeVersion(int index)
|
||||
void LauncherBackend::removeCustomBuild(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_versions.size())
|
||||
if (index < 0 || index >= m_builds.size())
|
||||
return;
|
||||
m_versions.removeAt(index);
|
||||
saveVersions();
|
||||
emit versionsChanged();
|
||||
m_builds.removeAt(index);
|
||||
saveCustomBuilds();
|
||||
emit customBuildsChanged();
|
||||
emit activeBuildChanged();
|
||||
}
|
||||
|
||||
QString LauncherBackend::versionIdOf(const Version &version) const
|
||||
int LauncherBackend::indexOfBuildId(int buildId) const
|
||||
{
|
||||
// Пользователь мог назвать запись как угодно — тогда версией считаем само имя.
|
||||
return version.versionId.isEmpty() ? version.name : version.versionId;
|
||||
for (int i = 0; i < m_builds.size(); ++i) {
|
||||
if (m_builds[i].id == buildId)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QStringList LauncherBackend::checkInstallation(int versionIndex) const
|
||||
int LauncherBackend::activeBuildIndex() const
|
||||
{
|
||||
if (versionIndex < 0 || versionIndex >= m_versions.size())
|
||||
return {tr("Версия не выбрана")};
|
||||
if (m_builds.isEmpty())
|
||||
return -1;
|
||||
const int index = indexOfBuildId(m_settings.value(QStringLiteral("activeBuildId")).toInt());
|
||||
// Настройка могла указывать на удалённую сборку — тогда активна первая.
|
||||
return index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
const Version &entry = m_versions[versionIndex];
|
||||
const QString id = versionIdOf(entry);
|
||||
void LauncherBackend::setActiveBuildIndex(int index)
|
||||
{
|
||||
const int current = activeBuildIndex();
|
||||
if (index < 0 || index >= m_builds.size() || index == current)
|
||||
return;
|
||||
|
||||
if (m_switcher->isRunning()) {
|
||||
emit launchError(tr("Смена сборки уже выполняется"));
|
||||
return;
|
||||
}
|
||||
if (m_launcher->isRunning()) {
|
||||
emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
|
||||
return;
|
||||
}
|
||||
if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) {
|
||||
emit launchError(tr("Дождитесь окончания текущей операции"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в
|
||||
// её архив, и только потом на его место разворачивается выбранная.
|
||||
const CustomBuild &target = m_builds[index];
|
||||
emit launchProgress(tr("Смена сборки на «%1»…").arg(target.name));
|
||||
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
|
||||
current >= 0 ? m_builds[current].name : QString(),
|
||||
target.id,
|
||||
target.name,
|
||||
gameDir());
|
||||
emit switchChanged();
|
||||
emit busyChanged();
|
||||
}
|
||||
|
||||
QString LauncherBackend::activeBuildName() const
|
||||
{
|
||||
const int index = activeBuildIndex();
|
||||
return index < 0 ? QString() : m_builds[index].name;
|
||||
}
|
||||
|
||||
void LauncherBackend::installCustomBuild(int index)
|
||||
{
|
||||
// Установка сборки — это установка её версии игры и, если выбран,
|
||||
// модлоадера: они ставятся одной цепочкой.
|
||||
installLoaderForBuild(index);
|
||||
}
|
||||
|
||||
QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const
|
||||
{
|
||||
// resolvedVersionId появляется после установки модлоадера. Пользователь мог
|
||||
// назвать сборку как угодно — тогда версией считаем само имя: так работали
|
||||
// записи, созданные до появления каталога версий.
|
||||
if (!build.resolvedVersionId.isEmpty())
|
||||
return build.resolvedVersionId;
|
||||
return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion;
|
||||
}
|
||||
|
||||
QStringList LauncherBackend::checkInstallation(int buildIndex) const
|
||||
{
|
||||
if (buildIndex < 0 || buildIndex >= m_builds.size())
|
||||
return {tr("Сборка не выбрана")};
|
||||
|
||||
const CustomBuild &entry = m_builds[buildIndex];
|
||||
const QString id = launchVersionIdOf(entry);
|
||||
if (id.trimmed().isEmpty())
|
||||
return {tr("У записи лаунчера не указан ID версии. Проверьте %1")
|
||||
.arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))};
|
||||
return {tr("У сборки не выбрана версия Minecraft. Проверьте %1")
|
||||
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))};
|
||||
|
||||
QString error;
|
||||
const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error);
|
||||
if (!version.isValid()) {
|
||||
return {error.isEmpty()
|
||||
? tr("Версия «%1» из записи «%2» не установлена в %3")
|
||||
? tr("Версия «%1» из сборки «%2» не установлена в %3")
|
||||
.arg(id,
|
||||
entry.name,
|
||||
QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions")))
|
||||
@@ -416,28 +622,43 @@ void LauncherBackend::installVersion(const QString &versionId)
|
||||
|
||||
void LauncherBackend::cancelDownload()
|
||||
{
|
||||
m_installer->cancel();
|
||||
// Установщик лоадера сам остановит вложенную установку версии.
|
||||
if (m_loaderInstaller->isRunning())
|
||||
m_loaderInstaller->cancel();
|
||||
else
|
||||
m_installer->cancel();
|
||||
}
|
||||
|
||||
// Панель загрузки одна на два установщика: пока идёт установка модлоадера, она
|
||||
// показывает его стадии, включая вложенную загрузку файлов версии.
|
||||
bool LauncherBackend::downloading() const
|
||||
{
|
||||
return m_installer->isRunning();
|
||||
return m_loaderInstaller->isRunning() || m_installer->isRunning();
|
||||
}
|
||||
|
||||
double LauncherBackend::downloadProgress() const
|
||||
{
|
||||
return m_installer->fraction();
|
||||
if (!m_loaderInstaller->isRunning())
|
||||
return m_installer->fraction();
|
||||
// Внутри установки лоадера файлы качает VersionInstaller — его доля точнее.
|
||||
return m_installer->isRunning() ? m_installer->fraction() : m_loaderInstaller->fraction();
|
||||
}
|
||||
|
||||
QString LauncherBackend::downloadVersion() const
|
||||
{
|
||||
if (m_loaderInstaller->isRunning())
|
||||
return m_loaderInstaller->label();
|
||||
return m_installer->versionId();
|
||||
}
|
||||
|
||||
QString LauncherBackend::downloadStatus() const
|
||||
{
|
||||
const QString stage = m_installer->stage();
|
||||
const QString file = m_installer->currentFile();
|
||||
QString stage = m_installer->stage();
|
||||
QString file = m_installer->currentFile();
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
|
||||
stage = m_loaderInstaller->stage();
|
||||
file = m_loaderInstaller->currentFile();
|
||||
}
|
||||
if (stage.isEmpty())
|
||||
return file;
|
||||
if (file.isEmpty())
|
||||
@@ -447,16 +668,111 @@ QString LauncherBackend::downloadStatus() const
|
||||
|
||||
qint64 LauncherBackend::downloadBytesDone() const
|
||||
{
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
||||
return m_loaderInstaller->bytesDone();
|
||||
return m_installer->bytesDone();
|
||||
}
|
||||
|
||||
qint64 LauncherBackend::downloadBytesTotal() const
|
||||
{
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
||||
return m_loaderInstaller->bytesTotal();
|
||||
return m_installer->bytesTotal();
|
||||
}
|
||||
|
||||
void LauncherBackend::launchGame(int profileIndex, int versionIndex)
|
||||
// ── Модлоадеры ─────────────────────────────────────────────────────────────
|
||||
|
||||
QVariantList LauncherBackend::loaderVersions(const QString &loaderKey,
|
||||
const QString &gameVersion) const
|
||||
{
|
||||
const auto loader = loaderFromKey(loaderKey);
|
||||
if (!loader)
|
||||
return {};
|
||||
|
||||
QVariantList result;
|
||||
const QList<LoaderVersionEntry> entries = m_loaderMeta->versions(*loader, gameVersion);
|
||||
result.reserve(entries.size());
|
||||
for (const LoaderVersionEntry &entry : entries) {
|
||||
QString label = entry.loaderVersion;
|
||||
if (entry.recommended)
|
||||
label += tr(" — рекомендуемая");
|
||||
else if (!entry.stable)
|
||||
label += tr(" — нестабильная");
|
||||
result.append(QVariantMap{{QStringLiteral("version"), entry.loaderVersion},
|
||||
{QStringLiteral("label"), label},
|
||||
{QStringLiteral("recommended"), entry.recommended},
|
||||
{QStringLiteral("stable"), entry.stable}});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void LauncherBackend::refreshLoaderVersions(const QString &loaderKey,
|
||||
const QString &gameVersion,
|
||||
bool force)
|
||||
{
|
||||
const auto loader = loaderFromKey(loaderKey);
|
||||
if (!loader)
|
||||
return;
|
||||
m_loaderMeta->ensureLoaded(*loader, gameVersion, [this, loaderKey, gameVersion](
|
||||
bool ok, const QString &warning) {
|
||||
if (!ok && !warning.isEmpty())
|
||||
emit launchError(warning);
|
||||
// Кэш мог ответить сразу, без сигнала versionsChanged.
|
||||
emit loaderVersionsChanged(loaderKey, gameVersion);
|
||||
}, force);
|
||||
}
|
||||
|
||||
bool LauncherBackend::loaderVersionsLoading(const QString &loaderKey,
|
||||
const QString &gameVersion) const
|
||||
{
|
||||
const auto loader = loaderFromKey(loaderKey);
|
||||
return loader && m_loaderMeta->isRefreshing(*loader, gameVersion);
|
||||
}
|
||||
|
||||
void LauncherBackend::installLoaderForBuild(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_builds.size())
|
||||
return;
|
||||
if (busy()) {
|
||||
emit launchError(tr("Дождитесь окончания текущей загрузки"));
|
||||
return;
|
||||
}
|
||||
|
||||
const CustomBuild &build = m_builds[index];
|
||||
if (build.minecraftVersion.trimmed().isEmpty()) {
|
||||
emit launchError(tr("Сначала выберите версию Minecraft для сборки «%1»").arg(build.name));
|
||||
return;
|
||||
}
|
||||
|
||||
// Лоадер не выбран — сборка ванильная, ставим саму версию игры.
|
||||
const auto loader = loaderFromKey(build.loader);
|
||||
if (!loader) {
|
||||
if (build.resolvedVersionId != build.minecraftVersion)
|
||||
updateCustomBuild(index, {{QStringLiteral("resolvedVersionId"), QString()}});
|
||||
maybeInstall(build.minecraftVersion);
|
||||
return;
|
||||
}
|
||||
if (build.loaderVersion.trimmed().isEmpty()) {
|
||||
emit launchError(tr("Выберите версию %1 для сборки «%2»")
|
||||
.arg(loaderTitle(*loader), build.name));
|
||||
return;
|
||||
}
|
||||
|
||||
m_loaderInstallBuildId = build.id;
|
||||
m_loaderInstaller->install(gameDir(),
|
||||
*loader,
|
||||
build.minecraftVersion,
|
||||
build.loaderVersion,
|
||||
m_settings.value(QStringLiteral("javaPath")).toString());
|
||||
}
|
||||
|
||||
void LauncherBackend::launchGame(int profileIndex, int buildIndex)
|
||||
{
|
||||
if (m_loaderInstaller->isRunning()) {
|
||||
emit launchError(tr("Идёт установка «%1» — дождитесь окончания")
|
||||
.arg(m_loaderInstaller->label()));
|
||||
return;
|
||||
}
|
||||
if (m_installer->isRunning()) {
|
||||
emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания")
|
||||
.arg(m_installer->versionId()));
|
||||
@@ -474,8 +790,8 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex)
|
||||
emit launchError(tr("Выберите профиль перед запуском"));
|
||||
return;
|
||||
}
|
||||
if (versionIndex < 0 || versionIndex >= m_versions.size()) {
|
||||
emit launchError(tr("Выберите версию перед запуском"));
|
||||
if (buildIndex < 0 || buildIndex >= m_builds.size()) {
|
||||
emit launchError(tr("Выберите сборку перед запуском"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -488,11 +804,12 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex)
|
||||
return;
|
||||
}
|
||||
|
||||
const Version &entry = m_versions[versionIndex];
|
||||
const QString id = versionIdOf(entry);
|
||||
const CustomBuild &entry = m_builds[buildIndex];
|
||||
const QString id = launchVersionIdOf(entry);
|
||||
if (id.trimmed().isEmpty()) {
|
||||
emit launchError(tr("У записи лаунчера не указан ID версии. Проверьте %1")
|
||||
.arg(QDir::toNativeSeparators(LauncherPaths::versionsFile())));
|
||||
emit launchError(tr("У сборки «%1» не выбрана версия Minecraft. Проверьте %2")
|
||||
.arg(entry.name,
|
||||
QDir::toNativeSeparators(LauncherPaths::customBuildsFile())));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -501,7 +818,7 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex)
|
||||
if (!installed.contains(id)) {
|
||||
emit launchError(installed.isEmpty()
|
||||
? tr("В %1 нет ни одной установленной версии").arg(versionsDir)
|
||||
: tr("Версия «%1» из записи «%2» не установлена в %3. Доступны: %4")
|
||||
: tr("Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4")
|
||||
.arg(id,
|
||||
entry.name,
|
||||
versionsDir,
|
||||
@@ -536,7 +853,9 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex)
|
||||
}
|
||||
|
||||
m_pendingProfile = profileIndex;
|
||||
m_pendingVersion = versionIndex;
|
||||
// Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из
|
||||
// модального окна, и индекс уехал бы на чужую запись.
|
||||
m_pendingBuild = entry.id;
|
||||
m_pendingInjectorPath.clear();
|
||||
beginAuthentication();
|
||||
}
|
||||
@@ -621,20 +940,26 @@ void LauncherBackend::submitTwoFactorCode(const QString &code)
|
||||
void LauncherBackend::cancelPendingLaunch()
|
||||
{
|
||||
m_pendingProfile = -1;
|
||||
m_pendingVersion = -1;
|
||||
m_pendingBuild = -1;
|
||||
m_pendingInjectorPath.clear();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
void LauncherBackend::continueLaunch(const AuthResult &auth)
|
||||
{
|
||||
if (m_pendingProfile < 0 || m_pendingVersion < 0) {
|
||||
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) {
|
||||
failLaunch(tr("Запуск был отменён"));
|
||||
return;
|
||||
}
|
||||
|
||||
const int buildIndex = indexOfBuildId(m_pendingBuild);
|
||||
if (buildIndex < 0) {
|
||||
failLaunch(tr("Сборка была удалена, пока шла авторизация"));
|
||||
return;
|
||||
}
|
||||
|
||||
Profile &profile = m_profiles[m_pendingProfile];
|
||||
const Version &version = m_versions[m_pendingVersion];
|
||||
const CustomBuild &build = m_builds[buildIndex];
|
||||
|
||||
// Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем,
|
||||
// чтобы в следующий раз обойтись без пароля.
|
||||
@@ -666,7 +991,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
|
||||
options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0);
|
||||
options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0);
|
||||
options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool();
|
||||
options.serverAddress = version.serverUrl.trimmed();
|
||||
options.serverAddress = build.serverUrl.trimmed();
|
||||
options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
|
||||
options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
|
||||
|
||||
@@ -682,8 +1007,8 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
|
||||
const bool started = m_launcher->launch(options, m_pendingVersionData, &error);
|
||||
|
||||
const QString profileName = profile.name;
|
||||
const QString versionName = version.name;
|
||||
const QString serverUrl = version.serverUrl;
|
||||
const QString buildName = build.name;
|
||||
const QString serverUrl = build.serverUrl;
|
||||
cancelPendingLaunch();
|
||||
|
||||
if (!started) {
|
||||
@@ -691,7 +1016,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
|
||||
return;
|
||||
}
|
||||
emit gameRunningChanged();
|
||||
emit launched(profileName, versionName, serverUrl);
|
||||
emit launched(profileName, buildName, serverUrl);
|
||||
}
|
||||
|
||||
void LauncherBackend::stopGame()
|
||||
@@ -705,9 +1030,30 @@ void LauncherBackend::failLaunch(const QString &message)
|
||||
emit launchError(message);
|
||||
}
|
||||
|
||||
bool LauncherBackend::switching() const
|
||||
{
|
||||
return m_switcher->isRunning();
|
||||
}
|
||||
|
||||
double LauncherBackend::switchProgress() const
|
||||
{
|
||||
return m_switcher->fraction();
|
||||
}
|
||||
|
||||
QString LauncherBackend::switchStage() const
|
||||
{
|
||||
return m_switcher->stage();
|
||||
}
|
||||
|
||||
QString LauncherBackend::switchStatus() const
|
||||
{
|
||||
return m_switcher->status();
|
||||
}
|
||||
|
||||
bool LauncherBackend::busy() const
|
||||
{
|
||||
return m_busy || m_installer->isRunning();
|
||||
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
|
||||
|| m_switcher->isRunning();
|
||||
}
|
||||
|
||||
void LauncherBackend::setBusy(bool busy)
|
||||
@@ -896,27 +1242,81 @@ void LauncherBackend::loadData()
|
||||
std::sort(m_profiles.begin(), m_profiles.end(),
|
||||
[](const Profile &a, const Profile &b) { return a.id < b.id; });
|
||||
|
||||
int skippedVersions = 0;
|
||||
loadFile(LauncherPaths::versionsFile(), [this, &skippedVersions](const QJsonObject &o) {
|
||||
const int id = o["id"].toInt();
|
||||
const QString name = o["name"].toString();
|
||||
const QString versionId = o.value("versionId").toString();
|
||||
// Без имени и без ID запись нерабочая: versionIdOf() вернёт пустую строку.
|
||||
if (name.trimmed().isEmpty() && versionId.trimmed().isEmpty()) {
|
||||
++skippedVersions;
|
||||
int skippedBuilds = 0;
|
||||
loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) {
|
||||
CustomBuild build;
|
||||
build.id = o["id"].toInt();
|
||||
build.name = o["name"].toString();
|
||||
build.serverUrl = o["serverUrl"].toString();
|
||||
build.minecraftVersion = o.value("minecraftVersion").toString();
|
||||
// Файл мог остаться от версии лаунчера до переименования или быть
|
||||
// поправлен руками — старый ключ тоже принимаем.
|
||||
if (build.minecraftVersion.isEmpty())
|
||||
build.minecraftVersion = o.value("versionId").toString();
|
||||
build.loader = o.value("loader").toString();
|
||||
build.loaderVersion = o.value("loaderVersion").toString();
|
||||
build.resolvedVersionId = o.value("resolvedVersionId").toString();
|
||||
|
||||
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
|
||||
// пустую строку.
|
||||
if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) {
|
||||
++skippedBuilds;
|
||||
return;
|
||||
}
|
||||
m_versions.append({id, name, o["serverUrl"].toString(), versionId});
|
||||
if (id >= m_nextVersionId)
|
||||
m_nextVersionId = id + 1;
|
||||
m_builds.append(build);
|
||||
if (build.id >= m_nextBuildId)
|
||||
m_nextBuildId = build.id + 1;
|
||||
});
|
||||
if (skippedVersions > 0) {
|
||||
m_storageIssues << tr("В %1 пропущены записи без имени и ID версии: %2")
|
||||
.arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))
|
||||
.arg(skippedVersions);
|
||||
if (skippedBuilds > 0) {
|
||||
m_storageIssues << tr("В %1 пропущены сборки без имени и версии Minecraft: %2")
|
||||
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))
|
||||
.arg(skippedBuilds);
|
||||
}
|
||||
std::sort(m_versions.begin(), m_versions.end(),
|
||||
[](const Version &a, const Version &b) { return a.id < b.id; });
|
||||
std::sort(m_builds.begin(), m_builds.end(),
|
||||
[](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; });
|
||||
}
|
||||
|
||||
// Первый запуск после переименования «версий» в «сборки»: перекладываем
|
||||
// <root>/versions.json в customBuilds.json. Старый файл не удаляем, а
|
||||
// переименовываем — он остаётся резервной копией.
|
||||
bool LauncherBackend::migrateLegacyBuildsFile()
|
||||
{
|
||||
if (QFile::exists(LauncherPaths::customBuildsFile()))
|
||||
return false;
|
||||
if (!QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
|
||||
return false;
|
||||
|
||||
// readArrayFile() сам отводит битый файл в .bak — тогда мигрировать нечего.
|
||||
const QJsonArray legacy = readArrayFile(LauncherPaths::legacyCustomBuildsFile());
|
||||
if (legacy.isEmpty() && !QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
|
||||
return false;
|
||||
|
||||
QJsonArray migrated;
|
||||
for (const auto &value : legacy) {
|
||||
const QJsonObject o = value.toObject();
|
||||
migrated.append(QJsonObject{{"id", o.value("id").toInt()},
|
||||
{"name", o.value("name").toString()},
|
||||
{"serverUrl", o.value("serverUrl").toString()},
|
||||
{"minecraftVersion", o.value("versionId").toString()},
|
||||
{"loader", QString()},
|
||||
{"loaderVersion", QString()},
|
||||
{"resolvedVersionId", QString()}});
|
||||
}
|
||||
|
||||
// Не удалось записать — молча читаем в этой сессии из старого файла.
|
||||
// Без данных пользователь не остаётся ни при каком исходе.
|
||||
if (!writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(migrated)))
|
||||
return false;
|
||||
|
||||
const QString retired = LauncherPaths::legacyCustomBuildsFile() + QStringLiteral(".migrated");
|
||||
QFile::remove(retired);
|
||||
if (!QFile::rename(LauncherPaths::legacyCustomBuildsFile(), retired)) {
|
||||
m_storageIssues << tr("Сборки перенесены в %1, но старый файл %2 удалить не удалось")
|
||||
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()),
|
||||
QDir::toNativeSeparators(
|
||||
LauncherPaths::legacyCustomBuildsFile()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
|
||||
@@ -927,8 +1327,8 @@ void LauncherBackend::ensureDataFiles()
|
||||
saveSettings();
|
||||
if (!QFile::exists(LauncherPaths::profilesFile()))
|
||||
saveProfiles();
|
||||
if (!QFile::exists(LauncherPaths::versionsFile()))
|
||||
saveVersions();
|
||||
if (!QFile::exists(LauncherPaths::customBuildsFile()))
|
||||
saveCustomBuilds();
|
||||
}
|
||||
|
||||
void LauncherBackend::saveProfiles()
|
||||
@@ -949,16 +1349,19 @@ void LauncherBackend::saveProfiles()
|
||||
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
|
||||
}
|
||||
|
||||
void LauncherBackend::saveVersions()
|
||||
void LauncherBackend::saveCustomBuilds()
|
||||
{
|
||||
QJsonArray arr;
|
||||
for (const auto &v : m_versions)
|
||||
arr.append(QJsonObject{{"id", v.id},
|
||||
{"name", v.name},
|
||||
{"serverUrl", v.serverUrl},
|
||||
{"versionId", v.versionId}});
|
||||
for (const auto &b : m_builds)
|
||||
arr.append(QJsonObject{{"id", b.id},
|
||||
{"name", b.name},
|
||||
{"serverUrl", b.serverUrl},
|
||||
{"minecraftVersion", b.minecraftVersion},
|
||||
{"loader", b.loader},
|
||||
{"loaderVersion", b.loaderVersion},
|
||||
{"resolvedVersionId", b.resolvedVersionId}});
|
||||
|
||||
writeJsonFile(LauncherPaths::versionsFile(), QJsonDocument(arr));
|
||||
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
|
||||
}
|
||||
|
||||
void LauncherBackend::loadSettings()
|
||||
@@ -972,7 +1375,8 @@ void LauncherBackend::loadSettings()
|
||||
{QStringLiteral("windowHeight"), 0},
|
||||
{QStringLiteral("fullscreen"), false},
|
||||
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
|
||||
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")}};
|
||||
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
|
||||
{QStringLiteral("activeBuildId"), 0}};
|
||||
|
||||
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
|
||||
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
|
||||
|
||||
+84
-22
@@ -15,6 +15,9 @@ class AuthService;
|
||||
class GameLauncher;
|
||||
class VersionManifestService;
|
||||
class VersionInstaller;
|
||||
class ModLoaderVersionService;
|
||||
class ModLoaderInstaller;
|
||||
class BuildSwitcher;
|
||||
|
||||
class LauncherBackend : public QObject
|
||||
{
|
||||
@@ -22,9 +25,22 @@ class LauncherBackend : public QObject
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(QStringList profileNames READ profileNames NOTIFY profilesChanged)
|
||||
Q_PROPERTY(QStringList versionNames READ versionNames NOTIFY versionsChanged)
|
||||
Q_PROPERTY(QStringList customBuildNames READ customBuildNames NOTIFY customBuildsChanged)
|
||||
|
||||
// Сборка, которую запускает кнопка игры. Хранится по id сборки, а не по
|
||||
// индексу: удаление соседней записи не должно переназначать активную.
|
||||
Q_PROPERTY(int activeBuildIndex READ activeBuildIndex WRITE setActiveBuildIndex
|
||||
NOTIFY activeBuildChanged)
|
||||
Q_PROPERTY(QString activeBuildName READ activeBuildName NOTIFY activeBuildChanged)
|
||||
Q_PROPERTY(QStringList installedVersions READ installedVersions NOTIFY installedVersionsChanged)
|
||||
Q_PROPERTY(bool gameRunning READ gameRunning NOTIFY gameRunningChanged)
|
||||
|
||||
// Идёт архивация/распаковка .minecraft при смене сборки. Отдельно от busy,
|
||||
// потому что на это время блокируется ещё и список сборок.
|
||||
Q_PROPERTY(bool switching READ switching NOTIFY switchChanged)
|
||||
Q_PROPERTY(double switchProgress READ switchProgress NOTIFY switchChanged)
|
||||
Q_PROPERTY(QString switchStage READ switchStage NOTIFY switchChanged)
|
||||
Q_PROPERTY(QString switchStatus READ switchStatus NOTIFY switchChanged)
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
|
||||
// Объединённый список для выпадающего меню версий: установленные, разделитель,
|
||||
@@ -46,9 +62,16 @@ public:
|
||||
~LauncherBackend() override;
|
||||
|
||||
QStringList profileNames() const;
|
||||
QStringList versionNames() const;
|
||||
QStringList customBuildNames() const;
|
||||
int activeBuildIndex() const;
|
||||
void setActiveBuildIndex(int index);
|
||||
QString activeBuildName() const;
|
||||
QStringList installedVersions() const;
|
||||
bool gameRunning() const;
|
||||
bool switching() const;
|
||||
double switchProgress() const;
|
||||
QString switchStage() const;
|
||||
QString switchStatus() const;
|
||||
// Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет.
|
||||
bool busy() const;
|
||||
|
||||
@@ -66,32 +89,35 @@ public:
|
||||
const QString &login,
|
||||
const QString &password,
|
||||
const QString &authType = QStringLiteral("offline"));
|
||||
Q_INVOKABLE void addVersion(const QString &name,
|
||||
const QString &serverUrl,
|
||||
const QString &versionId = QString());
|
||||
Q_INVOKABLE void addCustomBuild(const QString &name,
|
||||
const QString &serverUrl,
|
||||
const QString &minecraftVersion = QString());
|
||||
Q_INVOKABLE void updateProfile(int index,
|
||||
const QString &name,
|
||||
const QString &login,
|
||||
const QString &password,
|
||||
const QString &authType = QStringLiteral("offline"));
|
||||
Q_INVOKABLE void updateVersion(int index,
|
||||
const QString &name,
|
||||
const QString &serverUrl,
|
||||
const QString &versionId = QString());
|
||||
// Мержит только присланные ключи: name, serverUrl, minecraftVersion,
|
||||
// loader, loaderVersion, resolvedVersionId.
|
||||
Q_INVOKABLE void updateCustomBuild(int index, const QVariantMap &fields);
|
||||
Q_INVOKABLE QVariantMap profileAt(int index) const;
|
||||
Q_INVOKABLE QVariantMap versionAt(int index) const;
|
||||
Q_INVOKABLE QVariantMap customBuildAt(int index) const;
|
||||
Q_INVOKABLE void removeProfile(int index);
|
||||
Q_INVOKABLE void removeVersion(int index);
|
||||
Q_INVOKABLE void removeCustomBuild(int index);
|
||||
|
||||
// Докачать то, чего не хватает выбранной сборке. Отдельной кнопкой, потому
|
||||
// что карточка сборки сохраняет правки по ходу редактирования.
|
||||
Q_INVOKABLE void installCustomBuild(int index);
|
||||
|
||||
// Главная кнопка. Проверяет выбор профиля и версии, комплектность .minecraft,
|
||||
// при необходимости авторизуется и стартует игру.
|
||||
Q_INVOKABLE void launchGame(int profileIndex, int versionIndex);
|
||||
Q_INVOKABLE void launchGame(int profileIndex, int buildIndex);
|
||||
Q_INVOKABLE void submitTwoFactorCode(const QString &code);
|
||||
Q_INVOKABLE void cancelPendingLaunch();
|
||||
Q_INVOKABLE void stopGame();
|
||||
|
||||
// Проверка без запуска — для подсказки в интерфейсе.
|
||||
Q_INVOKABLE QStringList checkInstallation(int versionIndex) const;
|
||||
Q_INVOKABLE QStringList checkInstallation(int buildIndex) const;
|
||||
|
||||
// Вызывается при открытии выпадающего списка версий: свежий кэш — без сети.
|
||||
Q_INVOKABLE void refreshVersionCatalog(bool force = false);
|
||||
@@ -99,6 +125,20 @@ public:
|
||||
Q_INVOKABLE void installVersion(const QString &versionId);
|
||||
Q_INVOKABLE void cancelDownload();
|
||||
|
||||
// Версии модлоадеров для выбранной версии игры. Возвращает список карт
|
||||
// {version, label, recommended, stable}; несовместимых строк в нём нет —
|
||||
// отбор заложен в сам источник данных.
|
||||
Q_INVOKABLE QVariantList loaderVersions(const QString &loaderKey,
|
||||
const QString &gameVersion) const;
|
||||
Q_INVOKABLE void refreshLoaderVersions(const QString &loaderKey,
|
||||
const QString &gameVersion,
|
||||
bool force = false);
|
||||
Q_INVOKABLE bool loaderVersionsLoading(const QString &loaderKey,
|
||||
const QString &gameVersion) const;
|
||||
// Ставит модлоадер, выбранный в сборке, и записывает получившийся профиль
|
||||
// в resolvedVersionId.
|
||||
Q_INVOKABLE void installLoaderForBuild(int index);
|
||||
|
||||
Q_INVOKABLE QVariantMap settings() const;
|
||||
Q_INVOKABLE void updateSettings(const QVariantMap &values);
|
||||
Q_INVOKABLE QStringList detectedJava() const;
|
||||
@@ -108,7 +148,10 @@ public:
|
||||
|
||||
signals:
|
||||
void profilesChanged();
|
||||
void versionsChanged();
|
||||
void customBuildsChanged();
|
||||
void activeBuildChanged();
|
||||
void switchChanged();
|
||||
void loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion);
|
||||
void installedVersionsChanged();
|
||||
void settingsChanged();
|
||||
void gameRunningChanged();
|
||||
@@ -118,7 +161,7 @@ signals:
|
||||
void downloadChanged();
|
||||
|
||||
void launchProgress(const QString &message);
|
||||
void launched(const QString &profileName, const QString &versionName, const QString &serverUrl);
|
||||
void launched(const QString &profileName, const QString &buildName, const QString &serverUrl);
|
||||
void launchError(const QString &message);
|
||||
void twoFactorRequired(const QString &profileName);
|
||||
void gameOutput(const QString &line);
|
||||
@@ -132,17 +175,23 @@ private:
|
||||
QString authType = QStringLiteral("offline"); // offline | elyby
|
||||
QString clientToken, accessToken, uuid, resolvedName, userType;
|
||||
};
|
||||
struct Version
|
||||
// Пользовательская сборка: имя, сервер, версия Minecraft и, начиная со
|
||||
// второго этапа, выбранный модлоадер.
|
||||
struct CustomBuild
|
||||
{
|
||||
int id = 0;
|
||||
QString name, serverUrl, versionId;
|
||||
QString name, serverUrl;
|
||||
QString minecraftVersion; // 1.20.1
|
||||
QString loader; // "" | forge | fabric | neoforge | quilt
|
||||
QString loaderVersion; // 47.4.0 | 0.19.3
|
||||
QString resolvedVersionId; // versions/<id>, который реально запускается
|
||||
};
|
||||
|
||||
void loadData();
|
||||
void loadSettings();
|
||||
void ensureDataFiles();
|
||||
void saveProfiles();
|
||||
void saveVersions();
|
||||
void saveCustomBuilds();
|
||||
void saveSettings();
|
||||
|
||||
// Чтение файлов лаунчера. Отсутствие файла — норма (первый запуск), а вот
|
||||
@@ -155,7 +204,13 @@ private:
|
||||
bool writeJsonFile(const QString &path, const QJsonDocument &document);
|
||||
|
||||
QString gameDir() const;
|
||||
QString versionIdOf(const Version &version) const;
|
||||
// id профиля в <gameDir>/versions, который запускает эта сборка.
|
||||
QString launchVersionIdOf(const CustomBuild &build) const;
|
||||
int indexOfBuildId(int buildId) const;
|
||||
|
||||
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
||||
// после переименования. true — миграция прошла, читать нужно новый файл.
|
||||
bool migrateLegacyBuildsFile();
|
||||
|
||||
void invalidateCatalog();
|
||||
void rebuildCatalog() const;
|
||||
@@ -168,18 +223,25 @@ private:
|
||||
void continueLaunch(const AuthResult &auth);
|
||||
|
||||
QList<Profile> m_profiles;
|
||||
QList<Version> m_versions;
|
||||
QList<CustomBuild> m_builds;
|
||||
QVariantMap m_settings;
|
||||
// Проблемы хранилища, замеченные на старте: показываются одним сообщением,
|
||||
// когда интерфейс уже подключился к сигналам.
|
||||
QStringList m_storageIssues;
|
||||
int m_nextProfileId = 1;
|
||||
int m_nextVersionId = 1;
|
||||
int m_nextBuildId = 1;
|
||||
|
||||
AuthService *m_auth;
|
||||
GameLauncher *m_launcher;
|
||||
VersionManifestService *m_manifest;
|
||||
VersionInstaller *m_installer;
|
||||
ModLoaderVersionService *m_loaderMeta;
|
||||
ModLoaderInstaller *m_loaderInstaller;
|
||||
BuildSwitcher *m_switcher;
|
||||
|
||||
// Сборка, для которой сейчас ставится модлоадер: по её id находим запись,
|
||||
// когда установщик отчитается.
|
||||
int m_loaderInstallBuildId = -1;
|
||||
|
||||
// Каталог версий пересобирается только по изменениям: QML читает свойство
|
||||
// помногу раз за кадр, пока открыт список.
|
||||
@@ -190,7 +252,7 @@ private:
|
||||
|
||||
// Состояние запуска, начатого до асинхронной авторизации.
|
||||
int m_pendingProfile = -1;
|
||||
int m_pendingVersion = -1;
|
||||
int m_pendingBuild = -1;
|
||||
MinecraftVersion m_pendingVersionData;
|
||||
QString m_pendingInjectorPath;
|
||||
};
|
||||
|
||||
+23
-2
@@ -51,11 +51,21 @@ QString LauncherPaths::profilesFile()
|
||||
return rootDir() + QStringLiteral("/profiles.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::versionsFile()
|
||||
QString LauncherPaths::customBuildsFile()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/customBuilds.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::legacyCustomBuildsFile()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/versions.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::buildStorageDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/builds");
|
||||
}
|
||||
|
||||
QString LauncherPaths::runtimeDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/runtime");
|
||||
@@ -71,9 +81,20 @@ QString LauncherPaths::versionManifestFile()
|
||||
return cacheDir() + QStringLiteral("/version_manifest.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::loaderCacheFile(const QString &loaderKey)
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/loader_") + loaderKey + QStringLiteral(".json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::loaderDownloadDir()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/loaders");
|
||||
}
|
||||
|
||||
bool LauncherPaths::ensureRootExists(QString *error)
|
||||
{
|
||||
const QStringList required{rootDir(), runtimeDir(), cacheDir()};
|
||||
const QStringList required{rootDir(), runtimeDir(), cacheDir(),
|
||||
loaderDownloadDir(), buildStorageDir()};
|
||||
for (const QString &path : required) {
|
||||
if (!QDir().mkpath(path)) {
|
||||
if (error)
|
||||
|
||||
+16
-1
@@ -17,7 +17,16 @@ QString defaultMinecraftDir();
|
||||
|
||||
QString settingsFile();
|
||||
QString profilesFile();
|
||||
QString versionsFile();
|
||||
|
||||
// <root>/customBuilds.json — пользовательские сборки.
|
||||
QString customBuildsFile();
|
||||
|
||||
// <root>/versions.json — как сборки назывались до переименования. Читается
|
||||
// один раз при миграции и больше ни для чего не нужен.
|
||||
QString legacyCustomBuildsFile();
|
||||
|
||||
// <root>/builds — архивы содержимого .minecraft по одному на сборку.
|
||||
QString buildStorageDir();
|
||||
|
||||
// Сюда качается authlib-injector.
|
||||
QString runtimeDir();
|
||||
@@ -28,6 +37,12 @@ QString cacheDir();
|
||||
// Слепок манифеста версий Mojang с отметкой времени.
|
||||
QString versionManifestFile();
|
||||
|
||||
// Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt.
|
||||
QString loaderCacheFile(const QString &loaderKey);
|
||||
|
||||
// Сюда качаются installer.jar модлоадеров.
|
||||
QString loaderDownloadDir();
|
||||
|
||||
// Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед
|
||||
// каждой записью. false и текст в error, если папки нет или в неё не пишется.
|
||||
bool ensureRootExists(QString *error = nullptr);
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
#include <optional>
|
||||
|
||||
// Модлоадеры, которые лаунчер умеет ставить. Взаимоисключающи: игра
|
||||
// запускается ровно с одним профилем в <gameDir>/versions.
|
||||
enum class ModLoader { Forge, Fabric, NeoForge, Quilt };
|
||||
|
||||
// Одна сборка модлоадера под конкретную версию игры.
|
||||
struct LoaderVersionEntry
|
||||
{
|
||||
QString loaderVersion; // 47.4.0 | 0.19.3 | 21.1.248
|
||||
QString gameVersion; // 1.20.1
|
||||
QString versionId; // предсказанный versions/<id>; у Forge пусто —
|
||||
// он выясняется после работы установщика
|
||||
QUrl installerUrl; // только Forge и NeoForge
|
||||
bool recommended = false;
|
||||
bool stable = true;
|
||||
};
|
||||
|
||||
// Ключ для интерфейса, кэша на диске и поля loader в customBuilds.json.
|
||||
inline QString loaderKey(ModLoader loader)
|
||||
{
|
||||
switch (loader) {
|
||||
case ModLoader::Forge: return QStringLiteral("forge");
|
||||
case ModLoader::Fabric: return QStringLiteral("fabric");
|
||||
case ModLoader::NeoForge: return QStringLiteral("neoforge");
|
||||
case ModLoader::Quilt: return QStringLiteral("quilt");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
inline QString loaderTitle(ModLoader loader)
|
||||
{
|
||||
switch (loader) {
|
||||
case ModLoader::Forge: return QStringLiteral("Minecraft Forge");
|
||||
case ModLoader::Fabric: return QStringLiteral("Fabric Loader");
|
||||
case ModLoader::NeoForge: return QStringLiteral("NeoForge");
|
||||
case ModLoader::Quilt: return QStringLiteral("Quilt Loader");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
inline std::optional<ModLoader> loaderFromKey(const QString &key)
|
||||
{
|
||||
if (key == QLatin1String("forge")) return ModLoader::Forge;
|
||||
if (key == QLatin1String("fabric")) return ModLoader::Fabric;
|
||||
if (key == QLatin1String("neoforge")) return ModLoader::NeoForge;
|
||||
if (key == QLatin1String("quilt")) return ModLoader::Quilt;
|
||||
return std::nullopt;
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
#include "modloaderinstaller.h"
|
||||
|
||||
#include "javalocator.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "minecraftversion.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "versioninstaller.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QProcess>
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kTransferTimeoutMs = 30000;
|
||||
// Установщик Forge после распаковки ещё патчит клиентский jar: на медленной
|
||||
// машине это несколько минут. Обрываем только совсем зависший процесс.
|
||||
constexpr int kInstallerTimeoutMs = 15 * 60 * 1000;
|
||||
constexpr int kLogTailLines = 12;
|
||||
|
||||
const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/");
|
||||
const QString kQuiltProfile = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/");
|
||||
|
||||
// Тот же запрет, что у VersionInstaller: id версии становится именем каталога.
|
||||
bool isSafeSegment(const QString &value)
|
||||
{
|
||||
if (value.isEmpty() || value.contains(u'/') || value.contains(u'\\') || value.contains(u':'))
|
||||
return false;
|
||||
return value != QLatin1String(".") && value != QLatin1String("..");
|
||||
}
|
||||
|
||||
QString versionJsonPath(const QString &gameDir, const QString &id)
|
||||
{
|
||||
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ModLoaderInstaller::ModLoaderInstaller(ModLoaderVersionService *meta,
|
||||
VersionInstaller *versionInstaller,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_meta(meta)
|
||||
, m_versionInstaller(versionInstaller)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
// Последний шаг обоих путей одинаков: VersionInstaller дотягивает всё, чего
|
||||
// не хватает получившемуся профилю.
|
||||
connect(m_versionInstaller, &VersionInstaller::finished, this, [this](const QString &id) {
|
||||
if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId)
|
||||
return;
|
||||
const QString versionId = m_awaitingVersionId;
|
||||
const QString loader = loaderKey(m_loader);
|
||||
const QString game = m_gameVersion;
|
||||
const QString version = m_loaderVersion;
|
||||
reset();
|
||||
emit finished(loader, game, version, versionId);
|
||||
});
|
||||
connect(m_versionInstaller, &VersionInstaller::failed, this,
|
||||
[this](const QString &id, const QString &message) {
|
||||
if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId)
|
||||
return;
|
||||
fail(message);
|
||||
});
|
||||
connect(m_versionInstaller, &VersionInstaller::canceled, this, [this](const QString &id) {
|
||||
if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId)
|
||||
return;
|
||||
const QString label = m_label;
|
||||
reset();
|
||||
emit canceled(label);
|
||||
});
|
||||
}
|
||||
|
||||
ModLoaderInstaller::~ModLoaderInstaller()
|
||||
{
|
||||
if (m_process && m_process->state() != QProcess::NotRunning) {
|
||||
m_process->kill();
|
||||
m_process->waitForFinished(2000);
|
||||
}
|
||||
}
|
||||
|
||||
double ModLoaderInstaller::fraction() const
|
||||
{
|
||||
if (m_bytesTotal <= 0)
|
||||
return -1.0; // панель нарисует «…»
|
||||
return double(m_bytesDone) / double(m_bytesTotal);
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::install(const QString &gameDir,
|
||||
ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
const QString &loaderVersion,
|
||||
const QString &javaPreference)
|
||||
{
|
||||
if (m_running) {
|
||||
emit failed(loaderTitle(loader), tr("Установка модлоадера уже идёт"));
|
||||
return;
|
||||
}
|
||||
if (gameVersion.trimmed().isEmpty() || loaderVersion.trimmed().isEmpty()) {
|
||||
emit failed(loaderTitle(loader), tr("Не выбрана версия %1").arg(loaderTitle(loader)));
|
||||
return;
|
||||
}
|
||||
|
||||
m_gameDir = gameDir;
|
||||
m_loader = loader;
|
||||
m_gameVersion = gameVersion.trimmed();
|
||||
m_loaderVersion = loaderVersion.trimmed();
|
||||
m_javaPreference = javaPreference;
|
||||
m_label = QStringLiteral("%1 %2").arg(loaderTitle(loader), m_loaderVersion);
|
||||
m_installerLog.clear();
|
||||
m_bytesDone = 0;
|
||||
m_bytesTotal = 0;
|
||||
m_currentFile.clear();
|
||||
m_canceling = false;
|
||||
m_running = true;
|
||||
|
||||
emit started(m_label);
|
||||
setStage(tr("Подготовка %1").arg(loaderTitle(loader)));
|
||||
|
||||
// Список версий нужен и для ссылки на installer.jar, и для точного id
|
||||
// профиля. После перезапуска лаунчера он ещё не прочитан с диска, поэтому
|
||||
// спрашиваем его до всего остального: попадание в кэш обойдётся без сети.
|
||||
m_meta->ensureLoaded(loader, m_gameVersion, [this, loader](bool ok, const QString &warning) {
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
if (!ok) {
|
||||
fail(warning.isEmpty() ? tr("Список версий %1 недоступен").arg(loaderTitle(loader))
|
||||
: warning);
|
||||
return;
|
||||
}
|
||||
if (loader == ModLoader::Fabric || loader == ModLoader::Quilt)
|
||||
beginFabricLike();
|
||||
else
|
||||
beginInstallerJar();
|
||||
});
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::cancel()
|
||||
{
|
||||
if (!m_running)
|
||||
return;
|
||||
m_canceling = true;
|
||||
|
||||
if (m_reply) {
|
||||
m_reply->abort();
|
||||
m_reply = nullptr;
|
||||
}
|
||||
if (m_process && m_process->state() != QProcess::NotRunning)
|
||||
m_process->kill();
|
||||
if (!m_awaitingVersionId.isEmpty()) {
|
||||
m_versionInstaller->cancel();
|
||||
return; // финал придёт из VersionInstaller::canceled
|
||||
}
|
||||
|
||||
const QString label = m_label;
|
||||
reset();
|
||||
emit canceled(label);
|
||||
}
|
||||
|
||||
// ── Fabric и Quilt ─────────────────────────────────────────────────────────
|
||||
|
||||
void ModLoaderInstaller::beginFabricLike()
|
||||
{
|
||||
const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion);
|
||||
const QString expectedId =
|
||||
entry ? entry->versionId
|
||||
: (m_loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-")
|
||||
: QStringLiteral("quilt-loader-"))
|
||||
+ m_loaderVersion + u'-' + m_gameVersion;
|
||||
|
||||
if (!isSafeSegment(expectedId)) {
|
||||
fail(tr("Недопустимый идентификатор версии «%1»").arg(expectedId));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString base = m_loader == ModLoader::Fabric ? kFabricProfile : kQuiltProfile;
|
||||
const QUrl url(base + m_gameVersion + u'/' + m_loaderVersion + QStringLiteral("/profile/json"));
|
||||
|
||||
setStage(tr("Описание версии %1").arg(loaderTitle(m_loader)));
|
||||
m_currentFile = expectedId + QStringLiteral(".json");
|
||||
emit progressChanged();
|
||||
|
||||
QNetworkRequest request{url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
m_reply = m_network->get(request);
|
||||
connect(m_reply, &QNetworkReply::finished, this, [this, expectedId] {
|
||||
QNetworkReply *reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
fail(tr("Не удалось получить описание %1: %2")
|
||||
.arg(loaderTitle(m_loader), reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray payload = reply->readAll();
|
||||
const QJsonObject root = QJsonDocument::fromJson(payload).object();
|
||||
const QString id = root.value(QStringLiteral("id")).toString();
|
||||
const QString inherits = root.value(QStringLiteral("inheritsFrom")).toString();
|
||||
|
||||
// Описание приходит из сети и превращается в путь на диске — проверяем
|
||||
// и id, и то, что версия действительно наследует выбранную ванильную.
|
||||
if (id.isEmpty() || !isSafeSegment(id)) {
|
||||
fail(tr("Описание %1 испорчено: недопустимый идентификатор версии")
|
||||
.arg(loaderTitle(m_loader)));
|
||||
return;
|
||||
}
|
||||
if (inherits != m_gameVersion) {
|
||||
fail(tr("Описание %1 рассчитано на Minecraft %2, а не %3")
|
||||
.arg(loaderTitle(m_loader), inherits, m_gameVersion));
|
||||
return;
|
||||
}
|
||||
if (id != expectedId)
|
||||
emit log(tr("Идентификатор версии: %1").arg(id));
|
||||
|
||||
const QString directory = m_gameDir + QStringLiteral("/versions/") + id;
|
||||
if (!QDir().mkpath(directory)) {
|
||||
fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory)));
|
||||
return;
|
||||
}
|
||||
|
||||
QSaveFile file(versionJsonPath(m_gameDir, id));
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()
|
||||
|| !file.commit()) {
|
||||
file.cancelWriting();
|
||||
fail(tr("Не удалось сохранить описание версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
|
||||
handOffToVersionInstaller(id);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Forge и NeoForge ───────────────────────────────────────────────────────
|
||||
|
||||
void ModLoaderInstaller::beginInstallerJar()
|
||||
{
|
||||
const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion);
|
||||
if (!entry || !entry->installerUrl.isValid()) {
|
||||
fail(tr("%1 %2 не подходит к Minecraft %3 — выберите версию из списка")
|
||||
.arg(loaderTitle(m_loader), m_loaderVersion, m_gameVersion));
|
||||
return;
|
||||
}
|
||||
if (entry->installerUrl.scheme() != QLatin1String("https")) {
|
||||
fail(tr("Установщик %1 доступен только по недопустимой ссылке").arg(loaderTitle(m_loader)));
|
||||
return;
|
||||
}
|
||||
|
||||
const QUrl installerUrl = entry->installerUrl;
|
||||
// Установщик патчит versions/<mc>/<mc>.jar — без ванильной версии он не отработает.
|
||||
ensureBaseVersion([this, installerUrl] {
|
||||
downloadInstaller(installerUrl, [this](const QString &path) { runInstaller(path); });
|
||||
});
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::ensureBaseVersion(std::function<void()> next)
|
||||
{
|
||||
if (QFile::exists(versionJsonPath(m_gameDir, m_gameVersion))
|
||||
&& VersionLoader::installedVersions(m_gameDir).contains(m_gameVersion)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
setStage(tr("Установка Minecraft %1").arg(m_gameVersion));
|
||||
m_currentFile = m_gameVersion;
|
||||
emit progressChanged();
|
||||
|
||||
const QString base = m_gameVersion;
|
||||
auto guard = std::make_shared<QMetaObject::Connection>();
|
||||
auto failGuard = std::make_shared<QMetaObject::Connection>();
|
||||
|
||||
*guard = connect(m_versionInstaller, &VersionInstaller::finished, this,
|
||||
[this, base, guard, failGuard, next](const QString &id) {
|
||||
if (id != base)
|
||||
return;
|
||||
disconnect(*guard);
|
||||
disconnect(*failGuard);
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
next();
|
||||
});
|
||||
*failGuard = connect(m_versionInstaller, &VersionInstaller::failed, this,
|
||||
[this, base, guard, failGuard](const QString &id, const QString &message) {
|
||||
if (id != base)
|
||||
return;
|
||||
disconnect(*guard);
|
||||
disconnect(*failGuard);
|
||||
fail(message);
|
||||
});
|
||||
|
||||
m_versionInstaller->install(m_gameDir, base);
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::downloadInstaller(const QUrl &url, std::function<void(const QString &)> next)
|
||||
{
|
||||
if (!LauncherPaths::ensureRootExists()) {
|
||||
fail(tr("Нет доступа к папке лаунчера"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString target = LauncherPaths::loaderDownloadDir() + u'/' + loaderKey(m_loader) + u'-'
|
||||
+ QString(m_loaderVersion).replace(u'/', u'_')
|
||||
+ QStringLiteral("-installer.jar");
|
||||
|
||||
setStage(tr("Загрузка установщика %1").arg(loaderTitle(m_loader)));
|
||||
m_currentFile = QFileInfo(target).fileName();
|
||||
emit progressChanged();
|
||||
|
||||
QNetworkRequest request{url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
m_reply = m_network->get(request);
|
||||
connect(m_reply, &QNetworkReply::downloadProgress, this,
|
||||
[this](qint64 received, qint64 total) {
|
||||
m_bytesDone = received;
|
||||
m_bytesTotal = total;
|
||||
emit progressChanged();
|
||||
});
|
||||
connect(m_reply, &QNetworkReply::finished, this, [this, target, next] {
|
||||
QNetworkReply *reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
fail(tr("Не удалось скачать установщик %1: %2")
|
||||
.arg(loaderTitle(m_loader), reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray payload = reply->readAll();
|
||||
QSaveFile file(target);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()
|
||||
|| !file.commit()) {
|
||||
file.cancelWriting();
|
||||
fail(tr("Не удалось сохранить установщик %1").arg(loaderTitle(m_loader)));
|
||||
return;
|
||||
}
|
||||
next(target);
|
||||
});
|
||||
}
|
||||
|
||||
// Без launcher_profiles.json установщик Forge и NeoForge завершается с
|
||||
// сообщением про испорченный профиль лаунчера. Свои профили мы там не держим,
|
||||
// поэтому достаточно заглушки.
|
||||
bool ModLoaderInstaller::writeLauncherProfilesStub()
|
||||
{
|
||||
const QString path = m_gameDir + QStringLiteral("/launcher_profiles.json");
|
||||
if (QFile::exists(path))
|
||||
return true;
|
||||
if (!QDir().mkpath(m_gameDir))
|
||||
return false;
|
||||
|
||||
const QJsonObject stub{
|
||||
{QStringLiteral("profiles"), QJsonObject{}},
|
||||
{QStringLiteral("selectedProfile"), QString()},
|
||||
{QStringLiteral("clientToken"), QString()},
|
||||
{QStringLiteral("authenticationDatabase"), QJsonObject{}},
|
||||
{QStringLiteral("launcherVersion"),
|
||||
QJsonObject{{QStringLiteral("name"), QString()},
|
||||
{QStringLiteral("format"), 21},
|
||||
{QStringLiteral("profilesFormat"), 2}}}};
|
||||
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return false;
|
||||
const QByteArray payload = QJsonDocument(stub).toJson(QJsonDocument::Indented);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return false;
|
||||
}
|
||||
return file.commit();
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::runInstaller(const QString &installerPath)
|
||||
{
|
||||
if (!writeLauncherProfilesStub()) {
|
||||
fail(tr("Не удалось создать launcher_profiles.json — без него установщик %1 не работает")
|
||||
.arg(loaderTitle(m_loader)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Постобработка установщика гоняет те же классы, что и сама игра, поэтому
|
||||
// требования к Java берём у ванильной версии.
|
||||
QString loadError;
|
||||
const MinecraftVersion base = VersionLoader::load(m_gameDir, m_gameVersion, {}, &loadError);
|
||||
const int requiredMajor = base.isValid() ? base.javaMajor : 8;
|
||||
|
||||
QString javaError;
|
||||
const QString java = JavaLocator::select(m_gameDir, requiredMajor, m_javaPreference, &javaError);
|
||||
if (java.isEmpty()) {
|
||||
fail(javaError.isEmpty() ? tr("Java не найдена — установщик %1 запустить нечем")
|
||||
.arg(loaderTitle(m_loader))
|
||||
: javaError);
|
||||
return;
|
||||
}
|
||||
|
||||
m_versionsBefore = VersionLoader::installedVersions(m_gameDir);
|
||||
setStage(tr("Установка %1").arg(loaderTitle(m_loader)));
|
||||
m_currentFile = tr("работает установщик, это может занять несколько минут");
|
||||
// Прогресс установщика неизвестен: панель покажет «…».
|
||||
m_bytesDone = 0;
|
||||
m_bytesTotal = 0;
|
||||
emit progressChanged();
|
||||
|
||||
m_process = new QProcess(this);
|
||||
m_process->setProgram(java);
|
||||
m_process->setArguments({QStringLiteral("-jar"), installerPath,
|
||||
QStringLiteral("--installClient"), QDir::toNativeSeparators(m_gameDir)});
|
||||
m_process->setWorkingDirectory(LauncherPaths::loaderDownloadDir());
|
||||
m_process->setProcessChannelMode(QProcess::MergedChannels);
|
||||
|
||||
connect(m_process, &QProcess::readyReadStandardOutput, this, [this] {
|
||||
const QStringList lines = QString::fromLocal8Bit(m_process->readAllStandardOutput())
|
||||
.split(u'\n', Qt::SkipEmptyParts);
|
||||
for (const QString &line : lines) {
|
||||
m_installerLog << line.trimmed();
|
||||
emit log(line.trimmed());
|
||||
}
|
||||
// Хвоста хватает, чтобы показать причину отказа; весь лог не нужен.
|
||||
while (m_installerLog.size() > kLogTailLines)
|
||||
m_installerLog.removeFirst();
|
||||
});
|
||||
connect(m_process, &QProcess::finished, this,
|
||||
[this](int exitCode, QProcess::ExitStatus status) {
|
||||
handleInstallerFinished(exitCode, status == QProcess::CrashExit);
|
||||
});
|
||||
connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
|
||||
if (m_canceling || error == QProcess::Crashed)
|
||||
return; // Crashed придёт ещё и в finished
|
||||
fail(tr("Не удалось запустить установщик %1: %2")
|
||||
.arg(loaderTitle(m_loader), m_process->errorString()));
|
||||
});
|
||||
|
||||
m_process->start();
|
||||
if (!m_process->waitForStarted(kTransferTimeoutMs)) {
|
||||
fail(tr("Установщик %1 не запустился: %2")
|
||||
.arg(loaderTitle(m_loader), m_process->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Совсем зависший установщик не должен блокировать лаунчер навсегда.
|
||||
QTimer::singleShot(kInstallerTimeoutMs, this, [this] {
|
||||
if (m_process && m_process->state() != QProcess::NotRunning) {
|
||||
m_process->kill();
|
||||
fail(tr("Установщик %1 не завершился за отведённое время").arg(loaderTitle(m_loader)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed)
|
||||
{
|
||||
if (m_process) {
|
||||
m_process->deleteLater();
|
||||
m_process = nullptr;
|
||||
}
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
|
||||
if (crashed || exitCode != 0) {
|
||||
fail(tr("Установщик %1 завершился с ошибкой (код %2).\n%3")
|
||||
.arg(loaderTitle(m_loader))
|
||||
.arg(exitCode)
|
||||
.arg(m_installerLog.join(u'\n')));
|
||||
return;
|
||||
}
|
||||
|
||||
// Какой профиль появился — выясняем разницей: id у Forge разные по эпохам
|
||||
// (1.20.1-forge-47.4.0, 1.12.2-forge-14.23.5.2859, neoforge-21.1.66).
|
||||
const QStringList after = VersionLoader::installedVersions(m_gameDir);
|
||||
QStringList added;
|
||||
for (const QString &id : after) {
|
||||
if (!m_versionsBefore.contains(id))
|
||||
added << id;
|
||||
}
|
||||
|
||||
if (added.isEmpty()) {
|
||||
fail(tr("Установщик %1 отработал, но новая версия в %2 не появилась.\n%3")
|
||||
.arg(loaderTitle(m_loader),
|
||||
QDir::toNativeSeparators(m_gameDir + QStringLiteral("/versions")),
|
||||
m_installerLog.join(u'\n')));
|
||||
return;
|
||||
}
|
||||
if (added.size() > 1) {
|
||||
// Берём тот, где встречается версия лоадера: остальные — попутно
|
||||
// доустановленные зависимости.
|
||||
const auto match = std::find_if(added.cbegin(), added.cend(), [this](const QString &id) {
|
||||
return id.contains(m_loaderVersion);
|
||||
});
|
||||
added = {match != added.cend() ? *match : added.first()};
|
||||
}
|
||||
|
||||
handOffToVersionInstaller(added.first());
|
||||
}
|
||||
|
||||
// ── Общий хвост ────────────────────────────────────────────────────────────
|
||||
|
||||
void ModLoaderInstaller::handOffToVersionInstaller(const QString &versionId)
|
||||
{
|
||||
m_awaitingVersionId = versionId;
|
||||
setStage(tr("Загрузка файлов версии"));
|
||||
m_currentFile = versionId;
|
||||
emit progressChanged();
|
||||
m_versionInstaller->install(m_gameDir, versionId);
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::setStage(const QString &stage)
|
||||
{
|
||||
m_stage = stage;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::reset()
|
||||
{
|
||||
m_running = false;
|
||||
m_canceling = false;
|
||||
m_awaitingVersionId.clear();
|
||||
m_stage.clear();
|
||||
m_currentFile.clear();
|
||||
m_bytesDone = 0;
|
||||
m_bytesTotal = 0;
|
||||
m_versionsBefore.clear();
|
||||
if (m_process) {
|
||||
m_process->deleteLater();
|
||||
m_process = nullptr;
|
||||
}
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
void ModLoaderInstaller::fail(const QString &message)
|
||||
{
|
||||
const QString label = m_label;
|
||||
reset();
|
||||
emit failed(label, message);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#pragma once
|
||||
|
||||
#include "modloader.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
class QProcess;
|
||||
class ModLoaderVersionService;
|
||||
class VersionInstaller;
|
||||
|
||||
// Установка модлоадера в .minecraft. Два разных пути под одним фасадом:
|
||||
//
|
||||
// * Fabric и Quilt отдают готовое описание версии — кладём его в
|
||||
// versions/<id>/<id>.json и передаём дальше VersionInstaller, который по
|
||||
// inheritsFrom сам поставит ванильную версию и библиотеки лоадера.
|
||||
//
|
||||
// * Forge и NeoForge так не умеют: их установка — это патч клиентского jar,
|
||||
// поэтому запускаем официальный installer.jar найденной Java в headless-режиме
|
||||
// и смотрим, какой профиль появился в versions.
|
||||
//
|
||||
// Набор геттеров повторяет VersionInstaller: панель загрузки в интерфейсе
|
||||
// читает их одинаково, независимо от того, кто сейчас работает.
|
||||
class ModLoaderInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ModLoaderInstaller(ModLoaderVersionService *meta,
|
||||
VersionInstaller *versionInstaller,
|
||||
QObject *parent = nullptr);
|
||||
~ModLoaderInstaller() override;
|
||||
|
||||
bool isRunning() const { return m_running; }
|
||||
QString label() const { return m_label; }
|
||||
QString stage() const { return m_stage; }
|
||||
QString currentFile() const { return m_currentFile; }
|
||||
|
||||
qint64 bytesDone() const { return m_bytesDone; }
|
||||
qint64 bytesTotal() const { return m_bytesTotal; }
|
||||
double fraction() const; // 0..1, либо -1 пока итог неизвестен
|
||||
|
||||
void install(const QString &gameDir,
|
||||
ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
const QString &loaderVersion,
|
||||
const QString &javaPreference);
|
||||
void cancel();
|
||||
|
||||
signals:
|
||||
void started(const QString &label);
|
||||
void progressChanged();
|
||||
void finished(const QString &loaderKey,
|
||||
const QString &gameVersion,
|
||||
const QString &loaderVersion,
|
||||
const QString &producedVersionId);
|
||||
void failed(const QString &label, const QString &message);
|
||||
void canceled(const QString &label);
|
||||
void log(const QString &line);
|
||||
|
||||
private:
|
||||
void beginFabricLike();
|
||||
void beginInstallerJar();
|
||||
void ensureBaseVersion(std::function<void()> next);
|
||||
void downloadInstaller(const QUrl &url, std::function<void(const QString &)> next);
|
||||
bool writeLauncherProfilesStub();
|
||||
void runInstaller(const QString &installerPath);
|
||||
void handleInstallerFinished(int exitCode, bool crashed);
|
||||
|
||||
void handOffToVersionInstaller(const QString &versionId);
|
||||
void setStage(const QString &stage);
|
||||
void reset();
|
||||
void fail(const QString &message);
|
||||
|
||||
ModLoaderVersionService *m_meta;
|
||||
VersionInstaller *m_versionInstaller;
|
||||
QNetworkAccessManager *m_network;
|
||||
QNetworkReply *m_reply = nullptr;
|
||||
QProcess *m_process = nullptr;
|
||||
|
||||
QString m_gameDir;
|
||||
ModLoader m_loader = ModLoader::Fabric;
|
||||
QString m_gameVersion;
|
||||
QString m_loaderVersion;
|
||||
QString m_javaPreference;
|
||||
QString m_label;
|
||||
|
||||
QString m_stage;
|
||||
QString m_currentFile;
|
||||
qint64 m_bytesDone = 0;
|
||||
qint64 m_bytesTotal = 0;
|
||||
|
||||
// Профили, которые были в versions до запуска installer.jar: новый ищем
|
||||
// разницей, а не угадыванием строки — id у Forge разные по эпохам.
|
||||
QStringList m_versionsBefore;
|
||||
QStringList m_installerLog;
|
||||
|
||||
// Ждём, пока VersionInstaller докачает то, что установщик не положил.
|
||||
QString m_awaitingVersionId;
|
||||
|
||||
bool m_running = false;
|
||||
bool m_canceling = false;
|
||||
};
|
||||
@@ -0,0 +1,520 @@
|
||||
#include "modloaderversionservice.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <QXmlStreamReader>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
// Списки лоадеров меняются реже манифеста Mojang, но шесть часов держим те же:
|
||||
// одна цифра на весь лаунчер понятнее двух разных.
|
||||
constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60;
|
||||
constexpr int kTransferTimeoutMs = 20000;
|
||||
|
||||
const QString kFabricMeta = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/");
|
||||
const QString kQuiltMeta = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/");
|
||||
|
||||
const QString kForgeMaven = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/");
|
||||
const QString kForgePromotions =
|
||||
QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
|
||||
|
||||
const QString kNeoForgeMaven = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/");
|
||||
// Под 1.20.1 NeoForge выходил ещё под старым именем артефакта.
|
||||
const QString kNeoForgeLegacyMaven =
|
||||
QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/");
|
||||
|
||||
bool looksUnstable(const QString &version)
|
||||
{
|
||||
const QString lower = version.toLower();
|
||||
return lower.contains(QLatin1String("alpha")) || lower.contains(QLatin1String("beta"))
|
||||
|| lower.contains(QLatin1String("-rc")) || lower.contains(QLatin1String("-pre"))
|
||||
|| lower.contains(u'+');
|
||||
}
|
||||
|
||||
// Сравнение версий по числовым компонентам: «47.4.23» новее «47.4.2», хотя как
|
||||
// строка стоит раньше. Нечисловые хвосты (alpha, +snapshot) сравниваются как есть.
|
||||
bool isNewer(const QString &a, const QString &b)
|
||||
{
|
||||
const QStringList pa = a.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts);
|
||||
const QStringList pb = b.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts);
|
||||
for (int i = 0; i < std::max(pa.size(), pb.size()); ++i) {
|
||||
const QString sa = i < pa.size() ? pa.at(i) : QString();
|
||||
const QString sb = i < pb.size() ? pb.at(i) : QString();
|
||||
bool okA = false, okB = false;
|
||||
const int na = sa.toInt(&okA);
|
||||
const int nb = sb.toInt(&okB);
|
||||
if (okA && okB) {
|
||||
if (na != nb)
|
||||
return na > nb;
|
||||
continue;
|
||||
}
|
||||
// Число всегда «новее» суффикса: 26.1.0.0 идёт впереди 26.1.0.0-alpha.1.
|
||||
if (okA != okB)
|
||||
return okA;
|
||||
if (sa != sb)
|
||||
return sa > sb;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// «21.1.248» -> «1.21.1», «21.0.167» -> «1.21», «26.1.2.97» -> «26.1.2»,
|
||||
// «26.2.0.68» -> «26.2». До 1.20.2 Minecraft нумеровался как 1.x.y, с 26.1 —
|
||||
// как x.y, поэтому у NeoForge две схемы: три компонента и четыре.
|
||||
QString neoForgeGameVersion(const QString &neoVersion)
|
||||
{
|
||||
const QString head = neoVersion.section(u'-', 0, 0).section(u'+', 0, 0);
|
||||
const QStringList parts = head.split(u'.');
|
||||
if (parts.size() == 3)
|
||||
return parts.at(1) == QLatin1String("0") ? QStringLiteral("1.%1").arg(parts.at(0))
|
||||
: QStringLiteral("1.%1.%2").arg(parts.at(0), parts.at(1));
|
||||
if (parts.size() >= 4)
|
||||
return parts.at(2) == QLatin1String("0")
|
||||
? QStringLiteral("%1.%2").arg(parts.at(0), parts.at(1))
|
||||
: QStringLiteral("%1.%2.%3").arg(parts.at(0), parts.at(1), parts.at(2));
|
||||
return {};
|
||||
}
|
||||
|
||||
QJsonObject entryToJson(const LoaderVersionEntry &entry)
|
||||
{
|
||||
return {{QStringLiteral("loaderVersion"), entry.loaderVersion},
|
||||
{QStringLiteral("gameVersion"), entry.gameVersion},
|
||||
{QStringLiteral("versionId"), entry.versionId},
|
||||
{QStringLiteral("installerUrl"), entry.installerUrl.toString()},
|
||||
{QStringLiteral("recommended"), entry.recommended},
|
||||
{QStringLiteral("stable"), entry.stable}};
|
||||
}
|
||||
|
||||
LoaderVersionEntry entryFromJson(const QJsonObject &o)
|
||||
{
|
||||
LoaderVersionEntry entry;
|
||||
entry.loaderVersion = o.value(QStringLiteral("loaderVersion")).toString();
|
||||
entry.gameVersion = o.value(QStringLiteral("gameVersion")).toString();
|
||||
entry.versionId = o.value(QStringLiteral("versionId")).toString();
|
||||
entry.installerUrl = QUrl(o.value(QStringLiteral("installerUrl")).toString());
|
||||
entry.recommended = o.value(QStringLiteral("recommended")).toBool();
|
||||
entry.stable = o.value(QStringLiteral("stable")).toBool(true);
|
||||
return entry;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ModLoaderVersionService::ModLoaderVersionService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
}
|
||||
|
||||
bool ModLoaderVersionService::isPerGameVersion(ModLoader loader)
|
||||
{
|
||||
return loader == ModLoader::Fabric || loader == ModLoader::Quilt;
|
||||
}
|
||||
|
||||
ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader)
|
||||
{
|
||||
return m_buckets[static_cast<int>(loader)];
|
||||
}
|
||||
|
||||
const ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader) const
|
||||
{
|
||||
static const Bucket empty;
|
||||
const auto it = m_buckets.constFind(static_cast<int>(loader));
|
||||
return it == m_buckets.constEnd() ? empty : it.value();
|
||||
}
|
||||
|
||||
QString ModLoaderVersionService::requestKey(ModLoader loader, const QString &gameVersion)
|
||||
{
|
||||
// У Forge и NeoForge один запрос покрывает все версии игры — они делят
|
||||
// очередь колбэков, чтобы не качать maven-metadata по разу на версию.
|
||||
return isPerGameVersion(loader) ? loaderKey(loader) + u'/' + gameVersion : loaderKey(loader);
|
||||
}
|
||||
|
||||
bool ModLoaderVersionService::isFresh(ModLoader loader, const QString &gameVersion) const
|
||||
{
|
||||
const Bucket &b = bucket(loader);
|
||||
const QDateTime fetched = isPerGameVersion(loader)
|
||||
? b.fetchedAtByGame.value(gameVersion)
|
||||
: b.fetchedAt;
|
||||
return fetched.isValid() && fetched.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
|
||||
}
|
||||
|
||||
QList<LoaderVersionEntry> ModLoaderVersionService::versions(ModLoader loader,
|
||||
const QString &gameVersion) const
|
||||
{
|
||||
return bucket(loader).byGame.value(gameVersion);
|
||||
}
|
||||
|
||||
bool ModLoaderVersionService::isRefreshing(ModLoader loader, const QString &gameVersion) const
|
||||
{
|
||||
return m_inFlight.contains(requestKey(loader, gameVersion));
|
||||
}
|
||||
|
||||
std::optional<LoaderVersionEntry> ModLoaderVersionService::find(ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
const QString &loaderVersion) const
|
||||
{
|
||||
const QList<LoaderVersionEntry> list = versions(loader, gameVersion);
|
||||
for (const LoaderVersionEntry &entry : list) {
|
||||
if (entry.loaderVersion == loaderVersion)
|
||||
return entry;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void ModLoaderVersionService::ensureLoaded(ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
Callback callback,
|
||||
bool forceRefresh)
|
||||
{
|
||||
if (gameVersion.trimmed().isEmpty()) {
|
||||
if (callback)
|
||||
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
|
||||
return;
|
||||
}
|
||||
|
||||
Bucket &b = bucket(loader);
|
||||
if (!b.cacheRead) {
|
||||
b.cacheRead = true;
|
||||
if (readCache(loader))
|
||||
emit versionsChanged(loaderKey(loader), gameVersion);
|
||||
}
|
||||
|
||||
if (!forceRefresh && isFresh(loader, gameVersion)) {
|
||||
// Колбэк, который то синхронный, то нет, ломает вызывающий код —
|
||||
// на попадании в кэш отвечаем через событийный цикл.
|
||||
if (callback)
|
||||
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
|
||||
return;
|
||||
}
|
||||
|
||||
const QString key = requestKey(loader, gameVersion);
|
||||
if (callback)
|
||||
m_pending[key] << std::move(callback);
|
||||
if (m_inFlight.contains(key))
|
||||
return;
|
||||
startRefresh(loader, gameVersion);
|
||||
}
|
||||
|
||||
void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &gameVersion)
|
||||
{
|
||||
const QString key = requestKey(loader, gameVersion);
|
||||
m_inFlight.insert(key);
|
||||
emit refreshingChanged();
|
||||
|
||||
const auto request = [](const QUrl &url) {
|
||||
QNetworkRequest req{url};
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
req.setTransferTimeout(kTransferTimeoutMs);
|
||||
return req;
|
||||
};
|
||||
|
||||
if (isPerGameVersion(loader)) {
|
||||
const QString base = loader == ModLoader::Fabric ? kFabricMeta : kQuiltMeta;
|
||||
QNetworkReply *reply = m_network->get(request(QUrl(base + gameVersion)));
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, loader, gameVersion] {
|
||||
reply->deleteLater();
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
finishRefresh(loader, gameVersion, false,
|
||||
tr("Не удалось получить список версий %1: %2")
|
||||
.arg(loaderTitle(loader), reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray payload = reply->readAll();
|
||||
// Пустой список — законный ответ: этот лоадер не поддерживает такую
|
||||
// версию игры. Отличаем его от испорченного ответа по самому JSON.
|
||||
const QJsonDocument document = QJsonDocument::fromJson(payload);
|
||||
if (!document.isArray()) {
|
||||
finishRefresh(loader, gameVersion, false,
|
||||
tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader)));
|
||||
return;
|
||||
}
|
||||
|
||||
Bucket &b = bucket(loader);
|
||||
b.byGame.insert(gameVersion, parseFabricLike(payload, loader, gameVersion));
|
||||
b.fetchedAtByGame.insert(gameVersion, QDateTime::currentDateTimeUtc());
|
||||
writeCache(loader);
|
||||
emit versionsChanged(loaderKey(loader), gameVersion);
|
||||
finishRefresh(loader, gameVersion, true, {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Forge и NeoForge: основной maven-metadata плюс вспомогательный запрос
|
||||
// (рекомендованные сборки у Forge, старая схема 1.20.1 у NeoForge).
|
||||
// Считаем оба и сводим результат, когда придёт последний.
|
||||
struct Pending
|
||||
{
|
||||
int outstanding = 2;
|
||||
QHash<QString, QList<LoaderVersionEntry>> merged;
|
||||
QHash<QString, QString> recommended; // версия игры -> версия лоадера
|
||||
bool primaryOk = false;
|
||||
QString warning;
|
||||
};
|
||||
auto state = std::make_shared<Pending>();
|
||||
|
||||
const auto settle = [this, state, loader, gameVersion] {
|
||||
if (--state->outstanding > 0)
|
||||
return;
|
||||
if (!state->primaryOk) {
|
||||
finishRefresh(loader, gameVersion, false, state->warning);
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
|
||||
const QString promoted = state->recommended.value(it.key());
|
||||
if (promoted.isEmpty())
|
||||
continue;
|
||||
for (LoaderVersionEntry &entry : it.value())
|
||||
entry.recommended = entry.loaderVersion == promoted;
|
||||
}
|
||||
|
||||
Bucket &b = bucket(loader);
|
||||
b.byGame = state->merged;
|
||||
b.fetchedAt = QDateTime::currentDateTimeUtc();
|
||||
writeCache(loader);
|
||||
emit versionsChanged(loaderKey(loader), gameVersion);
|
||||
finishRefresh(loader, gameVersion, true, {});
|
||||
};
|
||||
|
||||
const QString primaryUrl = loader == ModLoader::Forge ? kForgeMaven : kNeoForgeMaven;
|
||||
QNetworkReply *primary = m_network->get(request(QUrl(primaryUrl + QStringLiteral("maven-metadata.xml"))));
|
||||
connect(primary, &QNetworkReply::finished, this, [this, primary, state, loader, settle] {
|
||||
primary->deleteLater();
|
||||
if (primary->error() != QNetworkReply::NoError) {
|
||||
state->warning = tr("Не удалось получить список версий %1: %2")
|
||||
.arg(loaderTitle(loader), primary->errorString());
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
auto parsed = parseMavenMetadata(primary->readAll(), loader, /*legacyNeoForge=*/false);
|
||||
if (parsed.isEmpty()) {
|
||||
state->warning = tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader));
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
state->primaryOk = true;
|
||||
state->merged = std::move(parsed);
|
||||
settle();
|
||||
});
|
||||
|
||||
if (loader == ModLoader::Forge) {
|
||||
QNetworkReply *promos = m_network->get(request(QUrl(kForgePromotions)));
|
||||
connect(promos, &QNetworkReply::finished, this, [promos, state, settle] {
|
||||
promos->deleteLater();
|
||||
if (promos->error() == QNetworkReply::NoError) {
|
||||
const QJsonObject root = QJsonDocument::fromJson(promos->readAll()).object();
|
||||
const QJsonObject promoted = root.value(QStringLiteral("promos")).toObject();
|
||||
for (auto it = promoted.constBegin(); it != promoted.constEnd(); ++it) {
|
||||
if (!it.key().endsWith(QLatin1String("-recommended")))
|
||||
continue;
|
||||
state->recommended.insert(it.key().chopped(int(sizeof("-recommended") - 1)),
|
||||
it.value().toString());
|
||||
}
|
||||
}
|
||||
// Метка «рекомендовано» необязательна: без неё список всё равно рабочий.
|
||||
settle();
|
||||
});
|
||||
} else {
|
||||
QNetworkReply *legacy =
|
||||
m_network->get(request(QUrl(kNeoForgeLegacyMaven + QStringLiteral("maven-metadata.xml"))));
|
||||
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
|
||||
legacy->deleteLater();
|
||||
if (legacy->error() == QNetworkReply::NoError) {
|
||||
const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
||||
/*legacyNeoForge=*/true);
|
||||
for (auto it = extra.constBegin(); it != extra.constEnd(); ++it)
|
||||
state->merged[it.key()] += it.value();
|
||||
}
|
||||
settle();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void ModLoaderVersionService::finishRefresh(ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
bool ok,
|
||||
const QString &warning)
|
||||
{
|
||||
const QString key = requestKey(loader, gameVersion);
|
||||
m_inFlight.remove(key);
|
||||
emit refreshingChanged();
|
||||
|
||||
// Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
|
||||
const QList<Callback> pending = m_pending.take(key);
|
||||
const bool haveData = !bucket(loader).byGame.isEmpty();
|
||||
for (const Callback &callback : pending) {
|
||||
// Сеть недоступна, но старые данные есть — работаем на них.
|
||||
if (!ok && haveData)
|
||||
callback(true, warning);
|
||||
else
|
||||
callback(ok, warning);
|
||||
}
|
||||
}
|
||||
|
||||
QList<LoaderVersionEntry> ModLoaderVersionService::parseFabricLike(const QByteArray &payload,
|
||||
ModLoader loader,
|
||||
const QString &gameVersion)
|
||||
{
|
||||
const QString prefix = loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-")
|
||||
: QStringLiteral("quilt-loader-");
|
||||
QList<LoaderVersionEntry> list;
|
||||
const QJsonArray array = QJsonDocument::fromJson(payload).array();
|
||||
list.reserve(array.size());
|
||||
|
||||
for (const QJsonValue &value : array) {
|
||||
const QJsonObject object = value.toObject().value(QStringLiteral("loader")).toObject();
|
||||
LoaderVersionEntry entry;
|
||||
entry.loaderVersion = object.value(QStringLiteral("version")).toString();
|
||||
if (entry.loaderVersion.isEmpty())
|
||||
continue;
|
||||
entry.gameVersion = gameVersion;
|
||||
entry.versionId = prefix + entry.loaderVersion + u'-' + gameVersion;
|
||||
entry.stable = object.value(QStringLiteral("stable")).toBool(!looksUnstable(entry.loaderVersion));
|
||||
list << entry;
|
||||
}
|
||||
|
||||
// Fabric и Quilt отдают новые сверху, но зависеть от этого не стоит.
|
||||
std::stable_sort(list.begin(), list.end(),
|
||||
[](const LoaderVersionEntry &a, const LoaderVersionEntry &b) {
|
||||
return isNewer(a.loaderVersion, b.loaderVersion);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
QHash<QString, QList<LoaderVersionEntry>> ModLoaderVersionService::parseMavenMetadata(
|
||||
const QByteArray &xml, ModLoader loader, bool legacyNeoForge)
|
||||
{
|
||||
// Версия игры зашита в саму строку версии лоадера, поэтому раскладка по
|
||||
// версиям игры — это и есть проверка совместимости: несовместимой пары в
|
||||
// получившемся отображении просто не существует.
|
||||
const bool splitOnDash = loader == ModLoader::Forge || legacyNeoForge;
|
||||
const QString base = loader == ModLoader::Forge ? kForgeMaven
|
||||
: legacyNeoForge ? kNeoForgeLegacyMaven
|
||||
: kNeoForgeMaven;
|
||||
const QString artifact = loader == ModLoader::Forge ? QStringLiteral("forge")
|
||||
: legacyNeoForge ? QStringLiteral("forge")
|
||||
: QStringLiteral("neoforge");
|
||||
|
||||
QHash<QString, QList<LoaderVersionEntry>> byGame;
|
||||
QXmlStreamReader reader(xml);
|
||||
while (!reader.atEnd()) {
|
||||
if (reader.readNext() != QXmlStreamReader::StartElement)
|
||||
continue;
|
||||
if (reader.name() != QLatin1String("version"))
|
||||
continue;
|
||||
|
||||
const QString full = reader.readElementText().trimmed();
|
||||
if (full.isEmpty())
|
||||
continue;
|
||||
|
||||
LoaderVersionEntry entry;
|
||||
if (splitOnDash) {
|
||||
const int dash = full.indexOf(u'-');
|
||||
if (dash <= 0 || dash + 1 >= full.size())
|
||||
continue;
|
||||
entry.gameVersion = full.left(dash);
|
||||
entry.loaderVersion = full.mid(dash + 1);
|
||||
} else {
|
||||
entry.gameVersion = neoForgeGameVersion(full);
|
||||
entry.loaderVersion = full;
|
||||
}
|
||||
if (entry.gameVersion.isEmpty())
|
||||
continue;
|
||||
|
||||
entry.stable = !looksUnstable(full);
|
||||
entry.installerUrl = QUrl(base + full + u'/' + artifact + u'-' + full
|
||||
+ QStringLiteral("-installer.jar"));
|
||||
byGame[entry.gameVersion] << entry;
|
||||
}
|
||||
if (reader.hasError())
|
||||
return {};
|
||||
|
||||
// maven-metadata приходит неотсортированным: 1.20.1-47.4.23 стоит между
|
||||
// версиями 26.x. Без сортировки «самой новой» оказалась бы случайная сборка.
|
||||
for (auto it = byGame.begin(); it != byGame.end(); ++it) {
|
||||
std::stable_sort(it.value().begin(), it.value().end(),
|
||||
[](const LoaderVersionEntry &a, const LoaderVersionEntry &b) {
|
||||
return isNewer(a.loaderVersion, b.loaderVersion);
|
||||
});
|
||||
}
|
||||
return byGame;
|
||||
}
|
||||
|
||||
bool ModLoaderVersionService::readCache(ModLoader loader)
|
||||
{
|
||||
QFile file(LauncherPaths::loaderCacheFile(loaderKey(loader)));
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
|
||||
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
||||
const QJsonObject games = root.value(QStringLiteral("games")).toObject();
|
||||
if (games.isEmpty())
|
||||
return false;
|
||||
|
||||
Bucket &b = bucket(loader);
|
||||
for (auto it = games.constBegin(); it != games.constEnd(); ++it) {
|
||||
const QJsonObject game = it.value().toObject();
|
||||
QList<LoaderVersionEntry> entries;
|
||||
const QJsonArray array = game.value(QStringLiteral("entries")).toArray();
|
||||
entries.reserve(array.size());
|
||||
for (const QJsonValue &value : array)
|
||||
entries << entryFromJson(value.toObject());
|
||||
b.byGame.insert(it.key(), entries);
|
||||
const QDateTime fetched =
|
||||
QDateTime::fromString(game.value(QStringLiteral("fetchedAt")).toString(), Qt::ISODate);
|
||||
if (fetched.isValid())
|
||||
b.fetchedAtByGame.insert(it.key(), fetched);
|
||||
}
|
||||
b.fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
|
||||
Qt::ISODate);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ModLoaderVersionService::writeCache(ModLoader loader)
|
||||
{
|
||||
// Папку кэша могли удалить уже после запуска лаунчера.
|
||||
if (!LauncherPaths::ensureRootExists())
|
||||
return;
|
||||
|
||||
const Bucket &b = bucket(loader);
|
||||
QJsonObject games;
|
||||
for (auto it = b.byGame.constBegin(); it != b.byGame.constEnd(); ++it) {
|
||||
QJsonArray entries;
|
||||
for (const LoaderVersionEntry &entry : it.value())
|
||||
entries << entryToJson(entry);
|
||||
games.insert(it.key(),
|
||||
QJsonObject{{QStringLiteral("fetchedAt"),
|
||||
b.fetchedAtByGame.value(it.key()).toString(Qt::ISODate)},
|
||||
{QStringLiteral("entries"), entries}});
|
||||
}
|
||||
|
||||
const QJsonObject root{{QStringLiteral("fetchedAt"), b.fetchedAt.toString(Qt::ISODate)},
|
||||
{QStringLiteral("games"), games}};
|
||||
|
||||
// QSaveFile: оборванная запись не оставит обрезанный кэш, который потом
|
||||
// пришлось бы отличать от настоящего.
|
||||
QSaveFile file(LauncherPaths::loaderCacheFile(loaderKey(loader)));
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return;
|
||||
}
|
||||
file.commit();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include "modloader.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
// Списки версий модлоадеров: качает, кэширует в папке лаунчера и отдаёт из
|
||||
// кэша, пока тот не устарел. Устроен как VersionManifestService и работает в
|
||||
// GUI-потоке.
|
||||
//
|
||||
// Совместимость с версией игры не проверяется отдельной логикой, а заложена в
|
||||
// структуру данных: Fabric и Quilt отдают список сразу под нужную версию игры,
|
||||
// а maven-metadata Forge и NeoForge раскладывается по версиям игры при разборе.
|
||||
// Версии игры, под которую сборок нет, соответствует пустой список — выбрать
|
||||
// несовместимый лоадер физически нечем.
|
||||
class ModLoaderVersionService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// ok = true и непустой warning — данные отдали из устаревшего кэша.
|
||||
using Callback = std::function<void(bool ok, const QString &warning)>;
|
||||
|
||||
explicit ModLoaderVersionService(QObject *parent = nullptr);
|
||||
|
||||
// Единственная точка входа. Свежий кэш — колбэк отложенно, иначе один
|
||||
// сетевой запрос на всех, кто успел попросить.
|
||||
void ensureLoaded(ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
Callback callback,
|
||||
bool forceRefresh = false);
|
||||
|
||||
// Новые сборки сверху: первая строка — самая свежая для этой версии игры.
|
||||
QList<LoaderVersionEntry> versions(ModLoader loader, const QString &gameVersion) const;
|
||||
bool isRefreshing(ModLoader loader, const QString &gameVersion) const;
|
||||
|
||||
// Запись по версии лоадера — из неё установщик берёт ссылку на installer.jar.
|
||||
std::optional<LoaderVersionEntry> find(ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
const QString &loaderVersion) const;
|
||||
|
||||
signals:
|
||||
void versionsChanged(const QString &loaderKey, const QString &gameVersion);
|
||||
void refreshingChanged();
|
||||
|
||||
private:
|
||||
// Fabric и Quilt спрашиваются по каждой версии игры отдельно, Forge и
|
||||
// NeoForge — одним maven-metadata на все версии сразу. Отсюда два вида
|
||||
// ключа свежести: у первых — на версию игры, у вторых — на весь лоадер.
|
||||
static bool isPerGameVersion(ModLoader loader);
|
||||
|
||||
struct Bucket
|
||||
{
|
||||
QHash<QString, QList<LoaderVersionEntry>> byGame;
|
||||
QHash<QString, QDateTime> fetchedAtByGame; // Fabric, Quilt
|
||||
QDateTime fetchedAt; // Forge, NeoForge
|
||||
bool cacheRead = false;
|
||||
};
|
||||
|
||||
Bucket &bucket(ModLoader loader);
|
||||
const Bucket &bucket(ModLoader loader) const;
|
||||
|
||||
static QString requestKey(ModLoader loader, const QString &gameVersion);
|
||||
bool isFresh(ModLoader loader, const QString &gameVersion) const;
|
||||
|
||||
bool readCache(ModLoader loader);
|
||||
void writeCache(ModLoader loader);
|
||||
void startRefresh(ModLoader loader, const QString &gameVersion);
|
||||
void finishRefresh(ModLoader loader,
|
||||
const QString &gameVersion,
|
||||
bool ok,
|
||||
const QString &warning);
|
||||
|
||||
// Разбор ответов. Возвращают пустое при испорченном ответе — хороший кэш им
|
||||
// не затирается.
|
||||
static QList<LoaderVersionEntry> parseFabricLike(const QByteArray &payload,
|
||||
ModLoader loader,
|
||||
const QString &gameVersion);
|
||||
static QHash<QString, QList<LoaderVersionEntry>> parseMavenMetadata(const QByteArray &xml,
|
||||
ModLoader loader,
|
||||
bool legacyNeoForge);
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
QHash<int, Bucket> m_buckets;
|
||||
QHash<QString, QList<Callback>> m_pending;
|
||||
QSet<QString> m_inFlight;
|
||||
};
|
||||
+59
-30
@@ -64,6 +64,11 @@ bool isSafeRelativeSegment(const QString &value)
|
||||
return !value.contains(u'/') && isSafeRelativePath(value);
|
||||
}
|
||||
|
||||
QString versionJsonPath(const QString &gameDir, const QString &id)
|
||||
{
|
||||
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
|
||||
}
|
||||
|
||||
bool isTransient(QNetworkReply::NetworkError error, int httpStatus)
|
||||
{
|
||||
if (httpStatus == 429 || httpStatus >= 500)
|
||||
@@ -234,20 +239,34 @@ void VersionInstaller::resolveManifestEntry()
|
||||
return;
|
||||
|
||||
if (!ok) {
|
||||
QFile local(versionJsonPath(m_gameDir, id));
|
||||
if (local.open(QIODevice::ReadOnly)) {
|
||||
adoptVersionJson(local.readAll(), /*alreadySaved=*/true);
|
||||
return;
|
||||
}
|
||||
failInstall(warning.isEmpty() ? tr("Список версий Mojang недоступен") : warning);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entry = m_manifest->find(id);
|
||||
if (!entry) {
|
||||
// Обычно это вручную вписанный id сборки — сообщение не должно
|
||||
// выглядеть как сетевая ошибка.
|
||||
failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — "
|
||||
"установить её автоматически нельзя")
|
||||
.arg(id));
|
||||
if (entry) {
|
||||
fetchVersionJson(entry->url, entry->sha1);
|
||||
return;
|
||||
}
|
||||
fetchVersionJson(entry->url, entry->sha1);
|
||||
|
||||
// Версии нет в манифесте, но описание могло уже лечь на диск: так
|
||||
// ставятся сборки модлоадеров, чьи id Mojang не знает.
|
||||
QFile local(versionJsonPath(m_gameDir, id));
|
||||
if (local.open(QIODevice::ReadOnly)) {
|
||||
adoptVersionJson(local.readAll(), /*alreadySaved=*/true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе это вручную вписанный id сборки — сообщение не должно
|
||||
// выглядеть как сетевая ошибка.
|
||||
failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — "
|
||||
"установить её автоматически нельзя")
|
||||
.arg(id));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,12 +302,23 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected
|
||||
}
|
||||
}
|
||||
|
||||
m_versionJson = QJsonDocument::fromJson(payload).object();
|
||||
if (m_versionJson.isEmpty()) {
|
||||
failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id));
|
||||
return;
|
||||
}
|
||||
adoptVersionJson(payload, /*alreadySaved=*/false);
|
||||
});
|
||||
}
|
||||
|
||||
// Общий хвост для описания версии, откуда бы оно ни пришло: из сети или уже
|
||||
// лежащим на диске (сборки модлоадеров кладут его туда сами).
|
||||
void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadySaved)
|
||||
{
|
||||
const QString id = m_versionId;
|
||||
|
||||
m_versionJson = QJsonDocument::fromJson(payload).object();
|
||||
if (m_versionJson.isEmpty()) {
|
||||
failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!alreadySaved) {
|
||||
const QString directory = m_gameDir + QStringLiteral("/versions/") + id;
|
||||
if (!QDir().mkpath(directory)) {
|
||||
failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory)));
|
||||
@@ -302,28 +332,27 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected
|
||||
failInstall(tr("Не удалось сохранить описание версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ванильные версии ничего не наследуют, но сборка могла бы — тогда
|
||||
// сначала ставим родителя, а потом возвращаемся к этой версии.
|
||||
const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
||||
if (!parentId.isEmpty() && isSafeRelativeSegment(parentId)
|
||||
&& !QFile::exists(m_gameDir + QStringLiteral("/versions/") + parentId + u'/' + parentId
|
||||
+ QStringLiteral(".json"))) {
|
||||
if (m_inheritDepth >= kMaxInheritDepth) {
|
||||
failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
++m_inheritDepth;
|
||||
m_pendingInstalls.prepend({m_gameDir, id});
|
||||
m_pendingInstalls.prepend({m_gameDir, parentId});
|
||||
abandonCurrent();
|
||||
// Ванильные версии ничего не наследуют, но сборка могла бы — тогда
|
||||
// сначала ставим родителя, а потом возвращаемся к этой версии.
|
||||
const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
||||
if (!parentId.isEmpty() && isSafeRelativeSegment(parentId)
|
||||
&& !QFile::exists(versionJsonPath(m_gameDir, parentId))) {
|
||||
if (m_inheritDepth >= kMaxInheritDepth) {
|
||||
failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
++m_inheritDepth;
|
||||
m_pendingInstalls.prepend({m_gameDir, id});
|
||||
m_pendingInstalls.prepend({m_gameDir, parentId});
|
||||
abandonCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
m_inheritDepth = 0;
|
||||
if (prepareTasks())
|
||||
pump();
|
||||
});
|
||||
m_inheritDepth = 0;
|
||||
if (prepareTasks())
|
||||
pump();
|
||||
}
|
||||
|
||||
// ── Составление списка файлов ──────────────────────────────────────────────
|
||||
|
||||
@@ -80,6 +80,8 @@ private:
|
||||
void startNext();
|
||||
void resolveManifestEntry();
|
||||
void fetchVersionJson(const QUrl &url, const QString &expectedSha1);
|
||||
// Общий хвост: описание версии пришло из сети или уже лежало на диске.
|
||||
void adoptVersionJson(const QByteArray &payload, bool alreadySaved);
|
||||
bool prepareTasks();
|
||||
void enqueue(DownloadTask task);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user