521 lines
23 KiB
C++
521 lines
23 KiB
C++
|
|
#include "modloaderversionservice.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,
|
|||
|
|
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<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 = 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<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();
|
|||
|
|
}
|