#include "launcherbackend.h" #include "authservice.h" #include "gamelauncher.h" #include "javalocator.h" #include "launcherpaths.h" #include "versioninstaller.h" #include "versionmanifestservice.h" #include "modloaderinstaller.h" #include "modloaderversionservice.h" #include "buildswitcher.h" #include #include #include #include #include #include #include #include #include #include #include #include namespace { const QString kOffline = QStringLiteral("offline"); const QString kElyBy = QStringLiteral("elyby"); int settingInt(const QVariantMap &settings, const QString &key, int fallback) { const QVariant value = settings.value(key); bool ok = false; const int parsed = value.toInt(&ok); return ok ? parsed : fallback; } } // namespace LauncherBackend::LauncherBackend(QObject *parent) : QObject(parent) , m_auth(new AuthService(this)) , m_launcher(new GameLauncher(this)) , m_manifest(new VersionManifestService(this)) , m_installer(new VersionInstaller(m_manifest, this)) , m_loaderMeta(new ModLoaderVersionService(this)) , m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this)) , m_switcher(new BuildSwitcher(this)) { // Папка лаунчера создаётся при первом запуске и проверяется при каждом // следующем — если её удалили, она появится снова. QString storageError; if (!LauncherPaths::ensureRootExists(&storageError)) m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError); loadSettings(); // Порядок важен: миграция должна успеть до того, как ensureDataFiles() // создаст пустой customBuilds.json поверх старых данных. migrateLegacyBuildsFile(); loadData(); ensureDataFiles(); // Прошлое переключение оборвалось на середине. Доигрываем его сами, а не // предлагаем пользователю переключиться заново: архив прежней сборки уже // записан целиком, а вот повторное переключение сначала заархивировало бы // поверх него нынешнее — наполовину разобранное — содержимое .minecraft. const QString interrupted = m_switcher->interruptedSwitchWarning(); if (!interrupted.isEmpty()) { m_storageIssues << interrupted; QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); }); } connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress); connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress); connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput); connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) { emit gameOutput(commandLine); emit gameRunningChanged(); }); connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) { emit gameRunningChanged(); emit gameFinished(exitCode, crashed); }); connect(m_manifest, &VersionManifestService::versionsChanged, this, &LauncherBackend::invalidateCatalog); connect(m_manifest, &VersionManifestService::refreshingChanged, this, &LauncherBackend::catalogLoadingChanged); connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) { emit downloadChanged(); emit busyChanged(); emit launchProgress(tr("Загрузка версии «%1»…").arg(id)); }); connect(m_installer, &VersionInstaller::progressChanged, this, &LauncherBackend::downloadChanged); // Порядок важен: на installedVersionsChanged интерфейс перечитывает каталог, // и устаревший кэш вернулся бы наружу. connect(m_installer, &VersionInstaller::finished, this, [this](const QString &id) { invalidateCatalog(); emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); emit launchProgress(tr("Версия «%1» загружена").arg(id)); }); connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id, const QString &message) { // Часть файлов всё же появилась — список установленного мог измениться. invalidateCatalog(); emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message)); }); connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] { emit switchChanged(); emit busyChanged(); }); connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) { // Активной сборка становится только здесь: провалившееся переключение // оставляет активной прежнюю. m_settings.insert(QStringLiteral("activeBuildId"), toBuildId); saveSettings(); invalidateCatalog(); emit activeBuildChanged(); emit installedVersionsChanged(); emit switchChanged(); emit busyChanged(); const int index = indexOfBuildId(toBuildId); emit launchProgress(tr("Активна сборка «%1»") .arg(index >= 0 ? m_builds[index].name : QString())); }); connect(m_switcher, &BuildSwitcher::failed, this, [this](int, const QString &message, bool gameDirIntact) { invalidateCatalog(); emit installedVersionsChanged(); emit switchChanged(); emit busyChanged(); emit launchError(gameDirIntact ? tr("Сборка не переключена: %1").arg(message) : tr("Сборка не переключена: %1\nСодержимое .minecraft " "осталось разобранным — повторите переключение.") .arg(message)); }); connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged, this, &LauncherBackend::loaderVersionsChanged); connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged, this, &LauncherBackend::downloadChanged); // Установщик лоадера делит панель прогресса и флаг занятости с обычным // установщиком версий — сигналы у них одни и те же. connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) { emit busyChanged(); emit downloadChanged(); emit launchProgress(tr("Установка %1…").arg(label)); }); connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged, this, &LauncherBackend::downloadChanged); connect(m_loaderInstaller, &ModLoaderInstaller::log, this, &LauncherBackend::gameOutput); connect(m_loaderInstaller, &ModLoaderInstaller::finished, this, [this](const QString &loader, const QString &gameVersion, const QString &loaderVersion, const QString &producedVersionId) { const int index = indexOfBuildId(m_loaderInstallBuildId); m_loaderInstallBuildId = -1; if (index >= 0) { updateCustomBuild(index, {{QStringLiteral("loader"), loader}, {QStringLiteral("loaderVersion"), loaderVersion}, {QStringLiteral("resolvedVersionId"), producedVersionId}}); } invalidateCatalog(); emit installedVersionsChanged(); emit busyChanged(); emit downloadChanged(); emit launchProgress(tr("Готово: %1 для Minecraft %2") .arg(producedVersionId, gameVersion)); }); connect(m_loaderInstaller, &ModLoaderInstaller::failed, this, [this](const QString &label, const QString &message) { m_loaderInstallBuildId = -1; invalidateCatalog(); emit installedVersionsChanged(); emit busyChanged(); emit downloadChanged(); emit launchError(tr("%1: %2").arg(label, message)); }); connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) { m_loaderInstallBuildId = -1; invalidateCatalog(); emit installedVersionsChanged(); emit busyChanged(); emit downloadChanged(); emit launchProgress(tr("Установка %1 отменена").arg(label)); }); connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) { invalidateCatalog(); emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id)); }); // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно. if (!m_storageIssues.isEmpty()) { QTimer::singleShot(0, this, [this] { emit launchError(m_storageIssues.join(u'\n')); }); } } LauncherBackend::~LauncherBackend() = default; QStringList LauncherBackend::profileNames() const { QStringList names; for (const auto &p : m_profiles) names << p.name; return names; } QStringList LauncherBackend::customBuildNames() const { QStringList names; for (const auto &b : m_builds) names << b.name; return names; } QStringList LauncherBackend::installedVersions() const { return VersionLoader::installedVersions(gameDir()); } bool LauncherBackend::gameRunning() const { return m_launcher->isRunning(); } void LauncherBackend::addProfile(const QString &name, const QString &login, const QString &password, const QString &authType) { Profile profile; profile.id = m_nextProfileId++; profile.name = name; profile.login = login; profile.password = password; profile.authType = (authType == kElyBy) ? kElyBy : kOffline; profile.clientToken = AuthService::generateClientToken(); m_profiles.append(profile); saveProfiles(); emit profilesChanged(); } void LauncherBackend::addCustomBuild(const QString &name, const QString &serverUrl, const QString &minecraftVersion) { CustomBuild build; build.id = m_nextBuildId++; build.name = name; build.serverUrl = serverUrl; build.minecraftVersion = minecraftVersion; m_builds.append(build); saveCustomBuilds(); emit customBuildsChanged(); maybeInstall(launchVersionIdOf(m_builds.last())); } void LauncherBackend::updateProfile(int index, const QString &name, const QString &login, const QString &password, const QString &authType) { if (index < 0 || index >= m_profiles.size()) return; Profile &profile = m_profiles[index]; // Смена логина или пароля обесценивает сохранённый токен Ely.by. if (profile.login != login || profile.password != password) { profile.accessToken.clear(); profile.uuid.clear(); profile.resolvedName.clear(); } profile.name = name; profile.login = login; profile.password = password; profile.authType = (authType == kElyBy) ? kElyBy : kOffline; if (profile.clientToken.isEmpty()) profile.clientToken = AuthService::generateClientToken(); saveProfiles(); emit profilesChanged(); } void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields) { if (index < 0 || index >= m_builds.size()) return; CustomBuild &build = m_builds[index]; const auto take = [&fields](const char *key, QString &target) { const auto it = fields.constFind(QLatin1String(key)); if (it == fields.constEnd()) return false; target = it->toString(); return true; }; // Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит. // Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию. const QString previousMinecraft = build.minecraftVersion; take("name", build.name); take("serverUrl", build.serverUrl); take("minecraftVersion", build.minecraftVersion); take("loader", build.loader); take("loaderVersion", build.loaderVersion); take("resolvedVersionId", build.resolvedVersionId); if (build.minecraftVersion != previousMinecraft && !fields.contains(QStringLiteral("loaderVersion"))) { build.loaderVersion.clear(); build.resolvedVersionId.clear(); } saveCustomBuilds(); emit customBuildsChanged(); emit activeBuildChanged(); // имя активной сборки могло измениться // maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу // правки, и загрузка стартовала бы почти на каждое действие. Для этого есть // installCustomBuild(). } QVariantMap LauncherBackend::profileAt(int index) const { if (index < 0 || index >= m_profiles.size()) return {}; const auto &p = m_profiles[index]; return {{"name", p.name}, {"login", p.login}, {"password", p.password}, {"authType", p.authType}}; } QVariantMap LauncherBackend::customBuildAt(int index) const { if (index < 0 || index >= m_builds.size()) return {}; const auto &b = m_builds[index]; return {{"name", b.name}, {"serverUrl", b.serverUrl}, {"minecraftVersion", b.minecraftVersion}, {"loader", b.loader}, {"loaderVersion", b.loaderVersion}, {"resolvedVersionId", b.resolvedVersionId}, {"launchVersionId", launchVersionIdOf(b)}}; } void LauncherBackend::removeProfile(int index) { if (index < 0 || index >= m_profiles.size()) return; m_profiles.removeAt(index); saveProfiles(); emit profilesChanged(); } QVariantMap LauncherBackend::customBuildRemovalInfo(int index) const { if (index < 0 || index >= m_builds.size()) return {}; const CustomBuild &build = m_builds[index]; const bool active = m_settings.value(QStringLiteral("activeBuildId")).toInt() == build.id || m_builds.size() == 1; return {{QStringLiteral("name"), build.name}, {QStringLiteral("hasArchive"), m_switcher->hasArchive(build.id)}, {QStringLiteral("active"), active}, {QStringLiteral("lastOne"), m_builds.size() == 1}}; } void LauncherBackend::removeCustomBuild(int index) { if (index < 0 || index >= m_builds.size()) return; if (m_switcher->isRunning() || m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { emit launchError(tr("Дождитесь окончания текущей операции")); return; } if (m_launcher->isRunning()) { emit launchError(tr("Сначала закройте игру")); return; } const CustomBuild removed = m_builds.takeAt(index); const bool wasActive = m_settings.value(QStringLiteral("activeBuildId")).toInt() == removed.id || m_builds.isEmpty(); saveCustomBuilds(); // Раньше удалялась только запись, а архив сборки оставался лежать в // galeonLauncher/builds вместе со ссылкой на него в index.json. if (!m_switcher->forgetBuild(removed.id)) { emit launchError(tr("Сборка «%1» удалена, но её архив в %2 стереть не удалось") .arg(removed.name, QDir::toNativeSeparators(LauncherPaths::buildStorageDir()))); } emit customBuildsChanged(); emit activeBuildChanged(); if (!wasActive) return; // Удалили активную сборку: в .minecraft лежит её содержимое, а активной // становится другая. Оставить как есть нельзя — при следующем переключении // чужое содержимое уехало бы в архив новой сборки поверх её собственного. m_settings.remove(QStringLiteral("activeBuildId")); saveSettings(); if (m_builds.isEmpty()) { emit activeBuildChanged(); return; } const CustomBuild &next = m_builds.first(); emit launchProgress(tr("Активна сборка «%1»…").arg(next.name)); // from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе // с ней. Дальше обычная очистка и распаковка новой активной сборки. m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir()); emit switchChanged(); emit busyChanged(); } int LauncherBackend::indexOfBuildId(int buildId) const { for (int i = 0; i < m_builds.size(); ++i) { if (m_builds[i].id == buildId) return i; } return -1; } int LauncherBackend::activeBuildIndex() const { if (m_builds.isEmpty()) return -1; const int index = indexOfBuildId(m_settings.value(QStringLiteral("activeBuildId")).toInt()); // Настройка могла указывать на удалённую сборку — тогда активна первая. return index >= 0 ? index : 0; } void LauncherBackend::setActiveBuildIndex(int index) { const int current = activeBuildIndex(); if (index < 0 || index >= m_builds.size() || index == current) return; if (m_switcher->isRunning()) { emit launchError(tr("Смена сборки уже выполняется")); return; } if (m_launcher->isRunning()) { emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся")); return; } if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { emit launchError(tr("Дождитесь окончания текущей операции")); return; } // Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в // её архив, и только потом на его место разворачивается выбранная. const CustomBuild &target = m_builds[index]; emit launchProgress(tr("Смена сборки на «%1»…").arg(target.name)); m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1, current >= 0 ? m_builds[current].name : QString(), target.id, target.name, gameDir()); emit switchChanged(); emit busyChanged(); } QString LauncherBackend::activeBuildName() const { const int index = activeBuildIndex(); return index < 0 ? QString() : m_builds[index].name; } void LauncherBackend::installCustomBuild(int index) { // Установка сборки — это установка её версии игры и, если выбран, // модлоадера: они ставятся одной цепочкой. installLoaderForBuild(index); } QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const { // resolvedVersionId появляется после установки модлоадера. Пользователь мог // назвать сборку как угодно — тогда версией считаем само имя: так работали // записи, созданные до появления каталога версий. if (!build.resolvedVersionId.isEmpty()) return build.resolvedVersionId; return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion; } QStringList LauncherBackend::checkInstallation(int buildIndex) const { if (buildIndex < 0 || buildIndex >= m_builds.size()) return {tr("Сборка не выбрана")}; const CustomBuild &entry = m_builds[buildIndex]; const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) return {tr("У сборки не выбрана версия Minecraft. Проверьте %1") .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))}; QString error; const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error); if (!version.isValid()) { return {error.isEmpty() ? tr("Версия «%1» из сборки «%2» не установлена в %3") .arg(id, entry.name, QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions"))) : error}; } LaunchOptions options; options.gameDir = gameDir(); options.versionId = id; return GameLauncher::missingFiles(options, version); } // ── Каталог версий ───────────────────────────────────────────────────────── void LauncherBackend::invalidateCatalog() { m_catalogDirty = true; emit versionCatalogChanged(); } bool LauncherBackend::catalogLoading() const { return m_manifest->isRefreshing(); } void LauncherBackend::refreshVersionCatalog(bool force) { // Список установленного мог поменяться и без нас (папку добавили руками). invalidateCatalog(); m_manifest->ensureLoaded([this](bool ok, const QString &warning) { if (!ok && !warning.isEmpty()) emit launchError(warning); }, force); } bool LauncherBackend::isVersionInstalled(const QString &versionId) const { if (versionId.trimmed().isEmpty()) return false; return VersionLoader::installedVersions(gameDir()).contains(versionId); } QVariantList LauncherBackend::versionCatalog() const { if (m_catalogDirty) { rebuildCatalog(); m_catalogDirty = false; } return m_catalog; } void LauncherBackend::rebuildCatalog() const { m_catalog.clear(); // Плоский список в порядке манифеста — новые версии сверху. Раскладку по // категориям и отбор делает окно выбора версии: ему нужен один список, // который можно фильтровать, а не готовая структура с разделителями. const QList remote = m_manifest->versions(); const QStringList installed = VersionLoader::installedVersions(gameDir()); const QSet installedSet(installed.cbegin(), installed.cend()); const auto category = [](const QString &type) { if (type == QLatin1String("release") || type == QLatin1String("snapshot") || type == QLatin1String("old_beta") || type == QLatin1String("old_alpha")) return type; return QStringLiteral("other"); }; const auto makeVersionRow = [&category](const QString &id, const QString &type, bool isInstalled) { return QVariantMap{{QStringLiteral("id"), id}, {QStringLiteral("type"), type}, {QStringLiteral("category"), category(type)}, {QStringLiteral("label"), id}, {QStringLiteral("installed"), isInstalled}, {QStringLiteral("search"), (id + u' ' + type).toLower()}}; }; m_catalog.reserve(remote.size() + installed.size()); QSet known; known.reserve(remote.size()); for (const RemoteVersionEntry &entry : remote) { known.insert(entry.id); m_catalog.append(makeVersionRow(entry.id, entry.type, installedSet.contains(entry.id))); } // Сборки модлоадеров манифест не знает — они попадают в «Прочие». QStringList unknown; for (const QString &id : installed) { if (!known.contains(id)) unknown << id; } std::sort(unknown.begin(), unknown.end()); for (const QString &id : std::as_const(unknown)) m_catalog.append(makeVersionRow(id, QString(), true)); } // ── Загрузка версий ──────────────────────────────────────────────────────── void LauncherBackend::maybeInstall(const QString &versionId) { const QString id = versionId.trimmed(); if (id.isEmpty() || isVersionInstalled(id)) return; if (m_installer->versionId() == id || m_installer->isQueued(id)) return; // Манифест здесь не спрашиваем: установщик сам скажет, что версии в нём нет, // и это правильный ответ на вручную вписанный id сборки. m_installer->install(gameDir(), id); } void LauncherBackend::installVersion(const QString &versionId) { const QString id = versionId.trimmed(); if (id.isEmpty()) return; if (m_installer->versionId() == id || m_installer->isQueued(id)) return; m_installer->install(gameDir(), id); } void LauncherBackend::cancelDownload() { // Установщик лоадера сам остановит вложенную установку версии. if (m_loaderInstaller->isRunning()) m_loaderInstaller->cancel(); else m_installer->cancel(); } // Панель загрузки одна на два установщика: пока идёт установка модлоадера, она // показывает его стадии, включая вложенную загрузку файлов версии. bool LauncherBackend::downloading() const { return m_loaderInstaller->isRunning() || m_installer->isRunning(); } double LauncherBackend::downloadProgress() const { if (!m_loaderInstaller->isRunning()) return m_installer->fraction(); // Внутри установки лоадера файлы качает VersionInstaller — его доля точнее. return m_installer->isRunning() ? m_installer->fraction() : m_loaderInstaller->fraction(); } QString LauncherBackend::downloadVersion() const { if (m_loaderInstaller->isRunning()) return m_loaderInstaller->label(); return m_installer->versionId(); } QString LauncherBackend::downloadStatus() const { QString stage = m_installer->stage(); QString file = m_installer->currentFile(); if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) { stage = m_loaderInstaller->stage(); file = m_loaderInstaller->currentFile(); } if (stage.isEmpty()) return file; if (file.isEmpty()) return stage; return QStringLiteral("%1 — %2").arg(stage, file); } qint64 LauncherBackend::downloadBytesDone() const { if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) return m_loaderInstaller->bytesDone(); return m_installer->bytesDone(); } qint64 LauncherBackend::downloadBytesTotal() const { if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) return m_loaderInstaller->bytesTotal(); return m_installer->bytesTotal(); } // ── Модлоадеры ───────────────────────────────────────────────────────────── QVariantList LauncherBackend::loaderVersions(const QString &loaderKey, const QString &gameVersion) const { const auto loader = loaderFromKey(loaderKey); if (!loader) return {}; QVariantList result; const QList entries = m_loaderMeta->versions(*loader, gameVersion); result.reserve(entries.size()); for (const LoaderVersionEntry &entry : entries) { QString label = entry.loaderVersion; if (entry.recommended) label += tr(" — рекомендуемая"); else if (!entry.stable) label += tr(" — нестабильная"); result.append(QVariantMap{{QStringLiteral("version"), entry.loaderVersion}, {QStringLiteral("label"), label}, {QStringLiteral("recommended"), entry.recommended}, {QStringLiteral("stable"), entry.stable}}); } return result; } void LauncherBackend::refreshLoaderVersions(const QString &loaderKey, const QString &gameVersion, bool force) { const auto loader = loaderFromKey(loaderKey); if (!loader) return; m_loaderMeta->ensureLoaded(*loader, gameVersion, [this, loaderKey, gameVersion]( bool ok, const QString &warning) { if (!ok && !warning.isEmpty()) emit launchError(warning); // Кэш мог ответить сразу, без сигнала versionsChanged. emit loaderVersionsChanged(loaderKey, gameVersion); }, force); } bool LauncherBackend::loaderVersionsLoading(const QString &loaderKey, const QString &gameVersion) const { const auto loader = loaderFromKey(loaderKey); return loader && m_loaderMeta->isRefreshing(*loader, gameVersion); } void LauncherBackend::installLoaderForBuild(int index) { if (index < 0 || index >= m_builds.size()) return; if (busy()) { emit launchError(tr("Дождитесь окончания текущей загрузки")); return; } const CustomBuild &build = m_builds[index]; if (build.minecraftVersion.trimmed().isEmpty()) { emit launchError(tr("Сначала выберите версию Minecraft для сборки «%1»").arg(build.name)); return; } // Лоадер не выбран — сборка ванильная, ставим саму версию игры. const auto loader = loaderFromKey(build.loader); if (!loader) { if (build.resolvedVersionId != build.minecraftVersion) updateCustomBuild(index, {{QStringLiteral("resolvedVersionId"), QString()}}); maybeInstall(build.minecraftVersion); return; } if (build.loaderVersion.trimmed().isEmpty()) { emit launchError(tr("Выберите версию %1 для сборки «%2»") .arg(loaderTitle(*loader), build.name)); return; } m_loaderInstallBuildId = build.id; m_loaderInstaller->install(gameDir(), *loader, build.minecraftVersion, build.loaderVersion, m_settings.value(QStringLiteral("javaPath")).toString()); } void LauncherBackend::launchGame(int profileIndex, int buildIndex) { if (m_loaderInstaller->isRunning()) { emit launchError(tr("Идёт установка «%1» — дождитесь окончания") .arg(m_loaderInstaller->label())); return; } if (m_installer->isRunning()) { emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания") .arg(m_installer->versionId())); return; } if (m_busy) { emit launchError(tr("Запуск уже выполняется")); return; } if (m_launcher->isRunning()) { emit launchError(tr("Игра уже запущена")); return; } if (profileIndex < 0 || profileIndex >= m_profiles.size()) { emit launchError(tr("Выберите профиль перед запуском")); return; } if (buildIndex < 0 || buildIndex >= m_builds.size()) { emit launchError(tr("Выберите сборку перед запуском")); return; } // Дальше проверяется только папка игры: свои файлы лаунчер уже разложил // в galeonLauncher при запуске. const QString directory = gameDir(); if (!QDir(directory).exists()) { emit launchError(tr("Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.") .arg(QDir::toNativeSeparators(directory))); return; } const CustomBuild &entry = m_builds[buildIndex]; const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) { emit launchError(tr("У сборки «%1» не выбрана версия Minecraft. Проверьте %2") .arg(entry.name, QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))); return; } const QString versionsDir = QDir::toNativeSeparators(directory + QStringLiteral("/versions")); const QStringList installed = VersionLoader::installedVersions(directory); if (!installed.contains(id)) { emit launchError(installed.isEmpty() ? tr("В %1 нет ни одной установленной версии").arg(versionsDir) : tr("Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4") .arg(id, entry.name, versionsDir, installed.join(QStringLiteral(", ")))); return; } setBusy(true); emit launchProgress(tr("Чтение описания версии «%1»…").arg(id)); QSet features; if (settingInt(m_settings, QStringLiteral("windowWidth"), 0) > 0 && settingInt(m_settings, QStringLiteral("windowHeight"), 0) > 0) features << QStringLiteral("has_custom_resolution"); QString error; m_pendingVersionData = VersionLoader::load(directory, id, features, &error); if (!m_pendingVersionData.isValid()) { failLaunch(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(id) : error); return; } LaunchOptions probe; probe.gameDir = directory; probe.versionId = id; const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData); if (!missing.isEmpty()) { failLaunch(tr("В папке игры не хватает файлов (%1):\n%2") .arg(missing.size()) .arg(missing.join(u'\n'))); return; } m_pendingProfile = profileIndex; // Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из // модального окна, и индекс уехал бы на чужую запись. m_pendingBuild = entry.id; m_pendingInjectorPath.clear(); beginAuthentication(); } void LauncherBackend::beginAuthentication() { const Profile &profile = m_profiles[m_pendingProfile]; if (profile.authType != kElyBy) { const QString nickname = profile.login.isEmpty() ? profile.name : profile.login; const AuthResult result = AuthService::offline(nickname); if (!result.ok) { failLaunch(result.error); return; } continueLaunch(result); return; } // Ely.by работает только вместе с authlib-injector — без него игра пойдёт // в сессионный сервер Mojang и токен окажется недействительным. m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(), [this](const QString &path, const QString &error) { if (m_pendingProfile < 0) return; // запуск отменили, пока качался инжектор if (!error.isEmpty()) { failLaunch(error); return; } m_pendingInjectorPath = path; const Profile &pending = m_profiles[m_pendingProfile]; m_auth->loginElyBy(pending.login, pending.password, pending.clientToken, pending.accessToken, [this](const AuthResult &result) { if (m_pendingProfile < 0) return; if (result.twoFactorRequired) { setBusy(false); emit twoFactorRequired(m_profiles[m_pendingProfile].name); return; } if (!result.ok) { failLaunch(result.error); return; } continueLaunch(result); }); }); } void LauncherBackend::submitTwoFactorCode(const QString &code) { if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) { emit launchError(tr("Запуск уже отменён")); return; } if (code.trimmed().isEmpty()) { emit launchError(tr("Введите код двухфакторной аутентификации")); return; } setBusy(true); const Profile &profile = m_profiles[m_pendingProfile]; m_auth->loginElyByWithTotp(profile.login, profile.password, code.trimmed(), profile.clientToken, [this](const AuthResult &result) { if (m_pendingProfile < 0) return; if (!result.ok) { failLaunch(result.error); return; } continueLaunch(result); }); } void LauncherBackend::cancelPendingLaunch() { m_pendingProfile = -1; m_pendingBuild = -1; m_pendingInjectorPath.clear(); setBusy(false); } void LauncherBackend::continueLaunch(const AuthResult &auth) { if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) { failLaunch(tr("Запуск был отменён")); return; } const int buildIndex = indexOfBuildId(m_pendingBuild); if (buildIndex < 0) { failLaunch(tr("Сборка была удалена, пока шла авторизация")); return; } Profile &profile = m_profiles[m_pendingProfile]; const CustomBuild &build = m_builds[buildIndex]; // Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем, // чтобы в следующий раз обойтись без пароля. if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) { profile.accessToken = auth.accessToken; profile.clientToken = auth.clientToken; if (!auth.uuid.isEmpty()) profile.uuid = auth.uuid; if (!auth.playerName.isEmpty()) profile.resolvedName = auth.playerName; profile.userType = auth.userType; saveProfiles(); } LaunchOptions options; options.gameDir = gameDir(); options.versionId = m_pendingVersionData.id; options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName; if (options.playerName.isEmpty()) options.playerName = profile.login.isEmpty() ? profile.name : profile.login; options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid; options.accessToken = auth.accessToken; options.clientToken = auth.clientToken; options.userType = auth.userType; options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString(); options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512); options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096); options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0); options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0); options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool(); options.serverAddress = build.serverUrl.trimmed(); options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString(); options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString(); const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed(); if (!extra.isEmpty()) options.extraJvmArgs = extra.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); if (profile.authType == kElyBy) options.authlibInjectorPath = m_pendingInjectorPath; QString error; const bool started = m_launcher->launch(options, m_pendingVersionData, &error); const QString profileName = profile.name; const QString buildName = build.name; const QString serverUrl = build.serverUrl; cancelPendingLaunch(); if (!started) { emit launchError(error); return; } emit gameRunningChanged(); emit launched(profileName, buildName, serverUrl); } void LauncherBackend::stopGame() { m_launcher->terminate(); } void LauncherBackend::failLaunch(const QString &message) { cancelPendingLaunch(); emit launchError(message); } bool LauncherBackend::switching() const { return m_switcher->isRunning(); } double LauncherBackend::switchProgress() const { return m_switcher->fraction(); } QString LauncherBackend::switchStage() const { return m_switcher->stage(); } QString LauncherBackend::switchStatus() const { return m_switcher->status(); } bool LauncherBackend::busy() const { return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning(); } void LauncherBackend::setBusy(bool busy) { if (m_busy == busy) return; m_busy = busy; emit busyChanged(); } QString LauncherBackend::gameDir() const { const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed(); return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir() : QDir::fromNativeSeparators(configured); } QVariantMap LauncherBackend::settings() const { QVariantMap result = m_settings; result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir()); return result; } void LauncherBackend::updateSettings(const QVariantMap &values) { const QString previousGameDir = gameDir(); for (auto it = values.cbegin(); it != values.cend(); ++it) m_settings.insert(it.key(), it.value()); saveSettings(); emit settingsChanged(); if (gameDir() != previousGameDir) { invalidateCatalog(); emit installedVersionsChanged(); } } QStringList LauncherBackend::detectedJava() const { QStringList result; const QStringList candidates = JavaLocator::findAll(gameDir()); for (const QString &candidate : candidates) { const int major = JavaLocator::majorVersion(candidate); result << (major > 0 ? QStringLiteral("Java %1 — %2") .arg(major) .arg(QDir::toNativeSeparators(candidate)) : QDir::toNativeSeparators(candidate)); } return result; } void LauncherBackend::openMinecraftFolder() { const QString modsDir = gameDir() + QStringLiteral("/mods"); QDir().mkpath(modsDir); if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir))) emit launchError(tr("Не удалось открыть папку с модами")); } void LauncherBackend::openGameFolder() { const QString directory = gameDir(); QDir().mkpath(directory); if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory))) emit launchError(tr("Не удалось открыть папку игры")); } // Повреждённый файл не удаляем: отводим в .bak, чтобы данные можно было спасти // руками, а ensureDataFiles() создал на его месте пустой. bool LauncherBackend::moveAsideBroken(const QString &path, const QString &reason) { const QString backup = path + QStringLiteral(".bak"); QFile::remove(backup); if (!QFile::rename(path, backup)) { m_storageIssues << tr("Файл %1 повреждён (%2), и его не удалось отложить в сторону") .arg(QDir::toNativeSeparators(path), reason); return false; } m_storageIssues << tr("Файл %1 повреждён (%2). Копия сохранена как %3, файл создан заново.") .arg(QDir::toNativeSeparators(path), reason, QDir::toNativeSeparators(backup)); return true; } QJsonArray LauncherBackend::readArrayFile(const QString &path) { QFile file(path); if (!file.exists()) return {}; // первый запуск — это не ошибка if (!file.open(QIODevice::ReadOnly)) { m_storageIssues << tr("Не удалось прочитать %1: %2") .arg(QDir::toNativeSeparators(path), file.errorString()); return {}; } QJsonParseError parseError; const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); if (parseError.error != QJsonParseError::NoError) { file.close(); moveAsideBroken(path, parseError.errorString()); return {}; } if (!document.isArray()) { file.close(); moveAsideBroken(path, tr("ожидался список записей")); return {}; } return document.array(); } QJsonObject LauncherBackend::readObjectFile(const QString &path) { QFile file(path); if (!file.exists()) return {}; if (!file.open(QIODevice::ReadOnly)) { m_storageIssues << tr("Не удалось прочитать %1: %2") .arg(QDir::toNativeSeparators(path), file.errorString()); return {}; } QJsonParseError parseError; const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); if (parseError.error != QJsonParseError::NoError) { file.close(); moveAsideBroken(path, parseError.errorString()); return {}; } if (!document.isObject()) { file.close(); moveAsideBroken(path, tr("ожидался набор настроек")); return {}; } return document.object(); } // Перед каждой записью убеждаемся, что папка лаунчера на месте: её могли удалить // уже после запуска, и тогда данные молча пропадали бы. bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &document) { QString storageError; if (!LauncherPaths::ensureRootExists(&storageError)) { emit launchError(tr("Не удалось создать папку лаунчера: %1").arg(storageError)); return false; } const QByteArray payload = document.toJson(); QFile file(path); if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) { emit launchError(tr("Не удалось сохранить %1: %2") .arg(QDir::toNativeSeparators(path), file.errorString())); return false; } return true; } void LauncherBackend::loadData() { auto loadFile = [this](const QString &path, auto handler) { for (const auto &val : readArrayFile(path)) handler(val.toObject()); }; loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) { Profile profile; profile.id = o["id"].toInt(); profile.name = o["name"].toString(); profile.login = o["login"].toString(); profile.password = o["password"].toString(); profile.authType = o.value("authType").toString(kOffline); profile.clientToken = o.value("clientToken").toString(); profile.accessToken = o.value("accessToken").toString(); profile.uuid = o.value("uuid").toString(); profile.resolvedName = o.value("resolvedName").toString(); profile.userType = o.value("userType").toString(); if (profile.clientToken.isEmpty()) profile.clientToken = AuthService::generateClientToken(); m_profiles.append(profile); if (profile.id >= m_nextProfileId) m_nextProfileId = profile.id + 1; }); std::sort(m_profiles.begin(), m_profiles.end(), [](const Profile &a, const Profile &b) { return a.id < b.id; }); int skippedBuilds = 0; loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) { CustomBuild build; build.id = o["id"].toInt(); build.name = o["name"].toString(); build.serverUrl = o["serverUrl"].toString(); build.minecraftVersion = o.value("minecraftVersion").toString(); // Файл мог остаться от версии лаунчера до переименования или быть // поправлен руками — старый ключ тоже принимаем. if (build.minecraftVersion.isEmpty()) build.minecraftVersion = o.value("versionId").toString(); build.loader = o.value("loader").toString(); build.loaderVersion = o.value("loaderVersion").toString(); build.resolvedVersionId = o.value("resolvedVersionId").toString(); // Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт // пустую строку. if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) { ++skippedBuilds; return; } m_builds.append(build); if (build.id >= m_nextBuildId) m_nextBuildId = build.id + 1; }); if (skippedBuilds > 0) { m_storageIssues << tr("В %1 пропущены сборки без имени и версии Minecraft: %2") .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile())) .arg(skippedBuilds); } std::sort(m_builds.begin(), m_builds.end(), [](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; }); } // Первый запуск после переименования «версий» в «сборки»: перекладываем // /versions.json в customBuilds.json. Старый файл не удаляем, а // переименовываем — он остаётся резервной копией. bool LauncherBackend::migrateLegacyBuildsFile() { if (QFile::exists(LauncherPaths::customBuildsFile())) return false; if (!QFile::exists(LauncherPaths::legacyCustomBuildsFile())) return false; // readArrayFile() сам отводит битый файл в .bak — тогда мигрировать нечего. const QJsonArray legacy = readArrayFile(LauncherPaths::legacyCustomBuildsFile()); if (legacy.isEmpty() && !QFile::exists(LauncherPaths::legacyCustomBuildsFile())) return false; QJsonArray migrated; for (const auto &value : legacy) { const QJsonObject o = value.toObject(); migrated.append(QJsonObject{{"id", o.value("id").toInt()}, {"name", o.value("name").toString()}, {"serverUrl", o.value("serverUrl").toString()}, {"minecraftVersion", o.value("versionId").toString()}, {"loader", QString()}, {"loaderVersion", QString()}, {"resolvedVersionId", QString()}}); } // Не удалось записать — молча читаем в этой сессии из старого файла. // Без данных пользователь не остаётся ни при каком исходе. if (!writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(migrated))) return false; const QString retired = LauncherPaths::legacyCustomBuildsFile() + QStringLiteral(".migrated"); QFile::remove(retired); if (!QFile::rename(LauncherPaths::legacyCustomBuildsFile(), retired)) { m_storageIssues << tr("Сборки перенесены в %1, но старый файл %2 удалить не удалось") .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()), QDir::toNativeSeparators( LauncherPaths::legacyCustomBuildsFile())); } return true; } // Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak — // создаём заново, чтобы папка лаунчера всегда была укомплектована. void LauncherBackend::ensureDataFiles() { if (!QFile::exists(LauncherPaths::settingsFile())) saveSettings(); if (!QFile::exists(LauncherPaths::profilesFile())) saveProfiles(); if (!QFile::exists(LauncherPaths::customBuildsFile())) saveCustomBuilds(); } void LauncherBackend::saveProfiles() { QJsonArray arr; for (const auto &p : m_profiles) arr.append(QJsonObject{{"id", p.id}, {"name", p.name}, {"login", p.login}, {"password", p.password}, {"authType", p.authType}, {"clientToken", p.clientToken}, {"accessToken", p.accessToken}, {"uuid", p.uuid}, {"resolvedName", p.resolvedName}, {"userType", p.userType}}); writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr)); } void LauncherBackend::saveCustomBuilds() { QJsonArray arr; for (const auto &b : m_builds) arr.append(QJsonObject{{"id", b.id}, {"name", b.name}, {"serverUrl", b.serverUrl}, {"minecraftVersion", b.minecraftVersion}, {"loader", b.loader}, {"loaderVersion", b.loaderVersion}, {"resolvedVersionId", b.resolvedVersionId}}); writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr)); } void LauncherBackend::loadSettings() { m_settings = {{QStringLiteral("gameDir"), QString()}, {QStringLiteral("javaPath"), QString()}, {QStringLiteral("minMemoryMb"), 512}, {QStringLiteral("maxMemoryMb"), 4096}, {QStringLiteral("jvmArgs"), QString()}, {QStringLiteral("windowWidth"), 0}, {QStringLiteral("windowHeight"), 0}, {QStringLiteral("fullscreen"), false}, {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}, {QStringLiteral("activeBuildId"), 0}}; const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile()); for (auto it = stored.constBegin(); it != stored.constEnd(); ++it) m_settings.insert(it.key(), it.value().toVariant()); } void LauncherBackend::saveSettings() { writeJsonFile(LauncherPaths::settingsFile(), QJsonDocument(QJsonObject::fromVariantMap(m_settings))); }