diff --git a/CMakeLists.txt b/CMakeLists.txt index f00aeec..2ae60b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,7 @@ -cmake_minimum_required(VERSION 3.16) +cmake_minimum_required(VERSION 3.20) -project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX) +# C нужен для эталонного zlib: см. блок про установщики Forge ниже. +project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX C) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -24,6 +25,8 @@ qt_add_qml_module(appMinecraft_launcher minecraftversion.cpp javalocator.h javalocator.cpp + zlibreference.h + zlibreference.cpp javaruntime.h javaruntime.cpp javaruntimeservice.h @@ -75,6 +78,73 @@ target_link_libraries(appMinecraft_launcher ) include(GNUInstallDirs) + +# ── Эталонный zlib для установщиков Forge и NeoForge ──────────────────────── +# +# Установщик Forge сверяет sha1 каждого jar, который сам же и собирает, с +# эталоном из install_profile.json. Эталон посчитан на обычном zlib, а +# дистрибутивы вроде CachyOS и Fedora подставляют вместо него zlib-ng: сжатие +# корректное, но побайтово другое, поэтому установка падает на любой версии +# игры («Processor failed, invalid outputs»). Сменить Java не выйдет — сборки +# OpenJDK под Linux берут libz.so.1 из системы. +# +# Поэтому собираем рядом обычный zlib и подсовываем его через LD_PRELOAD +# только процессу установщика. Сам лаунчер с ним не линкуется, системный +# zlib-ng остаётся на месте. +if(UNIX AND NOT APPLE) + include(FetchContent) + # SOURCE_SUBDIR указывает на несуществующий каталог: нужны только исходники. + # add_subdirectory от zlib притащил бы свои install-правила и цели example + # и minigzip. + FetchContent_Declare(zlib_reference + GIT_REPOSITORY https://github.com/madler/zlib.git + GIT_TAG v1.3.1 + GIT_SHALLOW TRUE + SOURCE_SUBDIR do-not-configure + ) + FetchContent_MakeAvailable(zlib_reference) + + set(LAUNCHER_ZLIB_DIR "${CMAKE_INSTALL_LIBDIR}/galeonLauncher") + + add_library(launcher_zlib_reference SHARED + ${zlib_reference_SOURCE_DIR}/adler32.c + ${zlib_reference_SOURCE_DIR}/compress.c + ${zlib_reference_SOURCE_DIR}/crc32.c + ${zlib_reference_SOURCE_DIR}/deflate.c + ${zlib_reference_SOURCE_DIR}/gzclose.c + ${zlib_reference_SOURCE_DIR}/gzlib.c + ${zlib_reference_SOURCE_DIR}/gzread.c + ${zlib_reference_SOURCE_DIR}/gzwrite.c + ${zlib_reference_SOURCE_DIR}/infback.c + ${zlib_reference_SOURCE_DIR}/inffast.c + ${zlib_reference_SOURCE_DIR}/inflate.c + ${zlib_reference_SOURCE_DIR}/inftrees.c + ${zlib_reference_SOURCE_DIR}/trees.c + ${zlib_reference_SOURCE_DIR}/uncompr.c + ${zlib_reference_SOURCE_DIR}/zutil.c + ) + target_include_directories(launcher_zlib_reference PRIVATE ${zlib_reference_SOURCE_DIR}) + # То же, что выставляет собственный configure zlib: без этого gz*-часть не + # видит unistd.h и не собирается. + target_compile_definitions(launcher_zlib_reference PRIVATE + HAVE_UNISTD_H=1 HAVE_STDARG_H=1 _LARGEFILE64_SOURCE=1) + # SONAME libz.so.1 обязателен: с ним ld.so считает предзагруженный объект + # закрывающим зависимость, и системный zlib-ng в процессе java не появится. + set_target_properties(launcher_zlib_reference PROPERTIES + OUTPUT_NAME z + VERSION 1.3.1 + SOVERSION 1 + C_VISIBILITY_PRESET default + LIBRARY_OUTPUT_DIRECTORY $/zlib + ) + add_dependencies(appMinecraft_launcher launcher_zlib_reference) + + target_compile_definitions(appMinecraft_launcher PRIVATE + LAUNCHER_ZLIB_INSTALL_DIR="${CMAKE_INSTALL_FULL_LIBDIR}/galeonLauncher" + ) + install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR}) +endif() + install(TARGETS appMinecraft_launcher BUNDLE DESTINATION . LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} diff --git a/modloaderinstaller.cpp b/modloaderinstaller.cpp index 579abf4..ecc1144 100644 --- a/modloaderinstaller.cpp +++ b/modloaderinstaller.cpp @@ -1,10 +1,12 @@ #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 @@ -15,9 +17,12 @@ #include #include #include +#include #include #include +#include + namespace { constexpr int kTransferTimeoutMs = 30000; @@ -136,10 +141,15 @@ void ModLoaderInstaller::install(const QString &gameDir, : warning); return; } - if (loader == ModLoader::Fabric || loader == ModLoader::Quilt) - beginFabricLike(); - else - beginInstallerJar(); + // Любой лоадер — надстройка над ванильной версией: и профиль Fabric с его + // inheritsFrom, и патч клиентского jar у Forge упрутся в недокачанную базу. + // Поэтому шлюз один на всех и стоит до выбора ветки. + ensureBaseVersion([this, loader] { + if (loader == ModLoader::Fabric || loader == ModLoader::Quilt) + beginFabricLike(); + else + beginInstallerJar(); + }); }); } @@ -153,8 +163,12 @@ void ModLoaderInstaller::cancel() m_reply->abort(); m_reply = nullptr; } - if (m_process && m_process->state() != QProcess::NotRunning) + 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 @@ -260,19 +274,26 @@ void ModLoaderInstaller::beginInstallerJar() return; } - const QUrl installerUrl = entry->installerUrl; - // Установщик патчит versions//.jar — без ванильной версии он не отработает. - ensureBaseVersion([this, installerUrl] { - downloadInstaller(installerUrl, [this](const QString &path) { runInstaller(path); }); - }); + // Ванильную версию к этому моменту уже проверил ensureBaseVersion из install(). + downloadInstaller(entry->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; + // Наличие 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)); @@ -413,6 +434,20 @@ void ModLoaderInstaller::runInstaller(const QString &installerPath) } 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("работает установщик, это может занять несколько минут"); // Прогресс установщика неизвестен: панель покажет «…». @@ -427,12 +462,25 @@ void ModLoaderInstaller::runInstaller(const QString &installerPath) 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) { - m_installerLog << line.trimmed(); - emit log(line.trimmed()); + const QString text = line.trimmed(); + m_installerLog << text; + appendInstallerLog(text); + emit log(text); } // Хвоста хватает, чтобы показать причину отказа; весь лог не нужен. while (m_installerLog.size() > kLogTailLines) @@ -471,31 +519,49 @@ void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) m_process->deleteLater(); m_process = nullptr; } + closeInstallerLog(); 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'))); + 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). - const QStringList after = VersionLoader::installedVersions(m_gameDir); - QStringList added; - for (const QString &id : after) { - if (!m_versionsBefore.contains(id)) - added << id; + 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()) { - fail(tr("Установщик %1 отработал, но новая версия в %2 не появилась.\n%3") - .arg(loaderTitle(m_loader), - QDir::toNativeSeparators(m_gameDir + QStringLiteral("/versions")), - m_installerLog.join(u'\n'))); + 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) { @@ -507,9 +573,78 @@ void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) 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) @@ -537,6 +672,10 @@ void ModLoaderInstaller::reset() 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; diff --git a/modloaderinstaller.h b/modloaderinstaller.h index ba4feb1..4a024b3 100644 --- a/modloaderinstaller.h +++ b/modloaderinstaller.h @@ -6,6 +6,7 @@ #include #include +class QFile; class QNetworkAccessManager; class QNetworkReply; class QProcess; @@ -20,7 +21,11 @@ class VersionInstaller; // // * Forge и NeoForge так не умеют: их установка — это патч клиентского jar, // поэтому запускаем официальный installer.jar найденной Java в headless-режиме -// и смотрим, какой профиль появился в versions. +// и смотрим, какой профиль появился в versions. Процессу установщика при этом +// подсовывается эталонный zlib — см. ZlibReference. +// +// Оба пути начинаются одинаково: пока ванильная версия не скачана целиком, +// ставить лоадер некуда, поэтому ensureBaseVersion стоит перед обеими ветками. // // Набор геттеров повторяет VersionInstaller: панель загрузки в интерфейсе // читает их одинаково, независимо от того, кто сейчас работает. @@ -69,6 +74,11 @@ private: bool writeLauncherProfilesStub(); void runInstaller(const QString &installerPath); void handleInstallerFinished(int exitCode, bool crashed); + QStringList versionsAddedByInstaller() const; + void removeVersionsAddedByInstaller(); + QString installerFailureHint() const; + void appendInstallerLog(const QString &line); + void closeInstallerLog(); void handOffToVersionInstaller(const QString &versionId); void setStage(const QString &stage); @@ -97,6 +107,14 @@ private: // разницей, а не угадыванием строки — id у Forge разные по эпохам. QStringList m_versionsBefore; QStringList m_installerLog; + // Установщик успел отработать — значит, за появившиеся в versions папки + // отвечаем мы и при неудаче их надо убрать. + bool m_installerStarted = false; + // В системе zlib-ng, а подменить его было нечем: почти наверняка причина + // расхождения sha1, о которой стоит сказать прямым текстом. + bool m_zlibNgUnpatched = false; + QFile *m_installerLogFile = nullptr; + QString m_installerLogPath; // Ждём, пока VersionInstaller докачает то, что установщик не положил. QString m_awaitingVersionId; diff --git a/zlibreference.cpp b/zlibreference.cpp new file mode 100644 index 0000000..ca330b5 --- /dev/null +++ b/zlibreference.cpp @@ -0,0 +1,101 @@ +#include "zlibreference.h" + +#include +#include +#include +#include +#include + +#include + +#if defined(Q_OS_LINUX) +#include +#endif + +namespace { + +QString translate(const char *text) +{ + return QCoreApplication::translate("ZlibReference", text); +} + +} // namespace + +bool ZlibReference::systemIsZlibNg() +{ +#if defined(Q_OS_LINUX) + // Спрашиваем ровно ту библиотеку, которую подберёт ld.so процессу java. + // zlib-ng в режиме совместимости честно пишет о себе в zlibVersion(). + static const bool result = [] { + void *handle = dlopen("libz.so.1", RTLD_LAZY); + if (!handle) + return false; + using VersionFn = const char *(*)(); + const auto zlibVersion = reinterpret_cast(dlsym(handle, "zlibVersion")); + const QString version = zlibVersion ? QString::fromLatin1(zlibVersion()) : QString(); + dlclose(handle); + return version.contains(QLatin1String("zlib-ng"), Qt::CaseInsensitive); + }(); + return result; +#else + return false; +#endif +} + +QString ZlibReference::bundledPath() +{ +#if defined(Q_OS_LINUX) + static const QString path = [] { + QStringList candidates; + const QString appDir = QCoreApplication::applicationDirPath(); + if (!appDir.isEmpty()) { + candidates << appDir + QStringLiteral("/zlib/libz.so.1") // дерево сборки + << appDir + QStringLiteral("/../lib/galeonLauncher/libz.so.1"); + } +#ifdef LAUNCHER_ZLIB_INSTALL_DIR + candidates << QStringLiteral(LAUNCHER_ZLIB_INSTALL_DIR) + QStringLiteral("/libz.so.1"); +#endif + for (const QString &candidate : std::as_const(candidates)) { + const QFileInfo info(candidate); + if (info.exists() && info.isFile()) + return info.canonicalFilePath(); + } + return QString(); + }(); + return path; +#else + return {}; +#endif +} + +bool ZlibReference::applyTo(QProcessEnvironment &env, QString *note) +{ +#if defined(Q_OS_LINUX) + if (!systemIsZlibNg()) { + if (note) + note->clear(); + return true; + } + + const QString path = bundledPath(); + if (path.isEmpty()) { + if (note) + *note = translate("В системе zlib-ng, а эталонный libz.so.1 рядом с лаунчером " + "не найден — установщик почти наверняка не сойдётся по sha1."); + return false; + } + + // Не затираем чужой LD_PRELOAD: наш идёт первым, остальное остаётся. + const QString existing = env.value(QStringLiteral("LD_PRELOAD")); + env.insert(QStringLiteral("LD_PRELOAD"), existing.isEmpty() ? path : path + u':' + existing); + if (note) + *note = translate("Системный zlib-ng подменён эталонным zlib: %1") + .arg(QDir::toNativeSeparators(path)); + return true; +#else + Q_UNUSED(env); + if (note) + note->clear(); + return true; +#endif +} diff --git a/zlibreference.h b/zlibreference.h new file mode 100644 index 0000000..d6a3b1f --- /dev/null +++ b/zlibreference.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +class QProcessEnvironment; + +// Установщики Forge и NeoForge сверяют sha1 каждого jar, который сами же и +// собирают, с эталоном из install_profile.json. Эталон посчитан на обычном +// zlib, а CachyOS, Fedora и другие подставляют вместо него zlib-ng: сжатие +// корректное, но побайтово другое, поэтому установка падает на любой версии +// игры сообщением «Processor failed, invalid outputs». +// +// Сменить Java не поможет — сборки OpenJDK под Linux берут libz.so.1 из +// системы. Поэтому рядом с лаунчером лежит собранный обычный zlib, и процессу +// установщика мы подсовываем его через LD_PRELOAD. Системный zlib-ng при этом +// остаётся на месте: подмена живёт ровно один процесс. +namespace ZlibReference { + +// libz.so.1, который достанется процессу java, — это zlib-ng? +bool systemIsZlibNg(); + +// Путь к собранному рядом эталонному libz.so.1; пусто, если его нет. +QString bundledPath(); + +// Дописывает LD_PRELOAD, если подмена нужна. true — окружение готово (в том +// числе когда подменять нечего). note — строка для лога и текста ошибки. +bool applyTo(QProcessEnvironment &env, QString *note = nullptr); + +} // namespace ZlibReference