version tracking and auto installing
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
#include "versionmanifestservice.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,
|
||||
tr("Не удалось обновить список версий: %1").arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QList<RemoteVersionEntry> 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<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();
|
||||
}
|
||||
Reference in New Issue
Block a user