366 lines
15 KiB
C++
366 lines
15 KiB
C++
#include "seasonalbuildservice.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
|
|
? tr("Сервер сборок отдаёт формат %1 — обновите лаунчер").arg(format)
|
|
: tr("Неизвестный формат каталога сборок (%1)").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 = tr("Адрес сервера сборок не задан");
|
|
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,
|
|
tr("Не удалось получить список сборок: %1").arg(reply->errorString()));
|
|
return;
|
|
}
|
|
|
|
QString parseError;
|
|
QList<SeasonalBuildEntry> parsed =
|
|
parseCatalog(QJsonDocument::fromJson(reply->readAll()).object(), &parseError);
|
|
// Пустой разбор — испорченный ответ; хороший кэш им не затираем.
|
|
// Пустой каталог на сервере от испорченного не отличить, поэтому
|
|
// «сборок нет» и «ответ битый» — одно и то же состояние: список
|
|
// остаётся прежним, а причина уходит в lastError.
|
|
if (parsed.isEmpty()) {
|
|
finishRefresh(false,
|
|
parseError.isEmpty() ? tr("Список сборок пуст или не разобран")
|
|
: 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();
|
|
}
|