Files
minecraft-launcher/modloaderinstaller.cpp
T
2026-08-28 00:13:59 +03:00

692 lines
28 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 "modloaderinstaller.h"
#include "gamelauncher.h"
#include "javalocator.h"
#include "launcherpaths.h"
#include "minecraftversion.h"
#include "modloaderversionservice.h"
#include "versioninstaller.h"
#include "zlibreference.h"
#include <QDir>
#include <QFileInfo>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcess>
#include <QProcessEnvironment>
#include <QSaveFile>
#include <QTimer>
#include <algorithm>
namespace {
constexpr int kTransferTimeoutMs = 30000;
// Установщик Forge после распаковки ещё патчит клиентский jar: на медленной
// машине это несколько минут. Обрываем только совсем зависший процесс.
constexpr int kInstallerTimeoutMs = 15 * 60 * 1000;
constexpr int kLogTailLines = 12;
const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/");
const QString kQuiltProfile = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/");
// Тот же запрет, что у VersionInstaller: id версии становится именем каталога.
bool isSafeSegment(const QString &value)
{
if (value.isEmpty() || value.contains(u'/') || value.contains(u'\\') || value.contains(u':'))
return false;
return value != QLatin1String(".") && value != QLatin1String("..");
}
QString versionJsonPath(const QString &gameDir, const QString &id)
{
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
}
} // namespace
ModLoaderInstaller::ModLoaderInstaller(ModLoaderVersionService *meta,
VersionInstaller *versionInstaller,
QObject *parent)
: QObject(parent)
, m_meta(meta)
, m_versionInstaller(versionInstaller)
, m_network(new QNetworkAccessManager(this))
{
// Последний шаг обоих путей одинаков: VersionInstaller дотягивает всё, чего
// не хватает получившемуся профилю.
connect(m_versionInstaller, &VersionInstaller::finished, this, [this](const QString &id) {
if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId)
return;
const QString versionId = m_awaitingVersionId;
const QString loader = loaderKey(m_loader);
const QString game = m_gameVersion;
const QString version = m_loaderVersion;
reset();
emit finished(loader, game, version, versionId);
});
connect(m_versionInstaller, &VersionInstaller::failed, this,
[this](const QString &id, const QString &message) {
if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId)
return;
fail(message);
});
connect(m_versionInstaller, &VersionInstaller::canceled, this, [this](const QString &id) {
if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId)
return;
const QString label = m_label;
reset();
emit canceled(label);
});
}
ModLoaderInstaller::~ModLoaderInstaller()
{
if (m_process && m_process->state() != QProcess::NotRunning) {
m_process->kill();
m_process->waitForFinished(2000);
}
}
double ModLoaderInstaller::fraction() const
{
if (m_bytesTotal <= 0)
return -1.0; // панель нарисует «…»
return double(m_bytesDone) / double(m_bytesTotal);
}
void ModLoaderInstaller::install(const QString &gameDir,
ModLoader loader,
const QString &gameVersion,
const QString &loaderVersion,
const QString &javaPreference)
{
if (m_running) {
emit failed(loaderTitle(loader), tr("Установка модлоадера уже идёт"));
return;
}
if (gameVersion.trimmed().isEmpty() || loaderVersion.trimmed().isEmpty()) {
emit failed(loaderTitle(loader), tr("Не выбрана версия %1").arg(loaderTitle(loader)));
return;
}
m_gameDir = gameDir;
m_loader = loader;
m_gameVersion = gameVersion.trimmed();
m_loaderVersion = loaderVersion.trimmed();
m_javaPreference = javaPreference;
m_label = QStringLiteral("%1 %2").arg(loaderTitle(loader), m_loaderVersion);
m_installerLog.clear();
m_bytesDone = 0;
m_bytesTotal = 0;
m_currentFile.clear();
m_canceling = false;
m_running = true;
emit started(m_label);
setStage(tr("Подготовка %1").arg(loaderTitle(loader)));
// Список версий нужен и для ссылки на installer.jar, и для точного id
// профиля. После перезапуска лаунчера он ещё не прочитан с диска, поэтому
// спрашиваем его до всего остального: попадание в кэш обойдётся без сети.
m_meta->ensureLoaded(loader, m_gameVersion, [this, loader](bool ok, const QString &warning) {
if (!m_running || m_canceling)
return;
if (!ok) {
fail(warning.isEmpty() ? tr("Список версий %1 недоступен").arg(loaderTitle(loader))
: warning);
return;
}
// Любой лоадер — надстройка над ванильной версией: и профиль Fabric с его
// inheritsFrom, и патч клиентского jar у Forge упрутся в недокачанную базу.
// Поэтому шлюз один на всех и стоит до выбора ветки.
ensureBaseVersion([this, loader] {
if (loader == ModLoader::Fabric || loader == ModLoader::Quilt)
beginFabricLike();
else
beginInstallerJar();
});
});
}
void ModLoaderInstaller::cancel()
{
if (!m_running)
return;
m_canceling = true;
if (m_reply) {
m_reply->abort();
m_reply = nullptr;
}
if (m_process && m_process->state() != QProcess::NotRunning) {
m_process->kill();
m_process->waitForFinished(2000);
}
if (m_installerStarted)
removeVersionsAddedByInstaller();
if (!m_awaitingVersionId.isEmpty()) {
m_versionInstaller->cancel();
return; // финал придёт из VersionInstaller::canceled
}
const QString label = m_label;
reset();
emit canceled(label);
}
// ── Fabric и Quilt ─────────────────────────────────────────────────────────
void ModLoaderInstaller::beginFabricLike()
{
const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion);
const QString expectedId =
entry ? entry->versionId
: (m_loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-")
: QStringLiteral("quilt-loader-"))
+ m_loaderVersion + u'-' + m_gameVersion;
if (!isSafeSegment(expectedId)) {
fail(tr("Недопустимый идентификатор версии «%1»").arg(expectedId));
return;
}
const QString base = m_loader == ModLoader::Fabric ? kFabricProfile : kQuiltProfile;
const QUrl url(base + m_gameVersion + u'/' + m_loaderVersion + QStringLiteral("/profile/json"));
setStage(tr("Описание версии %1").arg(loaderTitle(m_loader)));
m_currentFile = expectedId + QStringLiteral(".json");
emit progressChanged();
QNetworkRequest request{url};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
m_reply = m_network->get(request);
connect(m_reply, &QNetworkReply::finished, this, [this, expectedId] {
QNetworkReply *reply = m_reply;
m_reply = nullptr;
reply->deleteLater();
if (!m_running || m_canceling)
return;
if (reply->error() != QNetworkReply::NoError) {
fail(tr("Не удалось получить описание %1: %2")
.arg(loaderTitle(m_loader), reply->errorString()));
return;
}
const QByteArray payload = reply->readAll();
const QJsonObject root = QJsonDocument::fromJson(payload).object();
const QString id = root.value(QStringLiteral("id")).toString();
const QString inherits = root.value(QStringLiteral("inheritsFrom")).toString();
// Описание приходит из сети и превращается в путь на диске — проверяем
// и id, и то, что версия действительно наследует выбранную ванильную.
if (id.isEmpty() || !isSafeSegment(id)) {
fail(tr("Описание %1 испорчено: недопустимый идентификатор версии")
.arg(loaderTitle(m_loader)));
return;
}
if (inherits != m_gameVersion) {
fail(tr("Описание %1 рассчитано на Minecraft %2, а не %3")
.arg(loaderTitle(m_loader), inherits, m_gameVersion));
return;
}
if (id != expectedId)
emit log(tr("Идентификатор версии: %1").arg(id));
const QString directory = m_gameDir + QStringLiteral("/versions/") + id;
if (!QDir().mkpath(directory)) {
fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory)));
return;
}
QSaveFile file(versionJsonPath(m_gameDir, id));
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()
|| !file.commit()) {
file.cancelWriting();
fail(tr("Не удалось сохранить описание версии «%1»").arg(id));
return;
}
handOffToVersionInstaller(id);
});
}
// ── Forge и NeoForge ───────────────────────────────────────────────────────
void ModLoaderInstaller::beginInstallerJar()
{
const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion);
if (!entry || !entry->installerUrl.isValid()) {
fail(tr("%1 %2 не подходит к Minecraft %3 — выберите версию из списка")
.arg(loaderTitle(m_loader), m_loaderVersion, m_gameVersion));
return;
}
if (entry->installerUrl.scheme() != QLatin1String("https")) {
fail(tr("Установщик %1 доступен только по недопустимой ссылке").arg(loaderTitle(m_loader)));
return;
}
// Ванильную версию к этому моменту уже проверил ensureBaseVersion из install().
downloadInstaller(entry->installerUrl, [this](const QString &path) { runInstaller(path); });
}
void ModLoaderInstaller::ensureBaseVersion(std::function<void()> next)
{
// Наличие versions/<mc>/<mc>.json ничего не значит: VersionInstaller кладёт его
// до загрузки файлов, так что оборванная установка оставляет ровно такой след.
// Проверяем тем же, чем пользуются приёмка VersionInstaller и запуск игры.
QString loadError;
const MinecraftVersion installed =
VersionLoader::load(m_gameDir, m_gameVersion, {}, &loadError);
if (installed.isValid()) {
LaunchOptions probe;
probe.gameDir = m_gameDir;
probe.versionId = m_gameVersion;
if (GameLauncher::missingFiles(probe, installed, 1).isEmpty()) {
next();
return;
}
}
setStage(tr("Установка Minecraft %1").arg(m_gameVersion));
m_currentFile = m_gameVersion;
emit progressChanged();
const QString base = m_gameVersion;
auto guard = std::make_shared<QMetaObject::Connection>();
auto failGuard = std::make_shared<QMetaObject::Connection>();
*guard = connect(m_versionInstaller, &VersionInstaller::finished, this,
[this, base, guard, failGuard, next](const QString &id) {
if (id != base)
return;
disconnect(*guard);
disconnect(*failGuard);
if (!m_running || m_canceling)
return;
next();
});
*failGuard = connect(m_versionInstaller, &VersionInstaller::failed, this,
[this, base, guard, failGuard](const QString &id, const QString &message) {
if (id != base)
return;
disconnect(*guard);
disconnect(*failGuard);
fail(message);
});
m_versionInstaller->install(m_gameDir, base);
}
void ModLoaderInstaller::downloadInstaller(const QUrl &url, std::function<void(const QString &)> next)
{
if (!LauncherPaths::ensureRootExists()) {
fail(tr("Нет доступа к папке лаунчера"));
return;
}
const QString target = LauncherPaths::loaderDownloadDir() + u'/' + loaderKey(m_loader) + u'-'
+ QString(m_loaderVersion).replace(u'/', u'_')
+ QStringLiteral("-installer.jar");
setStage(tr("Загрузка установщика %1").arg(loaderTitle(m_loader)));
m_currentFile = QFileInfo(target).fileName();
emit progressChanged();
QNetworkRequest request{url};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
m_reply = m_network->get(request);
connect(m_reply, &QNetworkReply::downloadProgress, this,
[this](qint64 received, qint64 total) {
m_bytesDone = received;
m_bytesTotal = total;
emit progressChanged();
});
connect(m_reply, &QNetworkReply::finished, this, [this, target, next] {
QNetworkReply *reply = m_reply;
m_reply = nullptr;
reply->deleteLater();
if (!m_running || m_canceling)
return;
if (reply->error() != QNetworkReply::NoError) {
fail(tr("Не удалось скачать установщик %1: %2")
.arg(loaderTitle(m_loader), reply->errorString()));
return;
}
const QByteArray payload = reply->readAll();
QSaveFile file(target);
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()
|| !file.commit()) {
file.cancelWriting();
fail(tr("Не удалось сохранить установщик %1").arg(loaderTitle(m_loader)));
return;
}
next(target);
});
}
// Без launcher_profiles.json установщик Forge и NeoForge завершается с
// сообщением про испорченный профиль лаунчера. Свои профили мы там не держим,
// поэтому достаточно заглушки.
bool ModLoaderInstaller::writeLauncherProfilesStub()
{
const QString path = m_gameDir + QStringLiteral("/launcher_profiles.json");
if (QFile::exists(path))
return true;
if (!QDir().mkpath(m_gameDir))
return false;
const QJsonObject stub{
{QStringLiteral("profiles"), QJsonObject{}},
{QStringLiteral("selectedProfile"), QString()},
{QStringLiteral("clientToken"), QString()},
{QStringLiteral("authenticationDatabase"), QJsonObject{}},
{QStringLiteral("launcherVersion"),
QJsonObject{{QStringLiteral("name"), QString()},
{QStringLiteral("format"), 21},
{QStringLiteral("profilesFormat"), 2}}}};
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly))
return false;
const QByteArray payload = QJsonDocument(stub).toJson(QJsonDocument::Indented);
if (file.write(payload) != payload.size()) {
file.cancelWriting();
return false;
}
return file.commit();
}
void ModLoaderInstaller::runInstaller(const QString &installerPath)
{
if (!writeLauncherProfilesStub()) {
fail(tr("Не удалось создать launcher_profiles.json — без него установщик %1 не работает")
.arg(loaderTitle(m_loader)));
return;
}
// Постобработка установщика гоняет те же классы, что и сама игра, поэтому
// требования к Java берём у ванильной версии.
QString loadError;
const MinecraftVersion base = VersionLoader::load(m_gameDir, m_gameVersion, {}, &loadError);
const int requiredMajor = base.isValid() ? base.javaMajor : 8;
QString javaError;
const QString java = JavaLocator::select(m_gameDir, requiredMajor, m_javaPreference, &javaError);
if (java.isEmpty()) {
fail(javaError.isEmpty() ? tr("Java не найдена — установщик %1 запустить нечем")
.arg(loaderTitle(m_loader))
: javaError);
return;
}
m_versionsBefore = VersionLoader::installedVersions(m_gameDir);
m_installerStarted = true;
// Весь вывод установщика уходит в файл: в интерфейс попадает только хвост,
// а разбираться с «Processor failed» по двенадцати строкам невозможно.
m_installerLogPath = LauncherPaths::loaderDownloadDir() + u'/' + loaderKey(m_loader) + u'-'
+ QString(m_loaderVersion).replace(u'/', u'_')
+ QStringLiteral("-install.log");
m_installerLogFile = new QFile(m_installerLogPath, this);
if (!m_installerLogFile->open(QIODevice::WriteOnly | QIODevice::Truncate)) {
delete m_installerLogFile;
m_installerLogFile = nullptr;
m_installerLogPath.clear();
}
setStage(tr("Установка %1").arg(loaderTitle(m_loader)));
m_currentFile = tr("работает установщик, это может занять несколько минут");
// Прогресс установщика неизвестен: панель покажет «…».
m_bytesDone = 0;
m_bytesTotal = 0;
emit progressChanged();
m_process = new QProcess(this);
m_process->setProgram(java);
m_process->setArguments({QStringLiteral("-jar"), installerPath,
QStringLiteral("--installClient"), QDir::toNativeSeparators(m_gameDir)});
m_process->setWorkingDirectory(LauncherPaths::loaderDownloadDir());
m_process->setProcessChannelMode(QProcess::MergedChannels);
// Установщик сверяет sha1 всего, что собрал сам, а на системах с zlib-ng
// сжатие получается другое — без подмены он падает на любой версии игры.
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
QString zlibNote;
m_zlibNgUnpatched = !ZlibReference::applyTo(environment, &zlibNote);
if (!zlibNote.isEmpty()) {
appendInstallerLog(zlibNote);
emit log(zlibNote);
}
m_process->setProcessEnvironment(environment);
connect(m_process, &QProcess::readyReadStandardOutput, this, [this] {
const QStringList lines = QString::fromLocal8Bit(m_process->readAllStandardOutput())
.split(u'\n', Qt::SkipEmptyParts);
for (const QString &line : lines) {
const QString text = line.trimmed();
m_installerLog << text;
appendInstallerLog(text);
emit log(text);
}
// Хвоста хватает, чтобы показать причину отказа; весь лог не нужен.
while (m_installerLog.size() > kLogTailLines)
m_installerLog.removeFirst();
});
connect(m_process, &QProcess::finished, this,
[this](int exitCode, QProcess::ExitStatus status) {
handleInstallerFinished(exitCode, status == QProcess::CrashExit);
});
connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
if (m_canceling || error == QProcess::Crashed)
return; // Crashed придёт ещё и в finished
fail(tr("Не удалось запустить установщик %1: %2")
.arg(loaderTitle(m_loader), m_process->errorString()));
});
m_process->start();
if (!m_process->waitForStarted(kTransferTimeoutMs)) {
fail(tr("Установщик %1 не запустился: %2")
.arg(loaderTitle(m_loader), m_process->errorString()));
return;
}
// Совсем зависший установщик не должен блокировать лаунчер навсегда.
QTimer::singleShot(kInstallerTimeoutMs, this, [this] {
if (m_process && m_process->state() != QProcess::NotRunning) {
m_process->kill();
fail(tr("Установщик %1 не завершился за отведённое время").arg(loaderTitle(m_loader)));
}
});
}
void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed)
{
if (m_process) {
m_process->deleteLater();
m_process = nullptr;
}
closeInstallerLog();
if (!m_running || m_canceling)
return;
if (crashed || exitCode != 0) {
removeVersionsAddedByInstaller();
QString message = tr("Установщик %1 завершился с ошибкой (код %2).")
.arg(loaderTitle(m_loader))
.arg(exitCode);
const QString hint = installerFailureHint();
if (!hint.isEmpty())
message += u'\n' + hint;
if (!m_installerLog.isEmpty())
message += u'\n' + m_installerLog.join(u'\n');
if (!m_installerLogPath.isEmpty())
message += u'\n'
+ tr("Полный лог: %1").arg(QDir::toNativeSeparators(m_installerLogPath));
fail(message);
return;
}
// Какой профиль появился — выясняем разницей: id у Forge разные по эпохам
// (1.20.1-forge-47.4.0, 1.12.2-forge-14.23.5.2859, neoforge-21.1.66).
QStringList added = versionsAddedByInstaller();
if (added.isEmpty()) {
// Разницы нет, если профиль лежал в versions ещё до запуска: переустановка
// поверх или остаток прошлой попытки. Тогда ищем его среди наследников
// выбранной ванильной версии.
const QStringList dependents = VersionLoader::dependentsOf(m_gameDir, m_gameVersion);
for (const QString &id : dependents) {
if (id.contains(m_loaderVersion))
added << id;
}
}
if (added.isEmpty()) {
QString message = tr("Установщик %1 отработал, но новая версия в %2 не появилась.")
.arg(loaderTitle(m_loader),
QDir::toNativeSeparators(m_gameDir
+ QStringLiteral("/versions")));
if (!m_installerLog.isEmpty())
message += u'\n' + m_installerLog.join(u'\n');
fail(message);
return;
}
if (added.size() > 1) {
// Берём тот, где встречается версия лоадера: остальные — попутно
// доустановленные зависимости.
const auto match = std::find_if(added.cbegin(), added.cend(), [this](const QString &id) {
return id.contains(m_loaderVersion);
});
added = {match != added.cend() ? *match : added.first()};
}
// Профиль на месте — дальше его судьба в руках VersionInstaller, мусором он
// больше не считается.
m_installerStarted = false;
m_versionsBefore.clear();
handOffToVersionInstaller(added.first());
}
QStringList ModLoaderInstaller::versionsAddedByInstaller() const
{
if (!m_installerStarted)
return {};
QStringList added;
const QStringList after = VersionLoader::installedVersions(m_gameDir);
for (const QString &id : after) {
if (!m_versionsBefore.contains(id))
added << id;
}
return added;
}
void ModLoaderInstaller::removeVersionsAddedByInstaller()
{
// Forge пишет versions/<id>/<id>.json до запуска процессоров, поэтому после
// неудачи остаётся профиль-призрак: в списке версий он выглядит установленным,
// а следующей попытке мешает — разница по versions у неё выйдет пустой.
const QStringList added = versionsAddedByInstaller();
for (const QString &id : added) {
QString error;
if (!VersionLoader::remove(m_gameDir, id, &error))
emit log(tr("Не удалось убрать незавершённый профиль «%1»: %2").arg(id, error));
}
m_versionsBefore.clear();
m_installerStarted = false;
}
QString ModLoaderInstaller::installerFailureHint() const
{
if (!m_zlibNgUnpatched)
return {};
const bool checksumFailure =
std::any_of(m_installerLog.cbegin(), m_installerLog.cend(), [](const QString &line) {
return line.contains(QLatin1String("invalid outputs"))
|| line.contains(QLatin1String("Processor failed"));
});
if (!checksumFailure)
return {};
return tr("Системный libz.so.1 — это zlib-ng: он сжимает иначе, чем обычный zlib, "
"поэтому sha1 собранных установщиком файлов не совпадает с эталоном. "
"Пересоберите лаунчер, чтобы рядом с ним появился эталонный libz.so.1, "
"либо замените zlib-ng в системе: sudo pacman -S zlib lib32-zlib");
}
void ModLoaderInstaller::appendInstallerLog(const QString &line)
{
if (!m_installerLogFile)
return;
m_installerLogFile->write(line.toUtf8());
m_installerLogFile->write("\n");
}
void ModLoaderInstaller::closeInstallerLog()
{
if (!m_installerLogFile)
return;
m_installerLogFile->close();
delete m_installerLogFile;
m_installerLogFile = nullptr;
}
// ── Общий хвост ────────────────────────────────────────────────────────────
void ModLoaderInstaller::handOffToVersionInstaller(const QString &versionId)
{
m_awaitingVersionId = versionId;
setStage(tr("Загрузка файлов версии"));
m_currentFile = versionId;
emit progressChanged();
m_versionInstaller->install(m_gameDir, versionId);
}
void ModLoaderInstaller::setStage(const QString &stage)
{
m_stage = stage;
emit progressChanged();
}
void ModLoaderInstaller::reset()
{
m_running = false;
m_canceling = false;
m_awaitingVersionId.clear();
m_stage.clear();
m_currentFile.clear();
m_bytesDone = 0;
m_bytesTotal = 0;
m_versionsBefore.clear();
m_installerStarted = false;
m_zlibNgUnpatched = false;
closeInstallerLog();
m_installerLogPath.clear();
if (m_process) {
m_process->deleteLater();
m_process = nullptr;
}
emit progressChanged();
}
void ModLoaderInstaller::fail(const QString &message)
{
const QString label = m_label;
reset();
emit failed(label, message);
}