Files
minecraft-launcher/modloaderinstaller.cpp

690 lines
27 KiB
C++
Raw Permalink 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 "localization.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), Loc::text("loader.error.installInProgress"));
return;
}
if (gameVersion.trimmed().isEmpty() || loaderVersion.trimmed().isEmpty()) {
emit failed(loaderTitle(loader), Loc::text("loader.error.noVersionSelected").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(Loc::text("loader.progress.preparing").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() ? Loc::text("loader.error.listUnavailable").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(Loc::text("loader.error.invalidVersionId").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(Loc::text("loader.progress.description").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(Loc::text("loader.error.descriptionFetchFailed")
.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(Loc::text("loader.error.descriptionBadId")
.arg(loaderTitle(m_loader)));
return;
}
if (inherits != m_gameVersion) {
fail(Loc::text("loader.error.descriptionWrongMinecraft")
.arg(loaderTitle(m_loader), inherits, m_gameVersion));
return;
}
if (id != expectedId)
emit log(Loc::text("loader.status.versionId").arg(id));
const QString directory = m_gameDir + QStringLiteral("/versions/") + id;
if (!QDir().mkpath(directory)) {
fail(Loc::text("common.error.createFailed").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(Loc::text("common.error.versionSaveFailed").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(Loc::text("loader.error.incompatible")
.arg(loaderTitle(m_loader), m_loaderVersion, m_gameVersion));
return;
}
if (entry->installerUrl.scheme() != QLatin1String("https")) {
fail(Loc::text("loader.error.installerBadUrl").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(Loc::text("loader.progress.installingMinecraft").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(Loc::text("loader.error.launcherDirUnavailable"));
return;
}
const QString target = LauncherPaths::loaderDownloadDir() + u'/' + loaderKey(m_loader) + u'-'
+ QString(m_loaderVersion).replace(u'/', u'_')
+ QStringLiteral("-installer.jar");
setStage(Loc::text("loader.progress.downloadingInstaller").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(Loc::text("loader.error.installerDownloadFailed")
.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(Loc::text("loader.error.installerSaveFailed").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(Loc::text("loader.error.noLauncherProfiles")
.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() ? Loc::text("loader.error.noJava")
.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(Loc::text("loader.progress.installing").arg(loaderTitle(m_loader)));
m_currentFile = Loc::text("loader.progress.installerRunning");
// Прогресс установщика неизвестен: панель покажет «…».
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(Loc::text("loader.error.installerStartFailed")
.arg(loaderTitle(m_loader), m_process->errorString()));
});
m_process->start();
if (!m_process->waitForStarted(kTransferTimeoutMs)) {
fail(Loc::text("loader.error.installerFailedToStart")
.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(Loc::text("loader.error.installerTimeout").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 = Loc::text("loader.error.installerExitCode")
.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'
+ Loc::text("loader.error.fullLog").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 = Loc::text("loader.error.noVersionProduced")
.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(Loc::text("loader.error.cleanupFailed").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 Loc::text("loader.error.zlibNg");
}
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(Loc::text("loader.progress.downloadingVersionFiles"));
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);
}