Files
minecraft-launcher/modloaderversionservice.cpp
T

522 lines
23 KiB
C++
Raw Normal View History

#include "modloaderversionservice.h"
#include "localization.h"
#include "launcherpaths.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <QSaveFile>
#include <QTimer>
#include <QUrl>
#include <QXmlStreamReader>
#include <algorithm>
#include <utility>
namespace {
// Списки лоадеров меняются реже манифеста Mojang, но шесть часов держим те же:
// одна цифра на весь лаунчер понятнее двух разных.
constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60;
constexpr int kTransferTimeoutMs = 20000;
const QString kFabricMeta = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/");
const QString kQuiltMeta = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/");
const QString kForgeMaven = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/");
const QString kForgePromotions =
QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
const QString kNeoForgeMaven = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/");
// Под 1.20.1 NeoForge выходил ещё под старым именем артефакта.
const QString kNeoForgeLegacyMaven =
QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/");
bool looksUnstable(const QString &version)
{
const QString lower = version.toLower();
return lower.contains(QLatin1String("alpha")) || lower.contains(QLatin1String("beta"))
|| lower.contains(QLatin1String("-rc")) || lower.contains(QLatin1String("-pre"))
|| lower.contains(u'+');
}
// Сравнение версий по числовым компонентам: «47.4.23» новее «47.4.2», хотя как
// строка стоит раньше. Нечисловые хвосты (alpha, +snapshot) сравниваются как есть.
bool isNewer(const QString &a, const QString &b)
{
const QStringList pa = a.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts);
const QStringList pb = b.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts);
for (int i = 0; i < std::max(pa.size(), pb.size()); ++i) {
const QString sa = i < pa.size() ? pa.at(i) : QString();
const QString sb = i < pb.size() ? pb.at(i) : QString();
bool okA = false, okB = false;
const int na = sa.toInt(&okA);
const int nb = sb.toInt(&okB);
if (okA && okB) {
if (na != nb)
return na > nb;
continue;
}
// Число всегда «новее» суффикса: 26.1.0.0 идёт впереди 26.1.0.0-alpha.1.
if (okA != okB)
return okA;
if (sa != sb)
return sa > sb;
}
return false;
}
// «21.1.248» -> «1.21.1», «21.0.167» -> «1.21», «26.1.2.97» -> «26.1.2»,
// «26.2.0.68» -> «26.2». До 1.20.2 Minecraft нумеровался как 1.x.y, с 26.1 —
// как x.y, поэтому у NeoForge две схемы: три компонента и четыре.
QString neoForgeGameVersion(const QString &neoVersion)
{
const QString head = neoVersion.section(u'-', 0, 0).section(u'+', 0, 0);
const QStringList parts = head.split(u'.');
if (parts.size() == 3)
return parts.at(1) == QLatin1String("0") ? QStringLiteral("1.%1").arg(parts.at(0))
: QStringLiteral("1.%1.%2").arg(parts.at(0), parts.at(1));
if (parts.size() >= 4)
return parts.at(2) == QLatin1String("0")
? QStringLiteral("%1.%2").arg(parts.at(0), parts.at(1))
: QStringLiteral("%1.%2.%3").arg(parts.at(0), parts.at(1), parts.at(2));
return {};
}
QJsonObject entryToJson(const LoaderVersionEntry &entry)
{
return {{QStringLiteral("loaderVersion"), entry.loaderVersion},
{QStringLiteral("gameVersion"), entry.gameVersion},
{QStringLiteral("versionId"), entry.versionId},
{QStringLiteral("installerUrl"), entry.installerUrl.toString()},
{QStringLiteral("recommended"), entry.recommended},
{QStringLiteral("stable"), entry.stable}};
}
LoaderVersionEntry entryFromJson(const QJsonObject &o)
{
LoaderVersionEntry entry;
entry.loaderVersion = o.value(QStringLiteral("loaderVersion")).toString();
entry.gameVersion = o.value(QStringLiteral("gameVersion")).toString();
entry.versionId = o.value(QStringLiteral("versionId")).toString();
entry.installerUrl = QUrl(o.value(QStringLiteral("installerUrl")).toString());
entry.recommended = o.value(QStringLiteral("recommended")).toBool();
entry.stable = o.value(QStringLiteral("stable")).toBool(true);
return entry;
}
} // namespace
ModLoaderVersionService::ModLoaderVersionService(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
{
}
bool ModLoaderVersionService::isPerGameVersion(ModLoader loader)
{
return loader == ModLoader::Fabric || loader == ModLoader::Quilt;
}
ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader)
{
return m_buckets[static_cast<int>(loader)];
}
const ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader) const
{
static const Bucket empty;
const auto it = m_buckets.constFind(static_cast<int>(loader));
return it == m_buckets.constEnd() ? empty : it.value();
}
QString ModLoaderVersionService::requestKey(ModLoader loader, const QString &gameVersion)
{
// У Forge и NeoForge один запрос покрывает все версии игры — они делят
// очередь колбэков, чтобы не качать maven-metadata по разу на версию.
return isPerGameVersion(loader) ? loaderKey(loader) + u'/' + gameVersion : loaderKey(loader);
}
bool ModLoaderVersionService::isFresh(ModLoader loader, const QString &gameVersion) const
{
const Bucket &b = bucket(loader);
const QDateTime fetched = isPerGameVersion(loader)
? b.fetchedAtByGame.value(gameVersion)
: b.fetchedAt;
return fetched.isValid() && fetched.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
}
QList<LoaderVersionEntry> ModLoaderVersionService::versions(ModLoader loader,
const QString &gameVersion) const
{
return bucket(loader).byGame.value(gameVersion);
}
bool ModLoaderVersionService::isRefreshing(ModLoader loader, const QString &gameVersion) const
{
return m_inFlight.contains(requestKey(loader, gameVersion));
}
std::optional<LoaderVersionEntry> ModLoaderVersionService::find(ModLoader loader,
const QString &gameVersion,
const QString &loaderVersion) const
{
const QList<LoaderVersionEntry> list = versions(loader, gameVersion);
for (const LoaderVersionEntry &entry : list) {
if (entry.loaderVersion == loaderVersion)
return entry;
}
return std::nullopt;
}
void ModLoaderVersionService::ensureLoaded(ModLoader loader,
const QString &gameVersion,
Callback callback,
bool forceRefresh)
{
if (gameVersion.trimmed().isEmpty()) {
if (callback)
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
return;
}
Bucket &b = bucket(loader);
if (!b.cacheRead) {
b.cacheRead = true;
if (readCache(loader))
emit versionsChanged(loaderKey(loader), gameVersion);
}
if (!forceRefresh && isFresh(loader, gameVersion)) {
// Колбэк, который то синхронный, то нет, ломает вызывающий код —
// на попадании в кэш отвечаем через событийный цикл.
if (callback)
QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
return;
}
const QString key = requestKey(loader, gameVersion);
if (callback)
m_pending[key] << std::move(callback);
if (m_inFlight.contains(key))
return;
startRefresh(loader, gameVersion);
}
void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &gameVersion)
{
const QString key = requestKey(loader, gameVersion);
m_inFlight.insert(key);
emit refreshingChanged();
const auto request = [](const QUrl &url) {
QNetworkRequest req{url};
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
req.setTransferTimeout(kTransferTimeoutMs);
return req;
};
if (isPerGameVersion(loader)) {
const QString base = loader == ModLoader::Fabric ? kFabricMeta : kQuiltMeta;
QNetworkReply *reply = m_network->get(request(QUrl(base + gameVersion)));
connect(reply, &QNetworkReply::finished, this, [this, reply, loader, gameVersion] {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
finishRefresh(loader, gameVersion, false,
Loc::text("loader.error.versionsFetchFailed")
.arg(loaderTitle(loader), reply->errorString()));
return;
}
const QByteArray payload = reply->readAll();
// Пустой список — законный ответ: этот лоадер не поддерживает такую
// версию игры. Отличаем его от испорченного ответа по самому JSON.
const QJsonDocument document = QJsonDocument::fromJson(payload);
if (!document.isArray()) {
finishRefresh(loader, gameVersion, false,
Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(loader)));
return;
}
Bucket &b = bucket(loader);
b.byGame.insert(gameVersion, parseFabricLike(payload, loader, gameVersion));
b.fetchedAtByGame.insert(gameVersion, QDateTime::currentDateTimeUtc());
writeCache(loader);
emit versionsChanged(loaderKey(loader), gameVersion);
finishRefresh(loader, gameVersion, true, {});
});
return;
}
// Forge и NeoForge: основной maven-metadata плюс вспомогательный запрос
// (рекомендованные сборки у Forge, старая схема 1.20.1 у NeoForge).
// Считаем оба и сводим результат, когда придёт последний.
struct Pending
{
int outstanding = 2;
QHash<QString, QList<LoaderVersionEntry>> merged;
QHash<QString, QString> recommended; // версия игры -> версия лоадера
bool primaryOk = false;
QString warning;
};
auto state = std::make_shared<Pending>();
const auto settle = [this, state, loader, gameVersion] {
if (--state->outstanding > 0)
return;
if (!state->primaryOk) {
finishRefresh(loader, gameVersion, false, state->warning);
return;
}
for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
const QString promoted = state->recommended.value(it.key());
if (promoted.isEmpty())
continue;
for (LoaderVersionEntry &entry : it.value())
entry.recommended = entry.loaderVersion == promoted;
}
Bucket &b = bucket(loader);
b.byGame = state->merged;
b.fetchedAt = QDateTime::currentDateTimeUtc();
writeCache(loader);
emit versionsChanged(loaderKey(loader), gameVersion);
finishRefresh(loader, gameVersion, true, {});
};
const QString primaryUrl = loader == ModLoader::Forge ? kForgeMaven : kNeoForgeMaven;
QNetworkReply *primary = m_network->get(request(QUrl(primaryUrl + QStringLiteral("maven-metadata.xml"))));
connect(primary, &QNetworkReply::finished, this, [this, primary, state, loader, settle] {
primary->deleteLater();
if (primary->error() != QNetworkReply::NoError) {
state->warning = Loc::text("loader.error.versionsFetchFailed")
.arg(loaderTitle(loader), primary->errorString());
settle();
return;
}
auto parsed = parseMavenMetadata(primary->readAll(), loader, /*legacyNeoForge=*/false);
if (parsed.isEmpty()) {
state->warning = Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(loader));
settle();
return;
}
state->primaryOk = true;
state->merged = std::move(parsed);
settle();
});
if (loader == ModLoader::Forge) {
QNetworkReply *promos = m_network->get(request(QUrl(kForgePromotions)));
connect(promos, &QNetworkReply::finished, this, [promos, state, settle] {
promos->deleteLater();
if (promos->error() == QNetworkReply::NoError) {
const QJsonObject root = QJsonDocument::fromJson(promos->readAll()).object();
const QJsonObject promoted = root.value(QStringLiteral("promos")).toObject();
for (auto it = promoted.constBegin(); it != promoted.constEnd(); ++it) {
if (!it.key().endsWith(QLatin1String("-recommended")))
continue;
state->recommended.insert(it.key().chopped(int(sizeof("-recommended") - 1)),
it.value().toString());
}
}
// Метка «рекомендовано» необязательна: без неё список всё равно рабочий.
settle();
});
} else {
QNetworkReply *legacy =
m_network->get(request(QUrl(kNeoForgeLegacyMaven + QStringLiteral("maven-metadata.xml"))));
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
legacy->deleteLater();
if (legacy->error() == QNetworkReply::NoError) {
const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
/*legacyNeoForge=*/true);
for (auto it = extra.constBegin(); it != extra.constEnd(); ++it)
state->merged[it.key()] += it.value();
}
settle();
});
}
}
void ModLoaderVersionService::finishRefresh(ModLoader loader,
const QString &gameVersion,
bool ok,
const QString &warning)
{
const QString key = requestKey(loader, gameVersion);
m_inFlight.remove(key);
emit refreshingChanged();
// Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
const QList<Callback> pending = m_pending.take(key);
const bool haveData = !bucket(loader).byGame.isEmpty();
for (const Callback &callback : pending) {
// Сеть недоступна, но старые данные есть — работаем на них.
if (!ok && haveData)
callback(true, warning);
else
callback(ok, warning);
}
}
QList<LoaderVersionEntry> ModLoaderVersionService::parseFabricLike(const QByteArray &payload,
ModLoader loader,
const QString &gameVersion)
{
const QString prefix = loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-")
: QStringLiteral("quilt-loader-");
QList<LoaderVersionEntry> list;
const QJsonArray array = QJsonDocument::fromJson(payload).array();
list.reserve(array.size());
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject().value(QStringLiteral("loader")).toObject();
LoaderVersionEntry entry;
entry.loaderVersion = object.value(QStringLiteral("version")).toString();
if (entry.loaderVersion.isEmpty())
continue;
entry.gameVersion = gameVersion;
entry.versionId = prefix + entry.loaderVersion + u'-' + gameVersion;
entry.stable = object.value(QStringLiteral("stable")).toBool(!looksUnstable(entry.loaderVersion));
list << entry;
}
// Fabric и Quilt отдают новые сверху, но зависеть от этого не стоит.
std::stable_sort(list.begin(), list.end(),
[](const LoaderVersionEntry &a, const LoaderVersionEntry &b) {
return isNewer(a.loaderVersion, b.loaderVersion);
});
return list;
}
QHash<QString, QList<LoaderVersionEntry>> ModLoaderVersionService::parseMavenMetadata(
const QByteArray &xml, ModLoader loader, bool legacyNeoForge)
{
// Версия игры зашита в саму строку версии лоадера, поэтому раскладка по
// версиям игры — это и есть проверка совместимости: несовместимой пары в
// получившемся отображении просто не существует.
const bool splitOnDash = loader == ModLoader::Forge || legacyNeoForge;
const QString base = loader == ModLoader::Forge ? kForgeMaven
: legacyNeoForge ? kNeoForgeLegacyMaven
: kNeoForgeMaven;
const QString artifact = loader == ModLoader::Forge ? QStringLiteral("forge")
: legacyNeoForge ? QStringLiteral("forge")
: QStringLiteral("neoforge");
QHash<QString, QList<LoaderVersionEntry>> byGame;
QXmlStreamReader reader(xml);
while (!reader.atEnd()) {
if (reader.readNext() != QXmlStreamReader::StartElement)
continue;
if (reader.name() != QLatin1String("version"))
continue;
const QString full = reader.readElementText().trimmed();
if (full.isEmpty())
continue;
LoaderVersionEntry entry;
if (splitOnDash) {
const int dash = full.indexOf(u'-');
if (dash <= 0 || dash + 1 >= full.size())
continue;
entry.gameVersion = full.left(dash);
entry.loaderVersion = full.mid(dash + 1);
} else {
entry.gameVersion = neoForgeGameVersion(full);
entry.loaderVersion = full;
}
if (entry.gameVersion.isEmpty())
continue;
entry.stable = !looksUnstable(full);
entry.installerUrl = QUrl(base + full + u'/' + artifact + u'-' + full
+ QStringLiteral("-installer.jar"));
byGame[entry.gameVersion] << entry;
}
if (reader.hasError())
return {};
// maven-metadata приходит неотсортированным: 1.20.1-47.4.23 стоит между
// версиями 26.x. Без сортировки «самой новой» оказалась бы случайная сборка.
for (auto it = byGame.begin(); it != byGame.end(); ++it) {
std::stable_sort(it.value().begin(), it.value().end(),
[](const LoaderVersionEntry &a, const LoaderVersionEntry &b) {
return isNewer(a.loaderVersion, b.loaderVersion);
});
}
return byGame;
}
bool ModLoaderVersionService::readCache(ModLoader loader)
{
QFile file(LauncherPaths::loaderCacheFile(loaderKey(loader)));
if (!file.open(QIODevice::ReadOnly))
return false;
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
const QJsonObject games = root.value(QStringLiteral("games")).toObject();
if (games.isEmpty())
return false;
Bucket &b = bucket(loader);
for (auto it = games.constBegin(); it != games.constEnd(); ++it) {
const QJsonObject game = it.value().toObject();
QList<LoaderVersionEntry> entries;
const QJsonArray array = game.value(QStringLiteral("entries")).toArray();
entries.reserve(array.size());
for (const QJsonValue &value : array)
entries << entryFromJson(value.toObject());
b.byGame.insert(it.key(), entries);
const QDateTime fetched =
QDateTime::fromString(game.value(QStringLiteral("fetchedAt")).toString(), Qt::ISODate);
if (fetched.isValid())
b.fetchedAtByGame.insert(it.key(), fetched);
}
b.fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
Qt::ISODate);
return true;
}
void ModLoaderVersionService::writeCache(ModLoader loader)
{
// Папку кэша могли удалить уже после запуска лаунчера.
if (!LauncherPaths::ensureRootExists())
return;
const Bucket &b = bucket(loader);
QJsonObject games;
for (auto it = b.byGame.constBegin(); it != b.byGame.constEnd(); ++it) {
QJsonArray entries;
for (const LoaderVersionEntry &entry : it.value())
entries << entryToJson(entry);
games.insert(it.key(),
QJsonObject{{QStringLiteral("fetchedAt"),
b.fetchedAtByGame.value(it.key()).toString(Qt::ISODate)},
{QStringLiteral("entries"), entries}});
}
const QJsonObject root{{QStringLiteral("fetchedAt"), b.fetchedAt.toString(Qt::ISODate)},
{QStringLiteral("games"), games}};
// QSaveFile: оборванная запись не оставит обрезанный кэш, который потом
// пришлось бы отличать от настоящего.
QSaveFile file(LauncherPaths::loaderCacheFile(loaderKey(loader)));
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();
}