version tracking and auto installing

This commit is contained in:
2026-08-24 21:09:52 +03:00
parent 671fbe169d
commit 5bc59f8018
10 changed files with 1937 additions and 24 deletions
+4
View File
@@ -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
+391 -21
View File
@@ -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
View File
@@ -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,8 +739,10 @@ 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
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -22,6 +22,12 @@ QString versionsFile();
// Сюда качается authlib-injector.
QString runtimeDir();
// <root>/cache — данные, которые можно удалить без потерь.
QString cacheDir();
// Слепок манифеста версий Mojang с отметкой времени.
QString versionManifestFile();
// Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед
// каждой записью. false и текст в error, если папки нет или в неё не пишется.
bool ensureRootExists(QString *error = nullptr);
+809
View File
@@ -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);
}
+147
View File
@@ -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;
};
+214
View File
@@ -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();
}
+68
View File
@@ -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;
};