tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m20s
tests / linux (push) Successful in 2m14s
tests / windows (push) Failing after 23m21s
version number Signed-off-by: galeon <dertiellis@gmail.com>
245 lines
9.3 KiB
C++
245 lines
9.3 KiB
C++
#include "gamedirimport.h"
|
||
|
||
#include "minecraftversion.h"
|
||
|
||
#include <QDateTime>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
#include <QJsonArray>
|
||
#include <QJsonDocument>
|
||
#include <QList>
|
||
#include <algorithm>
|
||
#include <utility>
|
||
|
||
namespace {
|
||
|
||
// Признаки того, что в папке играли: это принадлежит сборке. Служебные файлы
|
||
// чужих лаунчеров (launcher_log.txt, webcache2 и т. п.) сюда не входят, иначе
|
||
// пустая папка официального лаунчера давала бы сборку без версии.
|
||
const QStringList kContentMarkers = {QStringLiteral("mods"),
|
||
QStringLiteral("saves"),
|
||
QStringLiteral("resourcepacks"),
|
||
QStringLiteral("shaderpacks"),
|
||
QStringLiteral("config"),
|
||
QStringLiteral("options.txt"),
|
||
QStringLiteral("servers.dat")};
|
||
|
||
struct Candidate
|
||
{
|
||
QString id;
|
||
QJsonObject json;
|
||
bool inherits = false;
|
||
};
|
||
|
||
QJsonObject readObject(const QString &path)
|
||
{
|
||
QFile file(path);
|
||
if (!file.open(QIODevice::ReadOnly))
|
||
return {};
|
||
return QJsonDocument::fromJson(file.readAll()).object();
|
||
}
|
||
|
||
QString versionJsonPath(const QString &gameDir, const QString &id)
|
||
{
|
||
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
|
||
}
|
||
|
||
bool hasContent(const QString &gameDir)
|
||
{
|
||
for (const QString &name : kContentMarkers) {
|
||
const QFileInfo info(gameDir + u'/' + name);
|
||
if (info.isFile())
|
||
return true;
|
||
if (info.isDir() && !QDir(info.filePath()).isEmpty())
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool hasMods(const QString &gameDir)
|
||
{
|
||
return !QDir(gameDir + QStringLiteral("/mods"))
|
||
.entryList({QStringLiteral("*.jar")}, QDir::Files)
|
||
.isEmpty();
|
||
}
|
||
|
||
QDateTime parseLastUsed(const QString &text)
|
||
{
|
||
QDateTime when = QDateTime::fromString(text, Qt::ISODateWithMs);
|
||
if (!when.isValid())
|
||
when = QDateTime::fromString(text, Qt::ISODate);
|
||
return when;
|
||
}
|
||
|
||
// Версии из launcher_profiles.json, которые есть в versions/, свежие сверху.
|
||
// Профили latest-release/latest-snapshot без lastVersionId пропускаются: без
|
||
// манифеста не узнать, во что они разворачивались.
|
||
QStringList profileVersions(const QString &gameDir, const QStringList &installed)
|
||
{
|
||
const QJsonObject profiles =
|
||
readObject(gameDir + QStringLiteral("/launcher_profiles.json"))
|
||
.value(QStringLiteral("profiles"))
|
||
.toObject();
|
||
|
||
struct Used
|
||
{
|
||
QString id;
|
||
QDateTime when;
|
||
};
|
||
QList<Used> used;
|
||
for (auto it = profiles.constBegin(); it != profiles.constEnd(); ++it) {
|
||
const QJsonObject profile = it.value().toObject();
|
||
const QString id = profile.value(QStringLiteral("lastVersionId")).toString();
|
||
if (id.isEmpty() || !installed.contains(id))
|
||
continue;
|
||
used.append({id, parseLastUsed(profile.value(QStringLiteral("lastUsed")).toString())});
|
||
}
|
||
// Профиль без даты считается самым старым.
|
||
std::stable_sort(used.begin(), used.end(), [](const Used &a, const Used &b) {
|
||
if (a.when.isValid() != b.when.isValid())
|
||
return a.when.isValid();
|
||
return a.when > b.when;
|
||
});
|
||
|
||
QStringList ids;
|
||
for (const Used &u : used) {
|
||
if (!ids.contains(u.id))
|
||
ids << u.id;
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
// Установленные версии по времени правки описания, свежие сверху.
|
||
QStringList versionsByMtime(const QString &gameDir, QStringList installed)
|
||
{
|
||
std::stable_sort(installed.begin(), installed.end(), [&](const QString &a, const QString &b) {
|
||
return QFileInfo(versionJsonPath(gameDir, a)).lastModified()
|
||
> QFileInfo(versionJsonPath(gameDir, b)).lastModified();
|
||
});
|
||
return installed;
|
||
}
|
||
|
||
QList<Candidate> load(const QString &gameDir, const QStringList &ids)
|
||
{
|
||
QList<Candidate> out;
|
||
for (const QString &id : ids) {
|
||
const QJsonObject json = readObject(versionJsonPath(gameDir, id));
|
||
// Битое описание не запустится — такую версию не предлагаем.
|
||
if (json.isEmpty())
|
||
continue;
|
||
out.append({id, json, !json.value(QStringLiteral("inheritsFrom")).toString().isEmpty()});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// "1.20.1-47.2.0" → "47.2.0". Хвост "-1.7.10" у старых Forge остаётся: в
|
||
// каталоге модлоадеров версии записаны так же.
|
||
QString stripGamePrefix(const QString &version, const QString &game)
|
||
{
|
||
if (!game.isEmpty() && version.startsWith(game + u'-'))
|
||
return version.mid(game.size() + 1);
|
||
return version;
|
||
}
|
||
|
||
}
|
||
|
||
namespace GameDirImport {
|
||
|
||
LoaderInfo loaderOf(const QJsonObject &versionJson)
|
||
{
|
||
const QString game = versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
||
|
||
for (const QJsonValue &value : versionJson.value(QStringLiteral("libraries")).toArray()) {
|
||
// group:artifact:version[:classifier]
|
||
const QStringList parts = value.toObject().value(QStringLiteral("name")).toString().split(u':');
|
||
if (parts.size() < 3)
|
||
continue;
|
||
const QString coord = parts[0] + u':' + parts[1];
|
||
const QString version = parts[2];
|
||
if (coord == QLatin1String("net.fabricmc:fabric-loader"))
|
||
return {QStringLiteral("fabric"), version};
|
||
if (coord == QLatin1String("org.quiltmc:quilt-loader"))
|
||
return {QStringLiteral("quilt"), version};
|
||
if (coord == QLatin1String("net.neoforged:neoforge"))
|
||
return {QStringLiteral("neoforge"), version};
|
||
// NeoForge для 1.20.1 публиковался под старым именем артефакта.
|
||
if (coord == QLatin1String("net.neoforged:forge"))
|
||
return {QStringLiteral("neoforge"), stripGamePrefix(version, game)};
|
||
if (coord == QLatin1String("net.minecraftforge:forge"))
|
||
return {QStringLiteral("forge"), stripGamePrefix(version, game)};
|
||
}
|
||
|
||
// Библиотек модлоадера в описании может не быть (их вынесли в родителя или
|
||
// описание урезали руками) — тогда смотрим на имя, которое дают установщики.
|
||
const QString id = versionJson.value(QStringLiteral("id")).toString();
|
||
for (const auto &[key, prefix] : {std::pair{QStringLiteral("fabric"), QStringLiteral("fabric-loader-")},
|
||
std::pair{QStringLiteral("quilt"), QStringLiteral("quilt-loader-")}}) {
|
||
const QString suffix = u'-' + game;
|
||
if (!game.isEmpty() && id.startsWith(prefix) && id.endsWith(suffix))
|
||
return {key, id.mid(prefix.size(), id.size() - prefix.size() - suffix.size())};
|
||
}
|
||
if (id.startsWith(QLatin1String("neoforge-")))
|
||
return {QStringLiteral("neoforge"), id.mid(9)};
|
||
const QString forgeInfix = game + QStringLiteral("-forge-");
|
||
if (!game.isEmpty() && id.startsWith(forgeInfix))
|
||
return {QStringLiteral("forge"), id.mid(forgeInfix.size())};
|
||
return {};
|
||
}
|
||
|
||
std::optional<Detected> detect(const QString &gameDir)
|
||
{
|
||
if (gameDir.isEmpty() || !QFileInfo(gameDir).isDir())
|
||
return std::nullopt;
|
||
|
||
const QStringList installed = VersionLoader::installedVersions(gameDir);
|
||
const bool content = hasContent(gameDir);
|
||
if (installed.isEmpty() && !content)
|
||
return std::nullopt;
|
||
|
||
const QList<Candidate> fromProfiles = load(gameDir, profileVersions(gameDir, installed));
|
||
const QList<Candidate> fromVersions = load(gameDir, versionsByMtime(gameDir, installed));
|
||
|
||
// С модами в папке ванильная версия почти наверняка не та, с которой играли:
|
||
// сначала ищем профиль модлоадера, и только потом берём что есть.
|
||
const Candidate *chosen = nullptr;
|
||
QString source;
|
||
auto pick = [&](const QList<Candidate> &list, const QString &from, bool needInherits) {
|
||
if (chosen)
|
||
return;
|
||
for (const Candidate &c : list) {
|
||
if (!needInherits || c.inherits) {
|
||
chosen = &c;
|
||
source = from;
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
if (hasMods(gameDir)) {
|
||
pick(fromProfiles, QStringLiteral("launcher_profiles"), true);
|
||
pick(fromVersions, QStringLiteral("versions"), true);
|
||
}
|
||
pick(fromProfiles, QStringLiteral("launcher_profiles"), false);
|
||
pick(fromVersions, QStringLiteral("versions"), false);
|
||
|
||
Detected result;
|
||
if (!chosen) {
|
||
result.source = QStringLiteral("content");
|
||
return result;
|
||
}
|
||
|
||
result.source = source;
|
||
if (chosen->inherits) {
|
||
result.minecraftVersion = chosen->json.value(QStringLiteral("inheritsFrom")).toString();
|
||
result.resolvedVersionId = chosen->id;
|
||
const LoaderInfo loader = loaderOf(chosen->json);
|
||
result.loader = loader.key;
|
||
result.loaderVersion = loader.version;
|
||
} else {
|
||
result.minecraftVersion = chosen->id;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
}
|