Files
minecraft-launcher/versionmanifestservice.cpp
T

216 lines
7.9 KiB
C++
Raw Normal View History

2026-08-24 21:09:52 +03:00
#include "versionmanifestservice.h"
#include "localization.h"
2026-08-24 21:09:52 +03:00
#include "launcherpaths.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QTimer>
#include <algorithm>
#include <utility>
namespace {
const QString kManifestUrl =
QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
// Снапшоты выходят примерно раз в неделю, манифест весит около 250 КБ: шесть
// часов — это не больше четырёх запросов в сутки, новая версия видна в тот же
// день, а десяток открытий списка за сессию не стоит ни одного обращения к сети.
constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60;
constexpr int kTransferTimeoutMs = 20000;
QList<RemoteVersionEntry> parseManifest(const QJsonObject &root)
{
QList<RemoteVersionEntry> list;
const QJsonArray versions = root.value(QStringLiteral("versions")).toArray();
list.reserve(versions.size());
for (const QJsonValue &value : versions) {
const QJsonObject object = value.toObject();
RemoteVersionEntry entry;
entry.id = object.value(QStringLiteral("id")).toString();
entry.type = object.value(QStringLiteral("type")).toString();
entry.url = QUrl(object.value(QStringLiteral("url")).toString());
entry.sha1 = object.value(QStringLiteral("sha1")).toString();
entry.releaseTime = QDateTime::fromString(
object.value(QStringLiteral("releaseTime")).toString(), Qt::ISODate);
// Описание версии качается по этой ссылке, поэтому чужие схемы не берём.
if (entry.id.isEmpty() || entry.url.scheme() != QLatin1String("https"))
continue;
list << entry;
}
// Mojang и так отдаёт новые сверху, но зависеть от этого не стоит.
std::stable_sort(list.begin(), list.end(),
[](const RemoteVersionEntry &a, const RemoteVersionEntry &b) {
if (a.releaseTime != b.releaseTime)
return a.releaseTime > b.releaseTime;
return a.id < b.id;
});
return list;
}
} // namespace
VersionManifestService::VersionManifestService(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
{
}
std::optional<RemoteVersionEntry> VersionManifestService::find(const QString &id) const
{
const auto it = m_indexById.constFind(id);
if (it == m_indexById.constEnd())
return std::nullopt;
return m_versions.at(it.value());
}
void VersionManifestService::applyVersions(QList<RemoteVersionEntry> list)
{
m_versions = std::move(list);
m_indexById.clear();
m_indexById.reserve(m_versions.size());
for (int i = 0; i < m_versions.size(); ++i)
m_indexById.insert(m_versions.at(i).id, i);
}
void VersionManifestService::ensureLoaded(Callback callback, bool forceRefresh)
{
if (!m_cacheRead) {
m_cacheRead = true;
if (readCache())
emit versionsChanged();
}
const bool fresh = hasData() && m_fetchedAt.isValid()
&& m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
if (!forceRefresh && fresh) {
// Колбэк, который то синхронный, то нет, ломает вызывающий код —
// на попадании в кэш отвечаем через событийный цикл.
if (callback)
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
return;
}
if (callback)
m_pending << std::move(callback);
if (m_refreshing)
return;
startRefresh();
}
void VersionManifestService::startRefresh()
{
m_refreshing = true;
emit refreshingChanged();
QNetworkRequest request{QUrl(kManifestUrl)};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
QNetworkReply *reply = m_network->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply] {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
finishRefresh(false,
Loc::text("version.error.manifestFetchFailed").arg(reply->errorString()));
2026-08-24 21:09:52 +03:00
return;
}
QList<RemoteVersionEntry> parsed =
parseManifest(QJsonDocument::fromJson(reply->readAll()).object());
// Пустой разбор — испорченный ответ; хороший кэш им не затираем.
if (parsed.isEmpty()) {
finishRefresh(false, Loc::text("version.error.manifestUnparsable"));
2026-08-24 21:09:52 +03:00
return;
}
applyVersions(std::move(parsed));
m_fetchedAt = QDateTime::currentDateTimeUtc();
writeCache();
emit versionsChanged();
finishRefresh(true, {});
});
}
void VersionManifestService::finishRefresh(bool ok, const QString &warning)
{
m_refreshing = false;
emit refreshingChanged();
// Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
const QList<Callback> pending = std::exchange(m_pending, {});
for (const Callback &callback : pending) {
// Сеть недоступна, но старые данные есть — работаем на них.
if (!ok && hasData())
callback(true, warning);
else
callback(ok, warning);
}
}
bool VersionManifestService::readCache()
{
QFile file(LauncherPaths::versionManifestFile());
if (!file.open(QIODevice::ReadOnly))
return false;
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QList<RemoteVersionEntry> parsed = parseManifest(root);
if (parsed.isEmpty())
return false;
applyVersions(std::move(parsed));
m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
Qt::ISODate);
return true;
}
void VersionManifestService::writeCache()
{
// Папку кэша могли удалить уже после запуска лаунчера.
if (!LauncherPaths::ensureRootExists())
return;
QJsonArray versions;
for (const RemoteVersionEntry &entry : std::as_const(m_versions)) {
versions << QJsonObject{{QStringLiteral("id"), entry.id},
{QStringLiteral("type"), entry.type},
{QStringLiteral("url"), entry.url.toString()},
{QStringLiteral("sha1"), entry.sha1},
{QStringLiteral("releaseTime"),
entry.releaseTime.toString(Qt::ISODate)}};
}
// Своя обёртка: в манифесте Mojang нет отметки времени, по которой можно
// судить о свежести кэша.
const QJsonObject root{{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)},
{QStringLiteral("versions"), versions}};
// QSaveFile: оборванная запись не оставит обрезанный кэш, который потом
// пришлось бы отличать от настоящего.
QSaveFile file(LauncherPaths::versionManifestFile());
if (!file.open(QIODevice::WriteOnly))
return;
const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact);
if (file.write(payload) != payload.size()) {
file.cancelWriting();
return;
}
file.commit();
}