#include "modloaderversionservice.h" #include "launcherpaths.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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(loader)]; } const ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader) const { static const Bucket empty; const auto it = m_buckets.constFind(static_cast(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 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 ModLoaderVersionService::find(ModLoader loader, const QString &gameVersion, const QString &loaderVersion) const { const QList 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, tr("Не удалось получить список версий %1: %2") .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, tr("Список версий %1 не удалось разобрать").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> merged; QHash recommended; // версия игры -> версия лоадера bool primaryOk = false; QString warning; }; auto state = std::make_shared(); 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 = tr("Не удалось получить список версий %1: %2") .arg(loaderTitle(loader), primary->errorString()); settle(); return; } auto parsed = parseMavenMetadata(primary->readAll(), loader, /*legacyNeoForge=*/false); if (parsed.isEmpty()) { state->warning = tr("Список версий %1 не удалось разобрать").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 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 ModLoaderVersionService::parseFabricLike(const QByteArray &payload, ModLoader loader, const QString &gameVersion) { const QString prefix = loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-") : QStringLiteral("quilt-loader-"); QList 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> 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> 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 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(); }