Files
minecraft-launcher/seasonalbuildservice.cpp

367 lines
15 KiB
C++

#include "seasonalbuildservice.h"
#include "localization.h"
#include "launcherpaths.h"
#include "modloader.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <QSaveFile>
#include <QTimer>
#include <algorithm>
#include <utility>
namespace {
// Формат каталога, который понимает этот лаунчер. Сервер, отдающий версию
// новее, обслуживает лаунчер новее — молча показывать половину полей нельзя.
constexpr int kSupportedFormatVersion = 1;
// Каталог весит килобайты, а новая ревизия сборки должна доезжать до игроков
// быстро. Четверть часа — это и не поток запросов при каждом открытии окна, и
// не полдня ожидания после публикации; кнопка «Обновить список» ходит с force.
constexpr qint64 kCacheTtlSeconds = 15 * 60;
constexpr int kTransferTimeoutMs = 20000;
// id попадает в имена файлов и в сравнение с локальными записями, поэтому
// набор символов узкий и проверяется до всего остального.
bool isSafeCatalogId(const QString &id)
{
static const QRegularExpression re(QStringLiteral("^[a-z0-9][a-z0-9._-]{0,63}$"));
return re.match(id).hasMatch();
}
// Тот же запрет, что у идентификаторов версий в minecraftversion.cpp: строка
// становится именем папки в versions.
bool isSafeVersionId(const QString &id)
{
if (id.isEmpty() || id.size() > 128)
return false;
if (id.contains(u'/') || id.contains(u'\\') || id.contains(u':'))
return false;
return id != QLatin1String(".") && id != QLatin1String("..");
}
bool isSha256Hex(const QString &value)
{
if (value.size() != 64)
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;
}
// null или пустая строка — даты нет; в таблице на её месте прочерк.
QDate readDate(const QJsonValue &value)
{
const QString text = value.toString();
return text.isEmpty() ? QDate() : QDate::fromString(text, Qt::ISODate);
}
QString dateToJson(const QDate &date)
{
return date.isValid() ? date.toString(Qt::ISODate) : QString();
}
} // namespace
SeasonalBuildService::SeasonalBuildService(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
{
}
std::optional<SeasonalBuildEntry> SeasonalBuildService::find(const QString &id) const
{
const auto it = m_indexById.constFind(id);
if (it == m_indexById.constEnd())
return std::nullopt;
return m_builds.at(it.value());
}
void SeasonalBuildService::setBaseUrl(const QUrl &baseUrl)
{
if (m_baseUrl == baseUrl)
return;
m_baseUrl = baseUrl;
// Ссылки на архивы привязаны к прежнему хосту, а проверка их происхождения
// сверяется с базовым адресом — держать такие данные после смены сервера
// нельзя.
m_builds.clear();
m_indexById.clear();
m_fetchedAt = QDateTime();
m_cacheRead = false;
m_lastError.clear();
emit buildsChanged();
}
void SeasonalBuildService::applyBuilds(QList<SeasonalBuildEntry> list)
{
m_builds = std::move(list);
m_indexById.clear();
m_indexById.reserve(m_builds.size());
for (int i = 0; i < m_builds.size(); ++i)
m_indexById.insert(m_builds.at(i).id, i);
}
QList<SeasonalBuildEntry> SeasonalBuildService::parseCatalog(const QJsonObject &root,
QString *error) const
{
const int format = root.value(QStringLiteral("formatVersion")).toInt();
if (format != kSupportedFormatVersion) {
if (error) {
*error = format > kSupportedFormatVersion
? Loc::text("seasonal.error.formatTooNew").arg(format)
: Loc::text("seasonal.error.formatUnknown").arg(format);
}
return {};
}
QList<SeasonalBuildEntry> list;
const QJsonArray builds = root.value(QStringLiteral("builds")).toArray();
list.reserve(builds.size());
for (const QJsonValue &value : builds) {
const QJsonObject object = value.toObject();
SeasonalBuildEntry entry;
entry.id = object.value(QStringLiteral("id")).toString();
entry.name = object.value(QStringLiteral("name")).toString().trimmed();
entry.revision = object.value(QStringLiteral("revision")).toInt();
entry.minecraftVersion = object.value(QStringLiteral("minecraftVersion")).toString();
entry.loader = object.value(QStringLiteral("loader")).toString();
entry.loaderVersion = object.value(QStringLiteral("loaderVersion")).toString().trimmed();
entry.modCount = object.value(QStringLiteral("modCount")).toInt();
entry.seasonStart = readDate(object.value(QStringLiteral("seasonStart")));
entry.seasonEnd = readDate(object.value(QStringLiteral("seasonEnd")));
entry.serverUrl = object.value(QStringLiteral("serverUrl")).toString().trimmed();
entry.javaMajor = object.value(QStringLiteral("javaMajor")).toInt();
entry.description = object.value(QStringLiteral("description")).toString().trimmed();
const QJsonObject archive = object.value(QStringLiteral("archive")).toObject();
entry.archiveUrl = QUrl(archive.value(QStringLiteral("url")).toString());
entry.archiveSize = qint64(archive.value(QStringLiteral("size")).toDouble());
entry.archiveSha256 = archive.value(QStringLiteral("sha256")).toString().toLower();
// Данные пришли из сети и превратятся в пути на диске и в запуск игры,
// поэтому строка отбрасывается целиком при любой странности.
if (!isSafeCatalogId(entry.id) || entry.name.isEmpty() || entry.revision <= 0)
continue;
if (!isSafeVersionId(entry.minecraftVersion))
continue;
if (!entry.loader.isEmpty() && !loaderFromKey(entry.loader))
continue;
// Загрузчик без версии установить нечем.
if (!entry.loader.isEmpty() && entry.loaderVersion.isEmpty())
continue;
if (!isSha256Hex(entry.archiveSha256) || entry.archiveSize < 0)
continue;
// Ссылка обязана вести на тот же сервер, с которого пришёл каталог:
// подменённый index.json иначе увёл бы загрузку куда угодно.
if (entry.archiveUrl.scheme() != m_baseUrl.scheme()
|| entry.archiveUrl.host() != m_baseUrl.host()
|| entry.archiveUrl.port(-1) != m_baseUrl.port(-1)) {
continue;
}
// http допустим только если сам адрес сервера задан по http — так
// работает отладка против локального python3 -m http.server.
if (entry.archiveUrl.scheme() != QLatin1String("https")
&& entry.archiveUrl.scheme() != QLatin1String("http")) {
continue;
}
list << entry;
}
// Свежие сезоны сверху: сначала те, что ещё идут, потом по дате старта.
std::stable_sort(list.begin(), list.end(),
[](const SeasonalBuildEntry &a, const SeasonalBuildEntry &b) {
const bool aOpen = !a.seasonEnd.isValid();
const bool bOpen = !b.seasonEnd.isValid();
if (aOpen != bOpen)
return aOpen;
if (a.seasonStart != b.seasonStart)
return a.seasonStart > b.seasonStart;
return a.name.localeAwareCompare(b.name) < 0;
});
return list;
}
void SeasonalBuildService::ensureLoaded(Callback callback, bool forceRefresh)
{
if (!m_baseUrl.isValid() || m_baseUrl.host().isEmpty()) {
m_lastError = Loc::text("seasonal.error.noServerUrl");
if (callback) {
const QString error = m_lastError;
QTimer::singleShot(0, this, [callback = std::move(callback), error] {
callback(false, error);
});
}
return;
}
if (!m_cacheRead) {
m_cacheRead = true;
if (readCache())
emit buildsChanged();
}
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 SeasonalBuildService::startRefresh()
{
m_refreshing = true;
emit refreshingChanged();
QUrl url = m_baseUrl;
url.setPath(m_baseUrl.path() + QStringLiteral("/index.json"));
QNetworkRequest request{url};
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("seasonal.error.catalogFetchFailed").arg(reply->errorString()));
return;
}
QString parseError;
QList<SeasonalBuildEntry> parsed =
parseCatalog(QJsonDocument::fromJson(reply->readAll()).object(), &parseError);
// Пустой разбор — испорченный ответ; хороший кэш им не затираем.
// Пустой каталог на сервере от испорченного не отличить, поэтому
// «сборок нет» и «ответ битый» — одно и то же состояние: список
// остаётся прежним, а причина уходит в lastError.
if (parsed.isEmpty()) {
finishRefresh(false,
parseError.isEmpty() ? Loc::text("seasonal.error.catalogEmpty")
: parseError);
return;
}
applyBuilds(std::move(parsed));
m_fetchedAt = QDateTime::currentDateTimeUtc();
writeCache();
emit buildsChanged();
finishRefresh(true, {});
});
}
void SeasonalBuildService::finishRefresh(bool ok, const QString &warning)
{
m_lastError = ok ? QString() : warning;
m_refreshing = false;
emit refreshingChanged();
// Строка ошибки — часть того, что показывает окно, поэтому список
// перечитывается и на неудаче.
emit buildsChanged();
// Колбэк может снова позвать 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 SeasonalBuildService::readCache()
{
QFile file(LauncherPaths::seasonalCatalogFile());
if (!file.open(QIODevice::ReadOnly))
return false;
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
// Кэш от другого сервера бесполезен: ссылки в нём ведут не туда.
if (root.value(QStringLiteral("baseUrl")).toString() != m_baseUrl.toString())
return false;
QList<SeasonalBuildEntry> parsed = parseCatalog(root, nullptr);
if (parsed.isEmpty())
return false;
applyBuilds(std::move(parsed));
m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
Qt::ISODate);
return true;
}
void SeasonalBuildService::writeCache()
{
// Папку кэша могли удалить уже после запуска лаунчера.
if (!LauncherPaths::ensureRootExists())
return;
QJsonArray builds;
for (const SeasonalBuildEntry &entry : std::as_const(m_builds)) {
builds << QJsonObject{
{QStringLiteral("id"), entry.id},
{QStringLiteral("name"), entry.name},
{QStringLiteral("revision"), entry.revision},
{QStringLiteral("minecraftVersion"), entry.minecraftVersion},
{QStringLiteral("loader"), entry.loader},
{QStringLiteral("loaderVersion"), entry.loaderVersion},
{QStringLiteral("modCount"), entry.modCount},
{QStringLiteral("seasonStart"), dateToJson(entry.seasonStart)},
{QStringLiteral("seasonEnd"), dateToJson(entry.seasonEnd)},
{QStringLiteral("serverUrl"), entry.serverUrl},
{QStringLiteral("javaMajor"), entry.javaMajor},
{QStringLiteral("description"), entry.description},
{QStringLiteral("archive"),
QJsonObject{{QStringLiteral("url"), entry.archiveUrl.toString()},
{QStringLiteral("size"), double(entry.archiveSize)},
{QStringLiteral("sha256"), entry.archiveSha256}}}};
}
// Своя обёртка: в каталоге нет ни отметки свежести кэша, ни адреса, с
// которого он приехал, — а без них кэш нечем проверить.
const QJsonObject root{{QStringLiteral("formatVersion"), kSupportedFormatVersion},
{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)},
{QStringLiteral("baseUrl"), m_baseUrl.toString()},
{QStringLiteral("builds"), builds}};
QSaveFile file(LauncherPaths::seasonalCatalogFile());
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();
}