#include "versionmanifestservice.h" #include "localization.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, Loc::text("version.error.manifestFetchFailed").arg(reply->errorString())); return; } QList parsed = parseManifest(QJsonDocument::fromJson(reply->readAll()).object()); // Пустой разбор — испорченный ответ; хороший кэш им не затираем. if (parsed.isEmpty()) { finishRefresh(false, Loc::text("version.error.manifestUnparsable")); 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(); }