#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 #include #include #include #include #include #include #include #include #include #include #include #include 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 next) { // Наличие versions//.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(); auto failGuard = std::make_shared(); *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 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//.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); }