Files
minecraft-launcher/modloaderversionservice.cpp
T

522 lines
23 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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();
}