#include "versioninstaller.h" #include "localization.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); } QString versionJsonPath(const QString &gameDir, const QString &id) { return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json"); } bool isTransient(QNetworkReply::NetworkError error, int httpStatus) { if (httpStatus == 429 || httpStatus >= 500) 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, Loc::text("version.error.invalidName").arg(id)); return; } if (m_canceling) { emit failed(id, Loc::text("version.error.cancelPending")); 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(Loc::text("version.progress.fetchingList")); 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) { QFile local(versionJsonPath(m_gameDir, id)); if (local.open(QIODevice::ReadOnly)) { adoptVersionJson(local.readAll(), /*alreadySaved=*/true); return; } failInstall(warning.isEmpty() ? Loc::text("version.error.listUnavailable") : warning); return; } const auto entry = m_manifest->find(id); if (entry) { fetchVersionJson(entry->url, entry->sha1); return; } // Версии нет в манифесте, но описание могло уже лечь на диск: так // ставятся сборки модлоадеров, чьи id Mojang не знает. QFile local(versionJsonPath(m_gameDir, id)); if (local.open(QIODevice::ReadOnly)) { adoptVersionJson(local.readAll(), /*alreadySaved=*/true); return; } // Иначе это вручную вписанный id сборки — сообщение не должно // выглядеть как сетевая ошибка. failInstall(Loc::text("version.error.notInManifest") .arg(id)); }); } void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expectedSha1) { setStage(Loc::text("version.progress.description")); 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(Loc::text("version.error.descriptionFetchFailed").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(Loc::text("version.error.descriptionChecksum").arg(id)); return; } } adoptVersionJson(payload, /*alreadySaved=*/false); }); } // Общий хвост для описания версии, откуда бы оно ни пришло: из сети или уже // лежащим на диске (сборки модлоадеров кладут его туда сами). void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadySaved) { const QString id = m_versionId; m_versionJson = QJsonDocument::fromJson(payload).object(); if (m_versionJson.isEmpty()) { failInstall(Loc::text("version.error.descriptionUnparsable").arg(id)); return; } if (!alreadySaved) { const QString directory = m_gameDir + QStringLiteral("/versions/") + id; if (!QDir().mkpath(directory)) { failInstall(Loc::text("common.error.createFailed").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(Loc::text("common.error.versionSaveFailed").arg(id)); return; } } // Ванильные версии ничего не наследуют, но сборка могла бы — тогда // сначала ставим родителя, а потом возвращаемся к этой версии. const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString(); if (!parentId.isEmpty() && isSafeRelativeSegment(parentId) && !QFile::exists(versionJsonPath(m_gameDir, parentId))) { if (m_inheritDepth >= kMaxInheritDepth) { failInstall(Loc::text("common.error.inheritsChainTooLong").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 << Loc::text("version.warning.skippedBadUrl").arg(task.label); return; } m_bytesTotal += task.size; m_queue.enqueue(std::move(task)); } bool VersionInstaller::prepareTasks() { setStage(Loc::text("version.progress.preparing")); QString error; // features пустые намеренно: Mojang отбирает библиотеки только по ОС, // а feature-правила живут в arguments, которые установщику не нужны. m_version = VersionLoader::load(m_gameDir, m_versionId, {}, &error); if (!m_version.isValid()) { failInstall(error.isEmpty() ? Loc::text("common.error.versionReadFailed").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 << Loc::text("version.error.libraryNoUrl").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 = Loc::text("version.progress.assetIndex"); 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(Loc::text("version.progress.gameFiles")); 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(Loc::text("common.error.createFailed").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(Loc::text("common.error.openForWriteFailed").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(Loc::text("common.error.downloadFailed").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, Loc::text("common.error.checksumMismatch"))) return; pump(); return; } } if (!context->file->commit()) { failInstall(Loc::text("common.error.saveFailed").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(Loc::text("common.error.downloadFailed").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(Loc::text("version.error.assetIndexReadFailed") .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(Loc::text("version.progress.assets")); 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 << Loc::text("version.warning.skippedBadHash").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(Loc::text("version.progress.preparingAssets")); 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 << Loc::text("version.error.assetPrepareFailed").arg(target.fileName()); } m_currentLabel = Loc::text("version.progress.countOf").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(Loc::text("version.error.filesMissingAfterDownload") .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); }