version tracking and auto installing
This commit is contained in:
@@ -28,6 +28,10 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
authservice.cpp
|
||||
gamelauncher.h
|
||||
gamelauncher.cpp
|
||||
versionmanifestservice.h
|
||||
versionmanifestservice.cpp
|
||||
versioninstaller.h
|
||||
versioninstaller.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
|
||||
|
||||
@@ -48,6 +48,10 @@ Window {
|
||||
}
|
||||
}
|
||||
|
||||
function formatMb(bytes) {
|
||||
return (bytes / 1048576).toFixed(1)
|
||||
}
|
||||
|
||||
// ── Background ─────────────────────────────────────────────────────────
|
||||
Image {
|
||||
id: image
|
||||
@@ -63,7 +67,7 @@ Window {
|
||||
visible: toastLabel.text !== ""
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 40
|
||||
anchors.bottomMargin: downloadPanel.visible ? 110 : 40
|
||||
width: Math.min(window.width - 80, toastLabel.implicitWidth + 32)
|
||||
height: toastLabel.implicitHeight + 20
|
||||
radius: 8
|
||||
@@ -86,6 +90,94 @@ Window {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download panel ─────────────────────────────────────────────────────
|
||||
// Левый нижний угол: не задевает кнопку запуска, плашку по центру и
|
||||
// кнопки папки с настройками.
|
||||
Rectangle {
|
||||
id: downloadPanel
|
||||
visible: backend.downloading
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 24
|
||||
anchors.bottomMargin: 24
|
||||
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: qsTr("Загрузка %1").arg(backend.downloadVersion)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
y: 8
|
||||
color: "#91B315"
|
||||
font.pixelSize: 12
|
||||
// Пока не известен общий объём, доля равна -1.
|
||||
text: backend.downloadProgress < 0
|
||||
? "…" : Math.round(backend.downloadProgress * 100) + "%"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: dlTrack
|
||||
x: 12; y: 30
|
||||
width: parent.width - 24
|
||||
height: 6
|
||||
radius: 3
|
||||
color: "#2a2a2a"
|
||||
|
||||
Rectangle {
|
||||
width: backend.downloadProgress > 0 ? dlTrack.width * backend.downloadProgress : 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: backend.downloadBytesTotal > 0
|
||||
? qsTr("%1 — %2 / %3 МБ").arg(backend.downloadStatus)
|
||||
.arg(window.formatMb(backend.downloadBytesDone))
|
||||
.arg(window.formatMb(backend.downloadBytesTotal))
|
||||
: backend.downloadStatus
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 10
|
||||
anchors.bottomMargin: 6
|
||||
text: "✕"
|
||||
color: cancelDownloadArea.containsMouse ? "#cc6666" : "#666666"
|
||||
font.pixelSize: 12
|
||||
|
||||
MouseArea {
|
||||
id: cancelDownloadArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
hoverEnabled: true
|
||||
onClicked: backend.cancelDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play button ────────────────────────────────────────────────────────
|
||||
Button {
|
||||
id: button
|
||||
@@ -753,21 +845,26 @@ Window {
|
||||
}
|
||||
}
|
||||
|
||||
// Папка внутри .minecraft/versions, которую и будем запускать.
|
||||
DarkCombo {
|
||||
id: verInstalled
|
||||
// Установленные версии и весь каталог Mojang одним списком.
|
||||
VersionCatalogCombo {
|
||||
id: verVersion
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
model: backend.installedVersions
|
||||
catalog: backend.versionCatalog
|
||||
onAboutToOpen: backend.refreshVersionCatalog()
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
text: backend.installedVersions.length === 0
|
||||
? qsTr("В .minecraft/versions нет установленных версий")
|
||||
: qsTr("Установленная версия из .minecraft")
|
||||
color: backend.installedVersions.length === 0 ? "#cc6666" : "#888888"
|
||||
text: verVersion.selectedId === ""
|
||||
? qsTr("Выберите версию из списка")
|
||||
: backend.isVersionInstalled(verVersion.selectedId)
|
||||
? qsTr("Версия уже установлена в .minecraft")
|
||||
: qsTr("Версия будет загружена в .minecraft после сохранения")
|
||||
color: verVersion.selectedId !== ""
|
||||
&& !backend.isVersionInstalled(verVersion.selectedId)
|
||||
? "#91B315" : "#888888"
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
@@ -819,18 +916,22 @@ Window {
|
||||
}
|
||||
}
|
||||
|
||||
onAboutToShow: verInstalled.currentIndex = 0
|
||||
// Манифест прогреваем заранее — тогда список открывается мгновенно.
|
||||
onAboutToShow: {
|
||||
verVersion.selectedId = ""
|
||||
backend.refreshVersionCatalog()
|
||||
}
|
||||
|
||||
onAccepted: {
|
||||
const name = verName.text.trim()
|
||||
if (name !== "") {
|
||||
backend.addVersion(name, verServer.text.trim(), verInstalled.currentText)
|
||||
backend.addVersion(name, verServer.text.trim(), verVersion.selectedId)
|
||||
versionBox.currentIndex = backend.versionNames.length - 1
|
||||
}
|
||||
verName.text = ""; verServer.text = ""
|
||||
verName.text = ""; verServer.text = ""; verVersion.selectedId = ""
|
||||
}
|
||||
onRejected: {
|
||||
verName.text = ""; verServer.text = ""
|
||||
verName.text = ""; verServer.text = ""; verVersion.selectedId = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,7 +1109,7 @@ Window {
|
||||
Dialog {
|
||||
id: editVersionDialog
|
||||
modal: true
|
||||
width: 320
|
||||
width: 380
|
||||
x: (window.width - width) / 2
|
||||
y: (window.height - height) / 2
|
||||
padding: 0
|
||||
@@ -1020,7 +1121,8 @@ Window {
|
||||
editIndex = index
|
||||
evName.text = data.name || ""
|
||||
evServer.text = data.serverUrl || ""
|
||||
evInstalled.currentIndex = backend.installedVersions.indexOf(data.versionId || "")
|
||||
evVersion.selectedId = data.versionId || ""
|
||||
backend.refreshVersionCatalog()
|
||||
|
||||
const problems = backend.checkInstallation(index)
|
||||
evStatus.text = problems.length === 0
|
||||
@@ -1090,11 +1192,12 @@ Window {
|
||||
}
|
||||
}
|
||||
|
||||
DarkCombo {
|
||||
id: evInstalled
|
||||
VersionCatalogCombo {
|
||||
id: evVersion
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
model: backend.installedVersions
|
||||
catalog: backend.versionCatalog
|
||||
onAboutToOpen: backend.refreshVersionCatalog()
|
||||
}
|
||||
|
||||
// Показываем, чего не хватает в .minecraft именно для этой версии.
|
||||
@@ -1120,6 +1223,25 @@ Window {
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
|
||||
// Починка: докачать то, чего не хватает выбранной версии.
|
||||
Button {
|
||||
text: "Скачать"
|
||||
width: 110; height: 36
|
||||
enabled: evVersion.selectedId !== "" && !backend.downloading
|
||||
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: backend.installVersion(evVersion.selectedId)
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Отмена"
|
||||
width: 110; height: 36
|
||||
@@ -1157,13 +1279,13 @@ Window {
|
||||
onAccepted: {
|
||||
const name = evName.text.trim()
|
||||
if (editIndex >= 0 && name !== "")
|
||||
backend.updateVersion(editIndex, name, evServer.text.trim(), evInstalled.currentText)
|
||||
backend.updateVersion(editIndex, name, evServer.text.trim(), evVersion.selectedId)
|
||||
editIndex = -1
|
||||
evName.text = ""; evServer.text = ""
|
||||
evName.text = ""; evServer.text = ""; evVersion.selectedId = ""
|
||||
}
|
||||
onRejected: {
|
||||
editIndex = -1
|
||||
evName.text = ""; evServer.text = ""
|
||||
evName.text = ""; evServer.text = ""; evVersion.selectedId = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1574,6 +1696,254 @@ Window {
|
||||
}
|
||||
}
|
||||
|
||||
// Выпадающий список версий: сверху установленные, ниже через разделитель
|
||||
// весь каталог Mojang, сверху поле поиска.
|
||||
//
|
||||
// Это Item с Popup, а не ComboBox: editable-ComboBox привязывает поле ввода
|
||||
// к currentText и запускает своё автодополнение, а при активном фильтре
|
||||
// currentIndex перестаёт указывать в исходную модель.
|
||||
component VersionCatalogCombo: 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Подпись + поле ввода одной колонкой — используется в настройках.
|
||||
component LabelledField: Column {
|
||||
id: labelled
|
||||
|
||||
+238
-1
@@ -4,6 +4,8 @@
|
||||
#include "gamelauncher.h"
|
||||
#include "javalocator.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
@@ -11,7 +13,9 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QHash>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <algorithm>
|
||||
@@ -35,6 +39,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_auth(new AuthService(this))
|
||||
, m_launcher(new GameLauncher(this))
|
||||
, m_manifest(new VersionManifestService(this))
|
||||
, m_installer(new VersionInstaller(m_manifest, this))
|
||||
{
|
||||
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
|
||||
// следующем — если её удалили, она появится снова.
|
||||
@@ -58,6 +64,44 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
emit gameFinished(exitCode, crashed);
|
||||
});
|
||||
|
||||
connect(m_manifest, &VersionManifestService::versionsChanged,
|
||||
this, &LauncherBackend::invalidateCatalog);
|
||||
connect(m_manifest, &VersionManifestService::refreshingChanged,
|
||||
this, &LauncherBackend::catalogLoadingChanged);
|
||||
|
||||
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка версии «%1»…").arg(id));
|
||||
});
|
||||
connect(m_installer, &VersionInstaller::progressChanged,
|
||||
this, &LauncherBackend::downloadChanged);
|
||||
// Порядок важен: на installedVersionsChanged интерфейс перечитывает каталог,
|
||||
// и устаревший кэш вернулся бы наружу.
|
||||
connect(m_installer, &VersionInstaller::finished, this, [this](const QString &id) {
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Версия «%1» загружена").arg(id));
|
||||
});
|
||||
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
|
||||
const QString &message) {
|
||||
// Часть файлов всё же появилась — список установленного мог измениться.
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
|
||||
});
|
||||
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
|
||||
});
|
||||
|
||||
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
||||
if (!m_storageIssues.isEmpty()) {
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
@@ -116,6 +160,7 @@ void LauncherBackend::addVersion(const QString &name, const QString &serverUrl,
|
||||
m_versions.append({m_nextVersionId++, name, serverUrl, versionId});
|
||||
saveVersions();
|
||||
emit versionsChanged();
|
||||
maybeInstall(versionIdOf(m_versions.last()));
|
||||
}
|
||||
|
||||
void LauncherBackend::updateProfile(int index,
|
||||
@@ -157,6 +202,7 @@ void LauncherBackend::updateVersion(int index,
|
||||
m_versions[index].versionId = versionId;
|
||||
saveVersions();
|
||||
emit versionsChanged();
|
||||
maybeInstall(versionIdOf(m_versions[index]));
|
||||
}
|
||||
|
||||
QVariantMap LauncherBackend::profileAt(int index) const
|
||||
@@ -230,8 +276,192 @@ QStringList LauncherBackend::checkInstallation(int versionIndex) const
|
||||
return GameLauncher::missingFiles(options, version);
|
||||
}
|
||||
|
||||
// ── Каталог версий ─────────────────────────────────────────────────────────
|
||||
|
||||
void LauncherBackend::invalidateCatalog()
|
||||
{
|
||||
m_catalogDirty = true;
|
||||
emit versionCatalogChanged();
|
||||
}
|
||||
|
||||
bool LauncherBackend::catalogLoading() const
|
||||
{
|
||||
return m_manifest->isRefreshing();
|
||||
}
|
||||
|
||||
void LauncherBackend::refreshVersionCatalog(bool force)
|
||||
{
|
||||
// Список установленного мог поменяться и без нас (папку добавили руками).
|
||||
invalidateCatalog();
|
||||
m_manifest->ensureLoaded([this](bool ok, const QString &warning) {
|
||||
if (!ok && !warning.isEmpty())
|
||||
emit launchError(warning);
|
||||
}, force);
|
||||
}
|
||||
|
||||
bool LauncherBackend::isVersionInstalled(const QString &versionId) const
|
||||
{
|
||||
if (versionId.trimmed().isEmpty())
|
||||
return false;
|
||||
return VersionLoader::installedVersions(gameDir()).contains(versionId);
|
||||
}
|
||||
|
||||
QVariantList LauncherBackend::versionCatalog() const
|
||||
{
|
||||
if (m_catalogDirty) {
|
||||
rebuildCatalog();
|
||||
m_catalogDirty = false;
|
||||
}
|
||||
return m_catalog;
|
||||
}
|
||||
|
||||
void LauncherBackend::rebuildCatalog() const
|
||||
{
|
||||
m_catalog.clear();
|
||||
|
||||
const QList<RemoteVersionEntry> remote = m_manifest->versions();
|
||||
QHash<QString, int> remoteIndex;
|
||||
remoteIndex.reserve(remote.size());
|
||||
for (int i = 0; i < remote.size(); ++i)
|
||||
remoteIndex.insert(remote.at(i).id, i);
|
||||
|
||||
const auto makeVersionRow = [](const QString &id, const QString &type, bool installed) {
|
||||
return QVariantMap{{QStringLiteral("kind"),
|
||||
installed ? QStringLiteral("installed") : QStringLiteral("remote")},
|
||||
{QStringLiteral("id"), id},
|
||||
{QStringLiteral("type"), type},
|
||||
{QStringLiteral("label"), id},
|
||||
{QStringLiteral("installed"), installed},
|
||||
{QStringLiteral("selectable"), true},
|
||||
{QStringLiteral("search"), (id + u' ' + type).toLower()}};
|
||||
};
|
||||
|
||||
// Установленные: сначала знакомые манифесту, свежие сверху, затем сборки
|
||||
// вроде fabric-loader-…, о которых манифест ничего не знает.
|
||||
QStringList installed = VersionLoader::installedVersions(gameDir());
|
||||
std::stable_sort(installed.begin(), installed.end(),
|
||||
[&remoteIndex, &remote](const QString &a, const QString &b) {
|
||||
const auto ia = remoteIndex.constFind(a);
|
||||
const auto ib = remoteIndex.constFind(b);
|
||||
const bool knownA = ia != remoteIndex.constEnd();
|
||||
const bool knownB = ib != remoteIndex.constEnd();
|
||||
if (knownA != knownB)
|
||||
return knownA;
|
||||
if (knownA)
|
||||
return ia.value() < ib.value(); // манифест уже отсортирован
|
||||
return a < b;
|
||||
});
|
||||
|
||||
QSet<QString> installedSet;
|
||||
for (const QString &id : std::as_const(installed)) {
|
||||
const auto it = remoteIndex.constFind(id);
|
||||
const QString type = it != remoteIndex.constEnd() ? remote.at(it.value()).type : QString();
|
||||
installedSet.insert(id);
|
||||
m_catalog.append(makeVersionRow(id, type, true));
|
||||
}
|
||||
|
||||
m_catalog.append(QVariantMap{{QStringLiteral("kind"), QStringLiteral("separator")},
|
||||
{QStringLiteral("id"), QString()},
|
||||
{QStringLiteral("type"), QString()},
|
||||
{QStringLiteral("label"), tr("Все версии Minecraft")},
|
||||
{QStringLiteral("installed"), false},
|
||||
{QStringLiteral("selectable"), false},
|
||||
{QStringLiteral("search"), QString()}});
|
||||
|
||||
if (remote.isEmpty()) {
|
||||
m_catalog.append(
|
||||
QVariantMap{{QStringLiteral("kind"), QStringLiteral("status")},
|
||||
{QStringLiteral("id"), QString()},
|
||||
{QStringLiteral("type"), QString()},
|
||||
{QStringLiteral("label"),
|
||||
m_manifest->isRefreshing()
|
||||
? tr("Загрузка списка версий…")
|
||||
: tr("Список версий недоступен — проверьте соединение")},
|
||||
{QStringLiteral("installed"), false},
|
||||
{QStringLiteral("selectable"), false},
|
||||
{QStringLiteral("search"), QString()}});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const RemoteVersionEntry &entry : remote) {
|
||||
if (installedSet.contains(entry.id))
|
||||
continue;
|
||||
m_catalog.append(makeVersionRow(entry.id, entry.type, false));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Загрузка версий ────────────────────────────────────────────────────────
|
||||
|
||||
void LauncherBackend::maybeInstall(const QString &versionId)
|
||||
{
|
||||
const QString id = versionId.trimmed();
|
||||
if (id.isEmpty() || isVersionInstalled(id))
|
||||
return;
|
||||
if (m_installer->versionId() == id || m_installer->isQueued(id))
|
||||
return;
|
||||
// Манифест здесь не спрашиваем: установщик сам скажет, что версии в нём нет,
|
||||
// и это правильный ответ на вручную вписанный id сборки.
|
||||
m_installer->install(gameDir(), id);
|
||||
}
|
||||
|
||||
void LauncherBackend::installVersion(const QString &versionId)
|
||||
{
|
||||
const QString id = versionId.trimmed();
|
||||
if (id.isEmpty())
|
||||
return;
|
||||
if (m_installer->versionId() == id || m_installer->isQueued(id))
|
||||
return;
|
||||
m_installer->install(gameDir(), id);
|
||||
}
|
||||
|
||||
void LauncherBackend::cancelDownload()
|
||||
{
|
||||
m_installer->cancel();
|
||||
}
|
||||
|
||||
bool LauncherBackend::downloading() const
|
||||
{
|
||||
return m_installer->isRunning();
|
||||
}
|
||||
|
||||
double LauncherBackend::downloadProgress() const
|
||||
{
|
||||
return m_installer->fraction();
|
||||
}
|
||||
|
||||
QString LauncherBackend::downloadVersion() const
|
||||
{
|
||||
return m_installer->versionId();
|
||||
}
|
||||
|
||||
QString LauncherBackend::downloadStatus() const
|
||||
{
|
||||
const QString stage = m_installer->stage();
|
||||
const QString file = m_installer->currentFile();
|
||||
if (stage.isEmpty())
|
||||
return file;
|
||||
if (file.isEmpty())
|
||||
return stage;
|
||||
return QStringLiteral("%1 — %2").arg(stage, file);
|
||||
}
|
||||
|
||||
qint64 LauncherBackend::downloadBytesDone() const
|
||||
{
|
||||
return m_installer->bytesDone();
|
||||
}
|
||||
|
||||
qint64 LauncherBackend::downloadBytesTotal() const
|
||||
{
|
||||
return m_installer->bytesTotal();
|
||||
}
|
||||
|
||||
void LauncherBackend::launchGame(int profileIndex, int versionIndex)
|
||||
{
|
||||
if (m_installer->isRunning()) {
|
||||
emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания")
|
||||
.arg(m_installer->versionId()));
|
||||
return;
|
||||
}
|
||||
if (m_busy) {
|
||||
emit launchError(tr("Запуск уже выполняется"));
|
||||
return;
|
||||
@@ -475,6 +705,11 @@ void LauncherBackend::failLaunch(const QString &message)
|
||||
emit launchError(message);
|
||||
}
|
||||
|
||||
bool LauncherBackend::busy() const
|
||||
{
|
||||
return m_busy || m_installer->isRunning();
|
||||
}
|
||||
|
||||
void LauncherBackend::setBusy(bool busy)
|
||||
{
|
||||
if (m_busy == busy)
|
||||
@@ -504,9 +739,11 @@ void LauncherBackend::updateSettings(const QVariantMap &values)
|
||||
m_settings.insert(it.key(), it.value());
|
||||
saveSettings();
|
||||
emit settingsChanged();
|
||||
if (gameDir() != previousGameDir)
|
||||
if (gameDir() != previousGameDir) {
|
||||
invalidateCatalog();
|
||||
emit installedVersionsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
QStringList LauncherBackend::detectedJava() const
|
||||
{
|
||||
|
||||
+49
-1
@@ -13,6 +13,8 @@
|
||||
struct AuthResult;
|
||||
class AuthService;
|
||||
class GameLauncher;
|
||||
class VersionManifestService;
|
||||
class VersionInstaller;
|
||||
|
||||
class LauncherBackend : public QObject
|
||||
{
|
||||
@@ -25,6 +27,20 @@ class LauncherBackend : public QObject
|
||||
Q_PROPERTY(bool gameRunning READ gameRunning NOTIFY gameRunningChanged)
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
|
||||
// Объединённый список для выпадающего меню версий: установленные, разделитель,
|
||||
// затем весь каталог Mojang.
|
||||
Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged)
|
||||
Q_PROPERTY(bool catalogLoading READ catalogLoading NOTIFY catalogLoadingChanged)
|
||||
|
||||
// Одного сигнала на все свойства загрузки достаточно: установщик уже
|
||||
// ограничивает частоту, а QML всё равно перечитывает их разом.
|
||||
Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged)
|
||||
Q_PROPERTY(double downloadProgress READ downloadProgress NOTIFY downloadChanged)
|
||||
Q_PROPERTY(QString downloadVersion READ downloadVersion NOTIFY downloadChanged)
|
||||
Q_PROPERTY(QString downloadStatus READ downloadStatus NOTIFY downloadChanged)
|
||||
Q_PROPERTY(qint64 downloadBytesDone READ downloadBytesDone NOTIFY downloadChanged)
|
||||
Q_PROPERTY(qint64 downloadBytesTotal READ downloadBytesTotal NOTIFY downloadChanged)
|
||||
|
||||
public:
|
||||
explicit LauncherBackend(QObject *parent = nullptr);
|
||||
~LauncherBackend() override;
|
||||
@@ -33,7 +49,18 @@ public:
|
||||
QStringList versionNames() const;
|
||||
QStringList installedVersions() const;
|
||||
bool gameRunning() const;
|
||||
bool busy() const { return m_busy; }
|
||||
// Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет.
|
||||
bool busy() const;
|
||||
|
||||
QVariantList versionCatalog() const;
|
||||
bool catalogLoading() const;
|
||||
|
||||
bool downloading() const;
|
||||
double downloadProgress() const;
|
||||
QString downloadVersion() const;
|
||||
QString downloadStatus() const;
|
||||
qint64 downloadBytesDone() const;
|
||||
qint64 downloadBytesTotal() const;
|
||||
|
||||
Q_INVOKABLE void addProfile(const QString &name,
|
||||
const QString &login,
|
||||
@@ -66,6 +93,12 @@ public:
|
||||
// Проверка без запуска — для подсказки в интерфейсе.
|
||||
Q_INVOKABLE QStringList checkInstallation(int versionIndex) const;
|
||||
|
||||
// Вызывается при открытии выпадающего списка версий: свежий кэш — без сети.
|
||||
Q_INVOKABLE void refreshVersionCatalog(bool force = false);
|
||||
Q_INVOKABLE bool isVersionInstalled(const QString &versionId) const;
|
||||
Q_INVOKABLE void installVersion(const QString &versionId);
|
||||
Q_INVOKABLE void cancelDownload();
|
||||
|
||||
Q_INVOKABLE QVariantMap settings() const;
|
||||
Q_INVOKABLE void updateSettings(const QVariantMap &values);
|
||||
Q_INVOKABLE QStringList detectedJava() const;
|
||||
@@ -80,6 +113,9 @@ signals:
|
||||
void settingsChanged();
|
||||
void gameRunningChanged();
|
||||
void busyChanged();
|
||||
void versionCatalogChanged();
|
||||
void catalogLoadingChanged();
|
||||
void downloadChanged();
|
||||
|
||||
void launchProgress(const QString &message);
|
||||
void launched(const QString &profileName, const QString &versionName, const QString &serverUrl);
|
||||
@@ -121,6 +157,11 @@ private:
|
||||
QString gameDir() const;
|
||||
QString versionIdOf(const Version &version) const;
|
||||
|
||||
void invalidateCatalog();
|
||||
void rebuildCatalog() const;
|
||||
// Версия выбрана, но её нет в .minecraft — ставим в фоне.
|
||||
void maybeInstall(const QString &versionId);
|
||||
|
||||
void setBusy(bool busy);
|
||||
void failLaunch(const QString &message);
|
||||
void beginAuthentication();
|
||||
@@ -137,6 +178,13 @@ private:
|
||||
|
||||
AuthService *m_auth;
|
||||
GameLauncher *m_launcher;
|
||||
VersionManifestService *m_manifest;
|
||||
VersionInstaller *m_installer;
|
||||
|
||||
// Каталог версий пересобирается только по изменениям: QML читает свойство
|
||||
// помногу раз за кадр, пока открыт список.
|
||||
mutable QVariantList m_catalog;
|
||||
mutable bool m_catalogDirty = true;
|
||||
|
||||
bool m_busy = false;
|
||||
|
||||
|
||||
+11
-1
@@ -61,9 +61,19 @@ QString LauncherPaths::runtimeDir()
|
||||
return rootDir() + QStringLiteral("/runtime");
|
||||
}
|
||||
|
||||
QString LauncherPaths::cacheDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/cache");
|
||||
}
|
||||
|
||||
QString LauncherPaths::versionManifestFile()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/version_manifest.json");
|
||||
}
|
||||
|
||||
bool LauncherPaths::ensureRootExists(QString *error)
|
||||
{
|
||||
const QStringList required{rootDir(), runtimeDir()};
|
||||
const QStringList required{rootDir(), runtimeDir(), cacheDir()};
|
||||
for (const QString &path : required) {
|
||||
if (!QDir().mkpath(path)) {
|
||||
if (error)
|
||||
|
||||
@@ -22,6 +22,12 @@ QString versionsFile();
|
||||
// Сюда качается authlib-injector.
|
||||
QString runtimeDir();
|
||||
|
||||
// <root>/cache — данные, которые можно удалить без потерь.
|
||||
QString cacheDir();
|
||||
|
||||
// Слепок манифеста версий Mojang с отметкой времени.
|
||||
QString versionManifestFile();
|
||||
|
||||
// Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед
|
||||
// каждой записью. false и текст в error, если папки нет или в неё не пишется.
|
||||
bool ensureRootExists(QString *error = nullptr);
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
#include "versioninstaller.h"
|
||||
|
||||
#include "gamelauncher.h"
|
||||
#include "versionmanifestservice.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
// Своя очередь, а не тысячи QNetworkReply внутри QNetworkAccessManager: там они
|
||||
// встают в невидимую очередь, каждый со своим объектом и без управления отменой.
|
||||
constexpr int kMaxParallel = 8;
|
||||
constexpr int kMaxAttempts = 3;
|
||||
constexpr int kTransferTimeoutMs = 30000;
|
||||
// Копирование тысяч мелких файлов одним циклом заморозило бы окно на секунды.
|
||||
constexpr int kLegacyCopiesPerTick = 64;
|
||||
// Столько уже имеющихся файлов проверяем за один заход, остальное — следующим.
|
||||
constexpr int kSkipsPerTick = 256;
|
||||
constexpr int kMaxInheritDepth = 10;
|
||||
|
||||
const QString kResourcesUrl = QStringLiteral("https://resources.download.minecraft.net/");
|
||||
|
||||
// Хэш из json попадает в путь на диске — принимаем только настоящий sha1.
|
||||
bool isSha1Hex(const QString &value)
|
||||
{
|
||||
if (value.size() != 40)
|
||||
return false;
|
||||
for (const QChar c : value) {
|
||||
if (!((c >= u'0' && c <= u'9') || (c >= u'a' && c <= u'f') || (c >= u'A' && c <= u'F')))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Относительный путь из json нельзя подставлять как есть: «../..» увёл бы
|
||||
// запись за пределы .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.isEmpty() || part == QLatin1String(".") || part == QLatin1String(".."))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Одиночный сегмент: id версии, id индекса ресурсов, имя конфига логирования.
|
||||
bool isSafeRelativeSegment(const QString &value)
|
||||
{
|
||||
return !value.contains(u'/') && isSafeRelativePath(value);
|
||||
}
|
||||
|
||||
bool isTransient(QNetworkReply::NetworkError error, int httpStatus)
|
||||
{
|
||||
if (httpStatus == 429 || httpStatus >= 500)
|
||||
return true;
|
||||
switch (error) {
|
||||
case QNetworkReply::RemoteHostClosedError:
|
||||
case QNetworkReply::TimeoutError:
|
||||
case QNetworkReply::TemporaryNetworkFailureError:
|
||||
case QNetworkReply::NetworkSessionFailedError:
|
||||
case QNetworkReply::UnknownNetworkError:
|
||||
case QNetworkReply::ProxyTimeoutError:
|
||||
case QNetworkReply::ContentReSendError:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QString fileNameOf(const QString &path)
|
||||
{
|
||||
return QFileInfo(path).fileName();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VersionInstaller::VersionInstaller(VersionManifestService *manifest, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_manifest(manifest)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
, m_progressTimer(new QTimer(this))
|
||||
, m_legacyTimer(new QTimer(this))
|
||||
{
|
||||
// Восемь параллельных ответов шлют downloadProgress на каждый кусок данных:
|
||||
// без склейки привязки в QML пересчитывались бы сотни раз в секунду.
|
||||
m_progressTimer->setInterval(100);
|
||||
connect(m_progressTimer, &QTimer::timeout, this, &VersionInstaller::flushProgress);
|
||||
|
||||
m_legacyTimer->setInterval(0);
|
||||
connect(m_legacyTimer, &QTimer::timeout, this, &VersionInstaller::legacyTick);
|
||||
}
|
||||
|
||||
VersionInstaller::~VersionInstaller()
|
||||
{
|
||||
// Из деструктора сигналы уже некому слушать, а QSaveFile сам выбросит
|
||||
// недописанные временные файлы.
|
||||
blockSignals(true);
|
||||
cancel();
|
||||
}
|
||||
|
||||
// ── Прогресс ───────────────────────────────────────────────────────────────
|
||||
|
||||
qint64 VersionInstaller::bytesDone() const
|
||||
{
|
||||
qint64 total = m_bytesSettled;
|
||||
for (const auto &context : m_active)
|
||||
total += context->received;
|
||||
return total;
|
||||
}
|
||||
|
||||
double VersionInstaller::fraction() const
|
||||
{
|
||||
if (m_bytesTotal <= 0)
|
||||
return -1.0;
|
||||
const double value = double(bytesDone()) / double(m_bytesTotal);
|
||||
return std::clamp(value, 0.0, 1.0);
|
||||
}
|
||||
|
||||
void VersionInstaller::setStage(const QString &stage)
|
||||
{
|
||||
m_stage = stage;
|
||||
scheduleProgress();
|
||||
}
|
||||
|
||||
void VersionInstaller::scheduleProgress()
|
||||
{
|
||||
m_progressDirty = true;
|
||||
if (!m_progressTimer->isActive())
|
||||
m_progressTimer->start();
|
||||
}
|
||||
|
||||
void VersionInstaller::flushProgress()
|
||||
{
|
||||
if (!m_progressDirty) {
|
||||
if (!m_running)
|
||||
m_progressTimer->stop();
|
||||
return;
|
||||
}
|
||||
m_progressDirty = false;
|
||||
emit progressChanged();
|
||||
}
|
||||
|
||||
// ── Очередь установок ──────────────────────────────────────────────────────
|
||||
|
||||
bool VersionInstaller::isQueued(const QString &versionId) const
|
||||
{
|
||||
for (const auto &entry : m_pendingInstalls) {
|
||||
if (entry.second == versionId)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void VersionInstaller::install(const QString &gameDir, const QString &versionId)
|
||||
{
|
||||
const QString id = versionId.trimmed();
|
||||
if (id.isEmpty())
|
||||
return;
|
||||
|
||||
if (!isSafeRelativeSegment(id)) {
|
||||
emit failed(id, tr("Недопустимое имя версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
if (m_canceling) {
|
||||
emit failed(id, tr("Идёт отмена предыдущей загрузки — попробуйте ещё раз"));
|
||||
return;
|
||||
}
|
||||
if (m_versionId == id || isQueued(id))
|
||||
return;
|
||||
|
||||
m_pendingInstalls.enqueue({gameDir, id});
|
||||
if (!m_running)
|
||||
startNext();
|
||||
}
|
||||
|
||||
void VersionInstaller::startNext()
|
||||
{
|
||||
if (m_pendingInstalls.isEmpty()) {
|
||||
m_running = false;
|
||||
m_versionId.clear();
|
||||
m_stage.clear();
|
||||
m_currentLabel.clear();
|
||||
m_progressTimer->stop();
|
||||
emit progressChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entry = m_pendingInstalls.dequeue();
|
||||
m_gameDir = entry.first;
|
||||
m_versionId = entry.second;
|
||||
m_versionJson = {};
|
||||
m_version = {};
|
||||
m_queue.clear();
|
||||
m_warnings.clear();
|
||||
m_assetObjects = {};
|
||||
m_assetsVirtual = false;
|
||||
m_assetsMapToResources = false;
|
||||
m_legacyCopies.clear();
|
||||
m_legacyIndex = 0;
|
||||
m_bytesSettled = 0;
|
||||
m_bytesTotal = 0;
|
||||
m_assetsDeclaredTotal = 0;
|
||||
|
||||
const bool wasRunning = m_running;
|
||||
m_running = true;
|
||||
if (!wasRunning)
|
||||
emit started(m_versionId);
|
||||
setStage(tr("Получение списка версий"));
|
||||
emit progressChanged();
|
||||
|
||||
resolveManifestEntry();
|
||||
}
|
||||
|
||||
void VersionInstaller::resolveManifestEntry()
|
||||
{
|
||||
const QString id = m_versionId;
|
||||
m_manifest->ensureLoaded([this, id](bool ok, const QString &warning) {
|
||||
if (!m_running || m_canceling || m_versionId != id)
|
||||
return;
|
||||
|
||||
if (!ok) {
|
||||
failInstall(warning.isEmpty() ? tr("Список версий Mojang недоступен") : warning);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entry = m_manifest->find(id);
|
||||
if (!entry) {
|
||||
// Обычно это вручную вписанный id сборки — сообщение не должно
|
||||
// выглядеть как сетевая ошибка.
|
||||
failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — "
|
||||
"установить её автоматически нельзя")
|
||||
.arg(id));
|
||||
return;
|
||||
}
|
||||
fetchVersionJson(entry->url, entry->sha1);
|
||||
});
|
||||
}
|
||||
|
||||
void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expectedSha1)
|
||||
{
|
||||
setStage(tr("Описание версии"));
|
||||
m_currentLabel = m_versionId + QStringLiteral(".json");
|
||||
|
||||
QNetworkRequest request{url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
const QString id = m_versionId;
|
||||
QNetworkReply *reply = m_network->get(request);
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, expectedSha1, id] {
|
||||
reply->deleteLater();
|
||||
if (!m_running || m_canceling || m_versionId != id)
|
||||
return;
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
failInstall(tr("Не удалось получить описание версии: %1").arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray payload = reply->readAll();
|
||||
if (!expectedSha1.isEmpty()) {
|
||||
const QString actual = QString::fromLatin1(
|
||||
QCryptographicHash::hash(payload, QCryptographicHash::Sha1).toHex());
|
||||
if (actual.compare(expectedSha1, Qt::CaseInsensitive) != 0) {
|
||||
failInstall(tr("Контрольная сумма описания версии «%1» не совпала").arg(id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
m_versionJson = QJsonDocument::fromJson(payload).object();
|
||||
if (m_versionJson.isEmpty()) {
|
||||
failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString directory = m_gameDir + QStringLiteral("/versions/") + id;
|
||||
if (!QDir().mkpath(directory)) {
|
||||
failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory)));
|
||||
return;
|
||||
}
|
||||
|
||||
QSaveFile file(directory + u'/' + id + QStringLiteral(".json"));
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()
|
||||
|| !file.commit()) {
|
||||
file.cancelWriting();
|
||||
failInstall(tr("Не удалось сохранить описание версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ванильные версии ничего не наследуют, но сборка могла бы — тогда
|
||||
// сначала ставим родителя, а потом возвращаемся к этой версии.
|
||||
const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
||||
if (!parentId.isEmpty() && isSafeRelativeSegment(parentId)
|
||||
&& !QFile::exists(m_gameDir + QStringLiteral("/versions/") + parentId + u'/' + parentId
|
||||
+ QStringLiteral(".json"))) {
|
||||
if (m_inheritDepth >= kMaxInheritDepth) {
|
||||
failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
++m_inheritDepth;
|
||||
m_pendingInstalls.prepend({m_gameDir, id});
|
||||
m_pendingInstalls.prepend({m_gameDir, parentId});
|
||||
abandonCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
m_inheritDepth = 0;
|
||||
if (prepareTasks())
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Составление списка файлов ──────────────────────────────────────────────
|
||||
|
||||
void VersionInstaller::enqueue(DownloadTask task)
|
||||
{
|
||||
if (task.url.scheme() != QLatin1String("https")) {
|
||||
// Подменённое описание версии не должно уводить загрузку на чужой адрес.
|
||||
m_warnings << tr("Пропущен %1: недопустимая ссылка").arg(task.label);
|
||||
return;
|
||||
}
|
||||
m_bytesTotal += task.size;
|
||||
m_queue.enqueue(std::move(task));
|
||||
}
|
||||
|
||||
bool VersionInstaller::prepareTasks()
|
||||
{
|
||||
setStage(tr("Подготовка"));
|
||||
|
||||
QString error;
|
||||
// features пустые намеренно: Mojang отбирает библиотеки только по ОС,
|
||||
// а feature-правила живут в arguments, которые установщику не нужны.
|
||||
m_version = VersionLoader::load(m_gameDir, m_versionId, {}, &error);
|
||||
if (!m_version.isValid()) {
|
||||
failInstall(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(m_versionId)
|
||||
: error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject client = m_versionJson.value(QStringLiteral("downloads"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("client"))
|
||||
.toObject();
|
||||
if (!client.isEmpty()) {
|
||||
DownloadTask task;
|
||||
task.kind = DownloadTask::ClientJar;
|
||||
task.url = QUrl(client.value(QStringLiteral("url")).toString());
|
||||
task.path = m_version.clientJarPath;
|
||||
task.sha1 = client.value(QStringLiteral("sha1")).toString();
|
||||
task.size = client.value(QStringLiteral("size")).toInteger();
|
||||
task.label = fileNameOf(task.path);
|
||||
enqueue(std::move(task));
|
||||
}
|
||||
|
||||
// url, sha1 и size у библиотек уже разобраны VersionLoader — это готовые
|
||||
// описания загрузки, разбирать json заново не нужно.
|
||||
for (const MinecraftLibrary &library : std::as_const(m_version.libraries)) {
|
||||
if (library.url.isEmpty() || !isSafeRelativePath(library.path)) {
|
||||
m_warnings << tr("Нет ссылки на библиотеку %1").arg(library.name);
|
||||
continue;
|
||||
}
|
||||
DownloadTask task;
|
||||
task.kind = DownloadTask::Library;
|
||||
task.url = QUrl(library.url);
|
||||
task.path = m_gameDir + QStringLiteral("/libraries/") + library.path;
|
||||
task.sha1 = library.sha1;
|
||||
task.size = library.size;
|
||||
task.label = library.name;
|
||||
enqueue(std::move(task));
|
||||
}
|
||||
|
||||
const QJsonObject assetIndex = m_versionJson.value(QStringLiteral("assetIndex")).toObject();
|
||||
if (!assetIndex.isEmpty() && isSafeRelativeSegment(m_version.assetIndexId)) {
|
||||
DownloadTask task;
|
||||
task.kind = DownloadTask::AssetIndex;
|
||||
task.url = QUrl(assetIndex.value(QStringLiteral("url")).toString());
|
||||
task.path = m_gameDir + QStringLiteral("/assets/indexes/") + m_version.assetIndexId
|
||||
+ QStringLiteral(".json");
|
||||
task.sha1 = assetIndex.value(QStringLiteral("sha1")).toString();
|
||||
task.size = assetIndex.value(QStringLiteral("size")).toInteger();
|
||||
task.label = tr("индекс ресурсов");
|
||||
enqueue(std::move(task));
|
||||
|
||||
// totalSize — суммарный вес всех ресурсов, известный ещё до скачивания
|
||||
// индекса: без него полоса первые секунды стояла бы и потом прыгнула.
|
||||
m_assetsDeclaredTotal = assetIndex.value(QStringLiteral("totalSize")).toInteger();
|
||||
m_bytesTotal += m_assetsDeclaredTotal;
|
||||
}
|
||||
|
||||
const QJsonObject logging = m_versionJson.value(QStringLiteral("logging"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("client"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("file"))
|
||||
.toObject();
|
||||
const QString loggingId = logging.value(QStringLiteral("id")).toString();
|
||||
if (!logging.isEmpty() && isSafeRelativeSegment(loggingId)) {
|
||||
DownloadTask task;
|
||||
task.kind = DownloadTask::LoggingConfig;
|
||||
task.url = QUrl(logging.value(QStringLiteral("url")).toString());
|
||||
task.path = m_gameDir + QStringLiteral("/assets/log_configs/") + loggingId;
|
||||
task.sha1 = logging.value(QStringLiteral("sha1")).toString();
|
||||
task.size = logging.value(QStringLiteral("size")).toInteger();
|
||||
task.label = loggingId;
|
||||
enqueue(std::move(task));
|
||||
}
|
||||
|
||||
setStage(tr("Файлы игры"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Движок загрузки ────────────────────────────────────────────────────────
|
||||
|
||||
void VersionInstaller::schedulePump()
|
||||
{
|
||||
if (m_pumpScheduled)
|
||||
return;
|
||||
m_pumpScheduled = true;
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
m_pumpScheduled = false;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
void VersionInstaller::pump()
|
||||
{
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
|
||||
int skipped = 0;
|
||||
while (m_active.size() < kMaxParallel && !m_queue.isEmpty()) {
|
||||
const int before = m_active.size();
|
||||
startTask(m_queue.dequeue());
|
||||
// startTask мог провалить установку — тогда очередь уже разобрана.
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
if (m_active.size() == before) {
|
||||
// Файл уже был на месте — сотни таких проверок подряд ощутимы,
|
||||
// поэтому остаток берём следующим проходом событийного цикла.
|
||||
if (++skipped >= kSkipsPerTick && !m_queue.isEmpty()) {
|
||||
schedulePump();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (m_queue.isEmpty() && m_active.isEmpty())
|
||||
afterDownloads();
|
||||
}
|
||||
|
||||
void VersionInstaller::startTask(DownloadTask task)
|
||||
{
|
||||
const QFileInfo info(task.path);
|
||||
if (info.exists() && !m_verifyExisting && (task.size == 0 || info.size() == task.size)) {
|
||||
m_bytesSettled += task.size;
|
||||
if (task.kind == DownloadTask::AssetIndex)
|
||||
expandAssetIndex(task.path);
|
||||
scheduleProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
// Временный файл QSaveFile ложится рядом с целевым, поэтому папка нужна
|
||||
// даже до открытия.
|
||||
if (!QDir().mkpath(info.absolutePath())) {
|
||||
failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath())));
|
||||
return;
|
||||
}
|
||||
|
||||
auto context = std::make_shared<ActiveDownload>();
|
||||
context->task = task;
|
||||
context->file = std::make_unique<QSaveFile>(task.path);
|
||||
if (!context->file->open(QIODevice::WriteOnly)) {
|
||||
failInstall(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(task.path)));
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkRequest request{task.url};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
// Без таймаута зависший сокет подвесил бы установку навсегда.
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
context->reply = m_network->get(request);
|
||||
m_active.append(context);
|
||||
m_currentLabel = task.label;
|
||||
|
||||
connect(context->reply, &QNetworkReply::readyRead, this, [this, context] {
|
||||
const QByteArray chunk = context->reply->readAll();
|
||||
if (chunk.isEmpty())
|
||||
return;
|
||||
context->hash.addData(chunk);
|
||||
if (context->file->write(chunk) != chunk.size())
|
||||
context->reply->abort();
|
||||
});
|
||||
connect(context->reply, &QNetworkReply::downloadProgress, this,
|
||||
[this, context](qint64 received, qint64) {
|
||||
context->received = received;
|
||||
scheduleProgress();
|
||||
});
|
||||
connect(context->reply, &QNetworkReply::finished, this,
|
||||
[this, context] { handleReplyFinished(context); });
|
||||
|
||||
scheduleProgress();
|
||||
}
|
||||
|
||||
void VersionInstaller::handleReplyFinished(const std::shared_ptr<ActiveDownload> &context)
|
||||
{
|
||||
context->reply->deleteLater();
|
||||
m_active.removeOne(context);
|
||||
|
||||
if (m_canceling) {
|
||||
context->file->cancelWriting();
|
||||
if (m_active.isEmpty())
|
||||
finalizeCancel();
|
||||
return;
|
||||
}
|
||||
if (!m_running)
|
||||
return;
|
||||
|
||||
const int status =
|
||||
context->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
|
||||
if (context->reply->error() != QNetworkReply::NoError) {
|
||||
context->file->cancelWriting();
|
||||
const QString reason = context->reply->errorString();
|
||||
if (isTransient(context->reply->error(), status)) {
|
||||
if (!retryOrFail(context->task, reason))
|
||||
return;
|
||||
} else {
|
||||
failInstall(tr("Не удалось скачать %1: %2").arg(context->task.label, reason));
|
||||
return;
|
||||
}
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context->task.sha1.isEmpty()) {
|
||||
const QString actual = QString::fromLatin1(context->hash.result().toHex());
|
||||
if (actual.compare(context->task.sha1, Qt::CaseInsensitive) != 0) {
|
||||
context->file->cancelWriting();
|
||||
if (!retryOrFail(context->task, tr("контрольная сумма не совпала")))
|
||||
return;
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!context->file->commit()) {
|
||||
failInstall(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path)));
|
||||
return;
|
||||
}
|
||||
|
||||
m_bytesSettled += context->received;
|
||||
if (context->task.kind == DownloadTask::AssetIndex)
|
||||
expandAssetIndex(context->task.path);
|
||||
|
||||
scheduleProgress();
|
||||
pump();
|
||||
}
|
||||
|
||||
// Возвращает true, если задача поставлена на повтор, и false, если установка
|
||||
// уже провалена.
|
||||
bool VersionInstaller::retryOrFail(DownloadTask task, const QString &reason)
|
||||
{
|
||||
if (task.attempts + 1 >= kMaxAttempts) {
|
||||
failInstall(tr("Не удалось скачать %1: %2").arg(task.label, reason));
|
||||
return false;
|
||||
}
|
||||
|
||||
++task.attempts;
|
||||
const int delay = 400 * task.attempts;
|
||||
QTimer::singleShot(delay, this, [this, task] {
|
||||
if (!m_running || m_canceling)
|
||||
return;
|
||||
// В начало очереди: иначе повтор ждал бы за тысячами ресурсов.
|
||||
m_queue.prepend(task);
|
||||
pump();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void VersionInstaller::expandAssetIndex(const QString &indexPath)
|
||||
{
|
||||
QFile file(indexPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
failInstall(tr("Не удалось прочитать индекс ресурсов %1")
|
||||
.arg(QDir::toNativeSeparators(indexPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
||||
m_assetObjects = root.value(QStringLiteral("objects")).toObject();
|
||||
m_assetsVirtual = root.value(QStringLiteral("virtual")).toBool();
|
||||
m_assetsMapToResources = root.value(QStringLiteral("map_to_resources")).toBool();
|
||||
|
||||
setStage(tr("Ресурсы"));
|
||||
|
||||
qint64 actualTotal = 0;
|
||||
for (auto it = m_assetObjects.constBegin(); it != m_assetObjects.constEnd(); ++it) {
|
||||
const QJsonObject object = it.value().toObject();
|
||||
const QString hash = object.value(QStringLiteral("hash")).toString();
|
||||
if (!isSha1Hex(hash)) {
|
||||
m_warnings << tr("Пропущен ресурс %1: некорректный хэш").arg(it.key());
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString prefix = hash.left(2);
|
||||
DownloadTask task;
|
||||
task.kind = DownloadTask::AssetObject;
|
||||
task.url = QUrl(kResourcesUrl + prefix + u'/' + hash);
|
||||
task.path = m_gameDir + QStringLiteral("/assets/objects/") + prefix + u'/' + hash;
|
||||
task.sha1 = hash;
|
||||
task.size = object.value(QStringLiteral("size")).toInteger();
|
||||
task.label = it.key();
|
||||
actualTotal += task.size;
|
||||
enqueue(std::move(task));
|
||||
}
|
||||
|
||||
// enqueue уже прибавил вес ресурсов к итогу — снимаем заявленный заранее,
|
||||
// чтобы он не учитывался дважды.
|
||||
m_bytesTotal -= m_assetsDeclaredTotal;
|
||||
m_assetsDeclaredTotal = actualTotal;
|
||||
scheduleProgress();
|
||||
}
|
||||
|
||||
// ── Завершение ─────────────────────────────────────────────────────────────
|
||||
|
||||
void VersionInstaller::afterDownloads()
|
||||
{
|
||||
const bool legacy = m_assetsVirtual || m_assetsMapToResources
|
||||
|| m_version.assetsKind == QLatin1String("legacy")
|
||||
|| m_version.assetsKind == QLatin1String("pre-1.6");
|
||||
if (legacy && !m_assetObjects.isEmpty() && m_legacyCopies.isEmpty() && m_legacyIndex == 0) {
|
||||
startLegacyCopy();
|
||||
return;
|
||||
}
|
||||
complete();
|
||||
}
|
||||
|
||||
void VersionInstaller::startLegacyCopy()
|
||||
{
|
||||
setStage(tr("Подготовка ресурсов"));
|
||||
|
||||
const QString targetRoot = m_assetsMapToResources
|
||||
? m_gameDir + QStringLiteral("/resources/")
|
||||
: m_gameDir + QStringLiteral("/assets/virtual/legacy/");
|
||||
|
||||
for (auto it = m_assetObjects.constBegin(); it != m_assetObjects.constEnd(); ++it) {
|
||||
const QString name = it.key();
|
||||
const QString hash = it.value().toObject().value(QStringLiteral("hash")).toString();
|
||||
if (!isSha1Hex(hash) || !isSafeRelativePath(name))
|
||||
continue;
|
||||
m_legacyCopies.append({m_gameDir + QStringLiteral("/assets/objects/") + hash.left(2) + u'/'
|
||||
+ hash,
|
||||
targetRoot + name});
|
||||
}
|
||||
|
||||
m_legacyIndex = 0;
|
||||
if (m_legacyCopies.isEmpty()) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
m_legacyTimer->start();
|
||||
}
|
||||
|
||||
void VersionInstaller::legacyTick()
|
||||
{
|
||||
if (!m_running || m_canceling) {
|
||||
m_legacyTimer->stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const int end = std::min(m_legacyIndex + kLegacyCopiesPerTick, int(m_legacyCopies.size()));
|
||||
for (; m_legacyIndex < end; ++m_legacyIndex) {
|
||||
const auto &pair = m_legacyCopies.at(m_legacyIndex);
|
||||
const QFileInfo source(pair.first);
|
||||
const QFileInfo target(pair.second);
|
||||
if (!source.exists())
|
||||
continue;
|
||||
if (target.exists() && target.size() == source.size())
|
||||
continue;
|
||||
|
||||
QDir().mkpath(target.absolutePath());
|
||||
QFile::remove(pair.second);
|
||||
if (!QFile::copy(pair.first, pair.second))
|
||||
m_warnings << tr("Не удалось подготовить ресурс %1").arg(target.fileName());
|
||||
}
|
||||
|
||||
m_currentLabel = tr("%1 из %2").arg(m_legacyIndex).arg(m_legacyCopies.size());
|
||||
scheduleProgress();
|
||||
|
||||
if (m_legacyIndex >= m_legacyCopies.size()) {
|
||||
m_legacyTimer->stop();
|
||||
complete();
|
||||
}
|
||||
}
|
||||
|
||||
void VersionInstaller::complete()
|
||||
{
|
||||
// Приёмка той же проверкой, которой пользуется запуск: успешная установка
|
||||
// тогда по построению запускаема.
|
||||
LaunchOptions probe;
|
||||
probe.gameDir = m_gameDir;
|
||||
probe.versionId = m_versionId;
|
||||
const QStringList missing = GameLauncher::missingFiles(probe, m_version);
|
||||
if (!missing.isEmpty()) {
|
||||
failInstall(tr("После загрузки не хватает файлов (%1):\n%2")
|
||||
.arg(missing.size())
|
||||
.arg(missing.join(u'\n')));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString id = m_versionId;
|
||||
resetInstallState();
|
||||
emit finished(id);
|
||||
startNext();
|
||||
}
|
||||
|
||||
void VersionInstaller::resetInstallState()
|
||||
{
|
||||
m_queue.clear();
|
||||
m_active.clear();
|
||||
m_assetObjects = {};
|
||||
m_legacyCopies.clear();
|
||||
m_legacyIndex = 0;
|
||||
m_legacyTimer->stop();
|
||||
m_versionId.clear();
|
||||
m_stage.clear();
|
||||
m_currentLabel.clear();
|
||||
m_running = false;
|
||||
m_progressDirty = false;
|
||||
m_progressTimer->stop();
|
||||
}
|
||||
|
||||
void VersionInstaller::failInstall(const QString &message)
|
||||
{
|
||||
const QString id = m_versionId;
|
||||
QString text = message;
|
||||
if (!m_warnings.isEmpty())
|
||||
text += u'\n' + m_warnings.mid(0, 3).join(u'\n');
|
||||
|
||||
// Остальные версии из очереди уже некуда ставить — их пути могли зависеть
|
||||
// от этой, да и пользователю нужен один внятный ответ.
|
||||
m_pendingInstalls.clear();
|
||||
m_inheritDepth = 0;
|
||||
for (const auto &context : std::as_const(m_active)) {
|
||||
if (context->reply)
|
||||
context->reply->abort();
|
||||
context->file->cancelWriting();
|
||||
}
|
||||
resetInstallState();
|
||||
emit failed(id, text);
|
||||
}
|
||||
|
||||
void VersionInstaller::abandonCurrent()
|
||||
{
|
||||
// Версия уступает очередь родителю: сигналов нет, установка продолжается.
|
||||
m_queue.clear();
|
||||
m_active.clear();
|
||||
m_versionId.clear();
|
||||
startNext();
|
||||
}
|
||||
|
||||
void VersionInstaller::cancel()
|
||||
{
|
||||
if (!m_running)
|
||||
return;
|
||||
|
||||
m_pendingInstalls.clear();
|
||||
m_queue.clear();
|
||||
m_legacyTimer->stop();
|
||||
m_inheritDepth = 0;
|
||||
m_canceling = true;
|
||||
|
||||
// abort() может доставить finished синхронно, а обработчик стирает элемент
|
||||
// из m_active — поэтому обходим копию.
|
||||
const auto active = m_active;
|
||||
for (const auto &context : active) {
|
||||
if (context->reply)
|
||||
context->reply->abort();
|
||||
}
|
||||
// abort() мог довести отмену до конца прямо здесь — тогда m_canceling уже снят.
|
||||
if (m_canceling && m_active.isEmpty())
|
||||
finalizeCancel();
|
||||
}
|
||||
|
||||
void VersionInstaller::finalizeCancel()
|
||||
{
|
||||
const QString id = m_versionId;
|
||||
m_canceling = false;
|
||||
resetInstallState();
|
||||
emit canceled(id);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
#pragma once
|
||||
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QQueue>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <memory>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
class QSaveFile;
|
||||
class QTimer;
|
||||
class VersionManifestService;
|
||||
|
||||
// Один файл, который нужно положить в .minecraft.
|
||||
struct DownloadTask
|
||||
{
|
||||
enum Kind { ClientJar, Library, AssetIndex, AssetObject, LoggingConfig };
|
||||
|
||||
Kind kind = ClientJar;
|
||||
QUrl url;
|
||||
QString path; // абсолютный путь назначения
|
||||
QString sha1; // пусто — не проверяем
|
||||
qint64 size = 0; // 0 — размер неизвестен
|
||||
QString label; // что показать в панели прогресса
|
||||
int attempts = 0;
|
||||
};
|
||||
|
||||
// Файл в процессе скачивания. Пишем потоком: держать десятки мегабайт в памяти
|
||||
// незачем, а нескольких параллельных загрузок хватило бы на сотни.
|
||||
struct ActiveDownload
|
||||
{
|
||||
DownloadTask task;
|
||||
QNetworkReply *reply = nullptr;
|
||||
std::unique_ptr<QSaveFile> file;
|
||||
QCryptographicHash hash{QCryptographicHash::Sha1};
|
||||
qint64 received = 0;
|
||||
};
|
||||
|
||||
// Фоновая установка версии в .minecraft: описание версии, клиентский jar,
|
||||
// библиотеки, индекс ресурсов и сами ресурсы. Одна версия за раз, остальные
|
||||
// ждут в очереди. Всё в GUI-потоке, как и остальной сетевой код лаунчера.
|
||||
class VersionInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit VersionInstaller(VersionManifestService *manifest, QObject *parent = nullptr);
|
||||
~VersionInstaller() override;
|
||||
|
||||
bool isRunning() const { return m_running; }
|
||||
QString versionId() const { return m_versionId; }
|
||||
QString stage() const { return m_stage; }
|
||||
QString currentFile() const { return m_currentLabel; }
|
||||
|
||||
qint64 bytesDone() const;
|
||||
qint64 bytesTotal() const { return m_bytesTotal; }
|
||||
double fraction() const; // 0..1, либо -1 пока итог неизвестен
|
||||
|
||||
void install(const QString &gameDir, const QString &versionId);
|
||||
bool isQueued(const QString &versionId) const;
|
||||
void cancel();
|
||||
|
||||
signals:
|
||||
void started(const QString &versionId);
|
||||
void progressChanged(); // не чаще десяти раз в секунду
|
||||
void finished(const QString &versionId);
|
||||
void failed(const QString &versionId, const QString &message);
|
||||
void canceled(const QString &versionId);
|
||||
|
||||
private:
|
||||
void startNext();
|
||||
void resolveManifestEntry();
|
||||
void fetchVersionJson(const QUrl &url, const QString &expectedSha1);
|
||||
bool prepareTasks();
|
||||
void enqueue(DownloadTask task);
|
||||
|
||||
void schedulePump();
|
||||
void pump();
|
||||
void startTask(DownloadTask task);
|
||||
void handleReplyFinished(const std::shared_ptr<ActiveDownload> &context);
|
||||
bool retryOrFail(DownloadTask task, const QString &reason);
|
||||
void expandAssetIndex(const QString &indexPath);
|
||||
|
||||
void afterDownloads();
|
||||
void startLegacyCopy();
|
||||
void legacyTick();
|
||||
void complete();
|
||||
|
||||
void setStage(const QString &stage);
|
||||
void scheduleProgress();
|
||||
void flushProgress();
|
||||
|
||||
void resetInstallState();
|
||||
void failInstall(const QString &message);
|
||||
void abandonCurrent(); // версия уступает место родителю, без сигналов
|
||||
void finalizeCancel();
|
||||
|
||||
VersionManifestService *m_manifest;
|
||||
QNetworkAccessManager *m_network;
|
||||
|
||||
QQueue<QPair<QString, QString>> m_pendingInstalls; // {gameDir, versionId}
|
||||
QString m_gameDir;
|
||||
QString m_versionId;
|
||||
QJsonObject m_versionJson;
|
||||
MinecraftVersion m_version;
|
||||
|
||||
QQueue<DownloadTask> m_queue;
|
||||
QList<std::shared_ptr<ActiveDownload>> m_active;
|
||||
QStringList m_warnings;
|
||||
|
||||
qint64 m_bytesSettled = 0;
|
||||
qint64 m_bytesTotal = 0;
|
||||
qint64 m_assetsDeclaredTotal = 0;
|
||||
|
||||
bool m_running = false;
|
||||
bool m_canceling = false;
|
||||
bool m_pumpScheduled = false;
|
||||
// Существующие файлы сверяются только по размеру: перехеширование сотен
|
||||
// мегабайт при каждом добавлении версии дороже, чем риск битого файла.
|
||||
// Флаг оставлен под будущую кнопку «проверить файлы».
|
||||
bool m_verifyExisting = false;
|
||||
|
||||
QString m_stage;
|
||||
QString m_currentLabel;
|
||||
|
||||
QTimer *m_progressTimer;
|
||||
bool m_progressDirty = false;
|
||||
|
||||
// Ресурсы до 1.6 игра ищет не в assets/objects, а в плоской папке.
|
||||
QJsonObject m_assetObjects;
|
||||
bool m_assetsVirtual = false;
|
||||
bool m_assetsMapToResources = false;
|
||||
QList<QPair<QString, QString>> m_legacyCopies; // {источник, назначение}
|
||||
int m_legacyIndex = 0;
|
||||
QTimer *m_legacyTimer;
|
||||
|
||||
int m_inheritDepth = 0;
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
#include "versionmanifestservice.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kManifestUrl =
|
||||
QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
|
||||
|
||||
// Снапшоты выходят примерно раз в неделю, манифест весит около 250 КБ: шесть
|
||||
// часов — это не больше четырёх запросов в сутки, новая версия видна в тот же
|
||||
// день, а десяток открытий списка за сессию не стоит ни одного обращения к сети.
|
||||
constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60;
|
||||
|
||||
constexpr int kTransferTimeoutMs = 20000;
|
||||
|
||||
QList<RemoteVersionEntry> parseManifest(const QJsonObject &root)
|
||||
{
|
||||
QList<RemoteVersionEntry> list;
|
||||
const QJsonArray versions = root.value(QStringLiteral("versions")).toArray();
|
||||
list.reserve(versions.size());
|
||||
|
||||
for (const QJsonValue &value : versions) {
|
||||
const QJsonObject object = value.toObject();
|
||||
|
||||
RemoteVersionEntry entry;
|
||||
entry.id = object.value(QStringLiteral("id")).toString();
|
||||
entry.type = object.value(QStringLiteral("type")).toString();
|
||||
entry.url = QUrl(object.value(QStringLiteral("url")).toString());
|
||||
entry.sha1 = object.value(QStringLiteral("sha1")).toString();
|
||||
entry.releaseTime = QDateTime::fromString(
|
||||
object.value(QStringLiteral("releaseTime")).toString(), Qt::ISODate);
|
||||
|
||||
// Описание версии качается по этой ссылке, поэтому чужие схемы не берём.
|
||||
if (entry.id.isEmpty() || entry.url.scheme() != QLatin1String("https"))
|
||||
continue;
|
||||
list << entry;
|
||||
}
|
||||
|
||||
// Mojang и так отдаёт новые сверху, но зависеть от этого не стоит.
|
||||
std::stable_sort(list.begin(), list.end(),
|
||||
[](const RemoteVersionEntry &a, const RemoteVersionEntry &b) {
|
||||
if (a.releaseTime != b.releaseTime)
|
||||
return a.releaseTime > b.releaseTime;
|
||||
return a.id < b.id;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VersionManifestService::VersionManifestService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
}
|
||||
|
||||
std::optional<RemoteVersionEntry> VersionManifestService::find(const QString &id) const
|
||||
{
|
||||
const auto it = m_indexById.constFind(id);
|
||||
if (it == m_indexById.constEnd())
|
||||
return std::nullopt;
|
||||
return m_versions.at(it.value());
|
||||
}
|
||||
|
||||
void VersionManifestService::applyVersions(QList<RemoteVersionEntry> list)
|
||||
{
|
||||
m_versions = std::move(list);
|
||||
m_indexById.clear();
|
||||
m_indexById.reserve(m_versions.size());
|
||||
for (int i = 0; i < m_versions.size(); ++i)
|
||||
m_indexById.insert(m_versions.at(i).id, i);
|
||||
}
|
||||
|
||||
void VersionManifestService::ensureLoaded(Callback callback, bool forceRefresh)
|
||||
{
|
||||
if (!m_cacheRead) {
|
||||
m_cacheRead = true;
|
||||
if (readCache())
|
||||
emit versionsChanged();
|
||||
}
|
||||
|
||||
const bool fresh = hasData() && m_fetchedAt.isValid()
|
||||
&& m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
|
||||
if (!forceRefresh && fresh) {
|
||||
// Колбэк, который то синхронный, то нет, ломает вызывающий код —
|
||||
// на попадании в кэш отвечаем через событийный цикл.
|
||||
if (callback)
|
||||
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback)
|
||||
m_pending << std::move(callback);
|
||||
if (m_refreshing)
|
||||
return;
|
||||
startRefresh();
|
||||
}
|
||||
|
||||
void VersionManifestService::startRefresh()
|
||||
{
|
||||
m_refreshing = true;
|
||||
emit refreshingChanged();
|
||||
|
||||
QNetworkRequest request{QUrl(kManifestUrl)};
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
QNetworkReply *reply = m_network->get(request);
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
||||
reply->deleteLater();
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
finishRefresh(false,
|
||||
tr("Не удалось обновить список версий: %1").arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QList<RemoteVersionEntry> parsed =
|
||||
parseManifest(QJsonDocument::fromJson(reply->readAll()).object());
|
||||
// Пустой разбор — испорченный ответ; хороший кэш им не затираем.
|
||||
if (parsed.isEmpty()) {
|
||||
finishRefresh(false, tr("Манифест версий Mojang не удалось разобрать"));
|
||||
return;
|
||||
}
|
||||
|
||||
applyVersions(std::move(parsed));
|
||||
m_fetchedAt = QDateTime::currentDateTimeUtc();
|
||||
writeCache();
|
||||
emit versionsChanged();
|
||||
finishRefresh(true, {});
|
||||
});
|
||||
}
|
||||
|
||||
void VersionManifestService::finishRefresh(bool ok, const QString &warning)
|
||||
{
|
||||
m_refreshing = false;
|
||||
emit refreshingChanged();
|
||||
|
||||
// Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
|
||||
const QList<Callback> pending = std::exchange(m_pending, {});
|
||||
for (const Callback &callback : pending) {
|
||||
// Сеть недоступна, но старые данные есть — работаем на них.
|
||||
if (!ok && hasData())
|
||||
callback(true, warning);
|
||||
else
|
||||
callback(ok, warning);
|
||||
}
|
||||
}
|
||||
|
||||
bool VersionManifestService::readCache()
|
||||
{
|
||||
QFile file(LauncherPaths::versionManifestFile());
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
|
||||
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
||||
QList<RemoteVersionEntry> parsed = parseManifest(root);
|
||||
if (parsed.isEmpty())
|
||||
return false;
|
||||
|
||||
applyVersions(std::move(parsed));
|
||||
m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
|
||||
Qt::ISODate);
|
||||
return true;
|
||||
}
|
||||
|
||||
void VersionManifestService::writeCache()
|
||||
{
|
||||
// Папку кэша могли удалить уже после запуска лаунчера.
|
||||
if (!LauncherPaths::ensureRootExists())
|
||||
return;
|
||||
|
||||
QJsonArray versions;
|
||||
for (const RemoteVersionEntry &entry : std::as_const(m_versions)) {
|
||||
versions << QJsonObject{{QStringLiteral("id"), entry.id},
|
||||
{QStringLiteral("type"), entry.type},
|
||||
{QStringLiteral("url"), entry.url.toString()},
|
||||
{QStringLiteral("sha1"), entry.sha1},
|
||||
{QStringLiteral("releaseTime"),
|
||||
entry.releaseTime.toString(Qt::ISODate)}};
|
||||
}
|
||||
|
||||
// Своя обёртка: в манифесте Mojang нет отметки времени, по которой можно
|
||||
// судить о свежести кэша.
|
||||
const QJsonObject root{{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)},
|
||||
{QStringLiteral("versions"), versions}};
|
||||
|
||||
// QSaveFile: оборванная запись не оставит обрезанный кэш, который потом
|
||||
// пришлось бы отличать от настоящего.
|
||||
QSaveFile file(LauncherPaths::versionManifestFile());
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
if (file.write(payload) != payload.size()) {
|
||||
file.cancelWriting();
|
||||
return;
|
||||
}
|
||||
file.commit();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QJsonObject;
|
||||
|
||||
// Одна строка манифеста Mojang.
|
||||
struct RemoteVersionEntry
|
||||
{
|
||||
QString id; // 1.21.8, 25w33a, b1.7.3
|
||||
QString type; // release | snapshot | old_beta | old_alpha
|
||||
QUrl url; // <id>.json с описанием версии
|
||||
QString sha1; // контрольная сумма самого json
|
||||
QDateTime releaseTime;
|
||||
};
|
||||
|
||||
// Каталог версий Mojang: скачивает манифест, кэширует его в папке лаунчера и
|
||||
// отдаёт из кэша, пока тот не устарел. Работает в GUI-потоке, как AuthService.
|
||||
class VersionManifestService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// ok = true и непустой warning — данные отдали из устаревшего кэша.
|
||||
using Callback = std::function<void(bool ok, const QString &warning)>;
|
||||
|
||||
explicit VersionManifestService(QObject *parent = nullptr);
|
||||
|
||||
QList<RemoteVersionEntry> versions() const { return m_versions; }
|
||||
bool hasData() const { return !m_versions.isEmpty(); }
|
||||
bool isRefreshing() const { return m_refreshing; }
|
||||
QDateTime fetchedAt() const { return m_fetchedAt; }
|
||||
|
||||
// Запись по id — из неё установщик берёт ссылку на описание версии.
|
||||
std::optional<RemoteVersionEntry> find(const QString &id) const;
|
||||
|
||||
// Единственная точка входа. Свежий кэш — колбэк отложенно, иначе один
|
||||
// сетевой запрос на всех, кто успел попросить.
|
||||
void ensureLoaded(Callback callback, bool forceRefresh = false);
|
||||
|
||||
signals:
|
||||
void versionsChanged();
|
||||
void refreshingChanged();
|
||||
|
||||
private:
|
||||
bool readCache();
|
||||
void writeCache();
|
||||
void startRefresh();
|
||||
void finishRefresh(bool ok, const QString &warning);
|
||||
void applyVersions(QList<RemoteVersionEntry> list);
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
QList<RemoteVersionEntry> m_versions;
|
||||
QHash<QString, int> m_indexById;
|
||||
QDateTime m_fetchedAt;
|
||||
bool m_refreshing = false;
|
||||
bool m_cacheRead = false;
|
||||
QList<Callback> m_pending;
|
||||
};
|
||||
Reference in New Issue
Block a user