diff --git a/CMakeLists.txt b/CMakeLists.txt index db6fa0a..1bdc0e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/Main.qml b/Main.qml index f51c219..3afa0e0 100644 --- a/Main.qml +++ b/Main.qml @@ -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 diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 64f0bb5..b4a0668 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -4,6 +4,8 @@ #include "gamelauncher.h" #include "javalocator.h" #include "launcherpaths.h" +#include "versioninstaller.h" +#include "versionmanifestservice.h" #include #include @@ -11,7 +13,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -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 remote = m_manifest->versions(); + QHash 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 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 diff --git a/launcherbackend.h b/launcherbackend.h index 099a40c..ee010a4 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -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; diff --git a/launcherpaths.cpp b/launcherpaths.cpp index d460112..2519aa7 100644 --- a/launcherpaths.cpp +++ b/launcherpaths.cpp @@ -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) diff --git a/launcherpaths.h b/launcherpaths.h index 11bd77b..21afea4 100644 --- a/launcherpaths.h +++ b/launcherpaths.h @@ -22,6 +22,12 @@ QString versionsFile(); // Сюда качается authlib-injector. QString runtimeDir(); +// /cache — данные, которые можно удалить без потерь. +QString cacheDir(); + +// Слепок манифеста версий Mojang с отметкой времени. +QString versionManifestFile(); + // Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед // каждой записью. false и текст в error, если папки нет или в неё не пишется. bool ensureRootExists(QString *error = nullptr); diff --git a/versioninstaller.cpp b/versioninstaller.cpp new file mode 100644 index 0000000..f5edc6d --- /dev/null +++ b/versioninstaller.cpp @@ -0,0 +1,809 @@ +#include "versioninstaller.h" + +#include "gamelauncher.h" +#include "versionmanifestservice.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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(); + context->task = task; + context->file = std::make_unique(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 &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); +} diff --git a/versioninstaller.h b/versioninstaller.h new file mode 100644 index 0000000..3a2801e --- /dev/null +++ b/versioninstaller.h @@ -0,0 +1,147 @@ +#pragma once + +#include "minecraftversion.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 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 &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> m_pendingInstalls; // {gameDir, versionId} + QString m_gameDir; + QString m_versionId; + QJsonObject m_versionJson; + MinecraftVersion m_version; + + QQueue m_queue; + QList> 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> m_legacyCopies; // {источник, назначение} + int m_legacyIndex = 0; + QTimer *m_legacyTimer; + + int m_inheritDepth = 0; +}; diff --git a/versionmanifestservice.cpp b/versionmanifestservice.cpp new file mode 100644 index 0000000..31a0816 --- /dev/null +++ b/versionmanifestservice.cpp @@ -0,0 +1,214 @@ +#include "versionmanifestservice.h" + +#include "launcherpaths.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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 parseManifest(const QJsonObject &root) +{ + QList 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 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 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 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 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 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(); +} diff --git a/versionmanifestservice.h b/versionmanifestservice.h new file mode 100644 index 0000000..2a7a4f2 --- /dev/null +++ b/versionmanifestservice.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +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; // .json с описанием версии + QString sha1; // контрольная сумма самого json + QDateTime releaseTime; +}; + +// Каталог версий Mojang: скачивает манифест, кэширует его в папке лаунчера и +// отдаёт из кэша, пока тот не устарел. Работает в GUI-потоке, как AuthService. +class VersionManifestService : public QObject +{ + Q_OBJECT + +public: + // ok = true и непустой warning — данные отдали из устаревшего кэша. + using Callback = std::function; + + explicit VersionManifestService(QObject *parent = nullptr); + + QList 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 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 list); + + QNetworkAccessManager *m_network; + QList m_versions; + QHash m_indexById; + QDateTime m_fetchedAt; + bool m_refreshing = false; + bool m_cacheRead = false; + QList m_pending; +};