new builds menu and modloaders implementation

This commit is contained in:
2026-08-27 09:21:51 +03:00
parent 5bc59f8018
commit 168884127b
23 changed files with 3979 additions and 1081 deletions
+460
View File
@@ -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
View File
@@ -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
)
+73
View File
@@ -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
}
}
}
+39
View File
@@ -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
View File
@@ -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)
}
}
+61 -931
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -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()
}
}
}
+250
View File
@@ -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()
}
}
}
}
}
}
}
}
+220
View File
@@ -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, {});
}
+45
View File
@@ -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 &currentPath);
void finished(bool ok, const QString &error);
private:
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
QAtomicInt m_cancel;
};
+438
View File
@@ -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 &currentPath)
{
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));
}
}
+93
View File
@@ -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 &currentPath);
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;
};
+490 -86
View File
@@ -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()
{
// Установщик лоадера сам остановит вложенную установку версии.
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
{
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)
+83 -21
View File
@@ -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,
Q_INVOKABLE void addCustomBuild(const QString &name,
const QString &serverUrl,
const QString &versionId = QString());
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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+552
View File
@@ -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);
}
+106
View File
@@ -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;
};
+520
View File
@@ -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();
}
+96
View File
@@ -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;
};
+37 -8
View File
@@ -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 сборки — сообщение не должно
if (entry) {
fetchVersionJson(entry->url, entry->sha1);
return;
}
// Версии нет в манифесте, но описание могло уже лечь на диск: так
// ставятся сборки модлоадеров, чьи 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));
return;
}
fetchVersionJson(entry->url, entry->sha1);
});
}
@@ -283,12 +302,23 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected
}
}
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,13 +332,13 @@ 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"))) {
&& !QFile::exists(versionJsonPath(m_gameDir, parentId))) {
if (m_inheritDepth >= kMaxInheritDepth) {
failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id));
return;
@@ -323,7 +353,6 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected
m_inheritDepth = 0;
if (prepareTasks())
pump();
});
}
// ── Составление списка файлов ──────────────────────────────────────────────
+2
View File
@@ -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);