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