Files
minecraft-launcher/modloaderinstaller.cpp
T

553 lines
22 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 "javalocator.h"
#include "launcherpaths.h"
#include "minecraftversion.h"
#include "modloaderversionservice.h"
#include "versioninstaller.h"
#include <QDir>
#include <QFileInfo>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcess>
#include <QSaveFile>
#include <QTimer>
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;
}
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();
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;
}
const QUrl installerUrl = entry->installerUrl;
// Установщик патчит versions/<mc>/<mc>.jar — без ванильной версии он не отработает.
ensureBaseVersion([this, installerUrl] {
downloadInstaller(installerUrl, [this](const QString &path) { runInstaller(path); });
});
}
void ModLoaderInstaller::ensureBaseVersion(std::function<void()> next)
{
if (QFile::exists(versionJsonPath(m_gameDir, m_gameVersion))
&& VersionLoader::installedVersions(m_gameDir).contains(m_gameVersion)) {
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);
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);
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) {
m_installerLog << line.trimmed();
emit log(line.trimmed());
}
// Хвоста хватает, чтобы показать причину отказа; весь лог не нужен.
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;
}
if (!m_running || m_canceling)
return;
if (crashed || exitCode != 0) {
fail(tr("Установщик %1 завершился с ошибкой (код %2).\n%3")
.arg(loaderTitle(m_loader))
.arg(exitCode)
.arg(m_installerLog.join(u'\n')));
return;
}
// Какой профиль появился — выясняем разницей: id у Forge разные по эпохам
// (1.20.1-forge-47.4.0, 1.12.2-forge-14.23.5.2859, neoforge-21.1.66).
const QStringList after = VersionLoader::installedVersions(m_gameDir);
QStringList added;
for (const QString &id : after) {
if (!m_versionsBefore.contains(id))
added << id;
}
if (added.isEmpty()) {
fail(tr("Установщик %1 отработал, но новая версия в %2 не появилась.\n%3")
.arg(loaderTitle(m_loader),
QDir::toNativeSeparators(m_gameDir + QStringLiteral("/versions")),
m_installerLog.join(u'\n')));
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()};
}
handOffToVersionInstaller(added.first());
}
// ── Общий хвост ────────────────────────────────────────────────────────────
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();
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);
}