#include "modloaderinstaller.h" #include "javalocator.h" #include "launcherpaths.h" #include "minecraftversion.h" #include "modloaderversionservice.h" #include "versioninstaller.h" #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; } 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//.jar — без ванильной версии он не отработает. ensureBaseVersion([this, installerUrl] { downloadInstaller(installerUrl, [this](const QString &path) { runInstaller(path); }); }); } void ModLoaderInstaller::ensureBaseVersion(std::function 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(); 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); 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); }