#include "launcherbackend.h" #include "localization.h" #include "authservice.h" #include "gamelauncher.h" #include "javainstaller.h" #include "javalocator.h" #include "javaruntime.h" #include "javaruntimeservice.h" #include "launcherpaths.h" #include "versioninstaller.h" #include "versionmanifestservice.h" #include "modloaderinstaller.h" #include "msaauthservice.h" #include "modloaderversionservice.h" #include "buildswitcher.h" #include "seasonalbuildservice.h" #include "seasonalpackdownloader.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { const QString kOffline = QStringLiteral("offline"); const QString kElyBy = QStringLiteral("elyby"); const QString kMicrosoft = QStringLiteral("microsoft"); // Токен Minecraft живёт около суток, но обновляем его чуть раньше срока: игра // проверяет сессию не в первую секунду после старта. const int kTokenRenewMarginSecs = 120; // Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl // в settings.json — это нужно, чтобы прогонять установку против локального // http-сервера, не трогая боевой. const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com"); // Тип авторизации профиля: всё, что лаунчер не знает, считается офлайном. QString normalizedAuthType(const QString &value) { if (value == kElyBy) return kElyBy; if (value == kMicrosoft) return kMicrosoft; return kOffline; } // Дата сезона в таблице: год нужен, время — нет. QString formatSeasonDate(const QDate &date) { return date.isValid() ? date.toString(QStringLiteral("dd.MM.yyyy")) : QStringLiteral("—"); } 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_msa(new MsaAuthService(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)) , m_javaMeta(new JavaRuntimeService(this)) , m_javaInstaller(new JavaInstaller(this)) , m_seasonalMeta(new SeasonalBuildService(this)) , m_packDownloader(new SeasonalPackDownloader(this)) { // Папка лаунчера создаётся при первом запуске и проверяется при каждом // следующем — если её удалили, она появится снова. QString storageError; if (!LauncherPaths::ensureRootExists(&storageError)) m_storageIssues << Loc::text("storage.error.launcherDirFailed").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_msa, &MsaAuthService::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_javaMeta, &JavaRuntimeService::catalogChanged, this, &LauncherBackend::invalidateJavaCatalog); connect(m_javaMeta, &JavaRuntimeService::refreshingChanged, this, &LauncherBackend::javaCatalogLoadingChanged); connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) { emit downloadChanged(); emit busyChanged(); emit launchProgress(Loc::text("java.progress.downloading").arg(label)); }); connect(m_javaInstaller, &JavaInstaller::progressChanged, this, &LauncherBackend::downloadChanged); connect(m_javaInstaller, &JavaInstaller::finished, this, [this](const QString &runtimeId, const QString &javaPath) { invalidateJavaCatalog(); emit downloadChanged(); emit busyChanged(); emit javaRuntimeInstalled(runtimeId); emit launchProgress(Loc::text("java.status.installed") .arg(QDir::toNativeSeparators(javaPath))); if (m_season.stage == SeasonStage::Java) seasonAdvance(); }); connect(m_javaInstaller, &JavaInstaller::failed, this, [this](const QString &label, const QString &message) { invalidateJavaCatalog(); emit downloadChanged(); emit busyChanged(); emit launchError(Loc::text("java.error.installFailed").arg(label, message)); if (m_season.stage == SeasonStage::Java) seasonFail({}); }); connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) { invalidateJavaCatalog(); emit downloadChanged(); emit busyChanged(); emit launchProgress(Loc::text("java.status.downloadCancelled").arg(label)); if (m_season.stage == SeasonStage::Java) seasonFail({}); }); connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) { emit downloadChanged(); emit busyChanged(); emit launchProgress(Loc::text("version.progress.downloading").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(Loc::text("version.status.downloaded").arg(id)); // У ванильной сезонной сборки этот установщик и есть весь шаг Loader. // Внутри установки модлоадера он тоже отчитывается — тогда ход // передаст ModLoaderInstaller, а не он. if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) seasonAdvance(); }); connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id, const QString &message) { // Часть файлов всё же появилась — список установленного мог измениться. invalidateCatalog(); emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); emit launchError(Loc::text("version.error.downloadFailed").arg(id, message)); if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) seasonFail({}); }); 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(Loc::text("build.status.active") .arg(index >= 0 ? m_builds[index].name : QString())); // Раскатка пака могла быть и той, что доигралась после перезапуска — // тогда цепочки установки уже нет, а записать ревизию всё равно надо. const QJsonObject note = m_switcher->lastPackNote(); if (!note.isEmpty()) recordSeasonalPack(toBuildId, note, m_switcher->lastPackEntries()); if (m_season.stage == SeasonStage::Applying) seasonFinish(); else if (m_season.stage == SeasonStage::Switching) seasonAdvance(); }); connect(m_switcher, &BuildSwitcher::failed, this, [this](int, const QString &message, bool gameDirIntact) { invalidateCatalog(); emit installedVersionsChanged(); emit switchChanged(); emit busyChanged(); // Раскатка пака и смена сборки приходят одним сигналом, а // сказать пользователю надо разное: после сорванного // переключения .minecraft лежит разобранной, а сорванная // раскатка означает лишь наполовину обновлённую сборку. if (m_season.stage == SeasonStage::Applying) { emit launchError(gameDirIntact ? Loc::text("build.error.filesNotInstalled").arg(message) : Loc::text("build.error.filesIncomplete") .arg(message)); } else { emit launchError(gameDirIntact ? Loc::text("build.error.switchFailed").arg(message) : Loc::text("build.error.switchFailedDirty") .arg(message)); } if (m_season.stage == SeasonStage::Switching || m_season.stage == SeasonStage::Applying) { seasonFail({}); } }); 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(Loc::text("loader.progress.installingShort").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(Loc::text("loader.status.done") .arg(producedVersionId, gameVersion)); if (m_season.stage == SeasonStage::Loader) seasonAdvance(); }); 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(Loc::text("common.status.pair").arg(label, message)); if (m_season.stage == SeasonStage::Loader) seasonFail({}); }); connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) { m_loaderInstallBuildId = -1; invalidateCatalog(); emit installedVersionsChanged(); emit busyChanged(); emit downloadChanged(); emit launchProgress(Loc::text("loader.status.installCancelled").arg(label)); if (m_season.stage == SeasonStage::Loader) seasonFail({}); }); connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) { invalidateCatalog(); emit installedVersionsChanged(); emit downloadChanged(); emit busyChanged(); emit launchProgress(Loc::text("version.status.downloadCancelled").arg(id)); if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning()) seasonFail({}); }); m_seasonalMeta->setBaseUrl(seasonalBaseUrl()); connect(m_seasonalMeta, &SeasonalBuildService::buildsChanged, this, &LauncherBackend::invalidateSeasonalCatalog); connect(m_seasonalMeta, &SeasonalBuildService::refreshingChanged, this, &LauncherBackend::seasonalCatalogLoadingChanged); // Статус строки («установлена», «доступно обновление») считается по // локальным записям — их изменение обновляет и таблицу. connect(this, &LauncherBackend::customBuildsChanged, this, &LauncherBackend::invalidateSeasonalCatalog); connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) { emit downloadChanged(); emit busyChanged(); emit launchProgress(Loc::text("seasonal.progress.downloadingBuild").arg(label)); }); connect(m_packDownloader, &SeasonalPackDownloader::progressChanged, this, &LauncherBackend::downloadChanged); connect(m_packDownloader, &SeasonalPackDownloader::finished, this, [this](const QString &path) { emit downloadChanged(); emit busyChanged(); if (m_season.stage != SeasonStage::Downloading) return; m_season.packPath = path; seasonAdvance(); }); connect(m_packDownloader, &SeasonalPackDownloader::failed, this, [this](const QString &label, const QString &message) { emit downloadChanged(); emit busyChanged(); emit launchError(Loc::text("build.status.named").arg(label, message)); if (m_season.stage == SeasonStage::Downloading) seasonFail({}); }); connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) { emit downloadChanged(); emit busyChanged(); emit launchProgress(Loc::text("seasonal.status.downloadCancelled").arg(label)); if (m_season.stage == SeasonStage::Downloading) seasonFail({}); }); // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно. 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 = normalizedAuthType(authType); 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]; const QString type = normalizedAuthType(authType); // Смена логина или пароля обесценивает сохранённый токен Ely.by. У аккаунта // Microsoft ни того ни другого нет, и переименование профиля не должно // выкидывать из аккаунта. if (profile.authType == kElyBy && (profile.login != login || profile.password != password)) { profile.accessToken.clear(); profile.uuid.clear(); profile.resolvedName.clear(); } // Смена типа авторизации: чужие учётные данные к новому типу не подходят. if (profile.authType != type) { profile.accessToken.clear(); profile.refreshToken.clear(); profile.uuid.clear(); profile.resolvedName.clear(); profile.xuid.clear(); profile.tokenExpiry.clear(); profile.userType.clear(); } profile.name = name; profile.login = login; profile.password = password; profile.authType = type; 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); take("seasonalId", build.seasonalId); if (fields.contains(QStringLiteral("seasonalRevision"))) build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt(); 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}, {"resolvedName", p.resolvedName}, {"hasMicrosoftSession", !p.refreshToken.isEmpty()}}; } 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}, {"seasonalId", b.seasonalId}, {"seasonalRevision", b.seasonalRevision}, {"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(Loc::text("common.error.busyOperation")); return; } if (m_launcher->isRunning()) { emit launchError(Loc::text("common.error.closeGameFirst")); 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(Loc::text("build.warning.archiveNotRemoved") .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(Loc::text("build.progress.activating").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(Loc::text("common.error.switchInProgress")); return; } if (m_launcher->isRunning()) { emit launchError(Loc::text("build.error.closeGameFilesMoving")); return; } if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { emit launchError(Loc::text("common.error.busyOperation")); return; } // Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в // её архив, и только потом на его место разворачивается выбранная. const CustomBuild &target = m_builds[index]; emit launchProgress(Loc::text("build.progress.switchingTo").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 {Loc::text("common.status.noBuildSelected")}; const CustomBuild &entry = m_builds[buildIndex]; const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) return {Loc::text("build.error.noMinecraftVersion") .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))}; QString error; const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error); if (!version.isValid()) { return {error.isEmpty() ? Loc::text("build.error.versionNotInstalled") .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); } QVariantMap LauncherBackend::versionRemovalInfo(const QString &versionId) const { const QString id = versionId.trimmed(); if (id.isEmpty()) return {}; const qint64 bytes = VersionLoader::installedSize(gameDir(), id); // Сборки не правим при удалении — ссылка на неустановленную версию это // рабочее состояние. Но перечислить, кого заденет, надо. QStringList builds; for (const CustomBuild &build : m_builds) { if (build.minecraftVersion == id || build.resolvedVersionId == id || launchVersionIdOf(build) == id) { builds << build.name; } } return {{QStringLiteral("id"), id}, {QStringLiteral("installed"), isVersionInstalled(id)}, {QStringLiteral("sizeMb"), static_cast((bytes + 1024 * 1024 - 1) / (1024 * 1024))}, {QStringLiteral("dependents"), VersionLoader::dependentsOf(gameDir(), id)}, {QStringLiteral("builds"), builds}}; } void LauncherBackend::removeVersion(const QString &versionId) { const QString id = versionId.trimmed(); if (id.isEmpty()) return; if (m_installer->versionId() == id || m_installer->isQueued(id)) { emit launchError(Loc::text("version.error.currentlyDownloading")); return; } if (m_switcher->isRunning() || m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) { emit launchError(Loc::text("common.error.busyOperation")); return; } // Какой версией сейчас играют, GameLauncher наружу не отдаёт, поэтому на // время игры удаление запрещено целиком. if (m_launcher->isRunning()) { emit launchError(Loc::text("common.error.closeGameFirst")); return; } QString error; if (!VersionLoader::remove(gameDir(), id, &error)) { emit launchError(Loc::text("version.error.removeFailed").arg(error)); return; } invalidateCatalog(); emit installedVersionsChanged(); emit launchProgress(Loc::text("version.status.removed").arg(id)); } 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 if (m_installer->isRunning()) m_installer->cancel(); else if (m_javaInstaller->isRunning()) m_javaInstaller->cancel(); else m_packDownloader->cancel(); } // Панель загрузки одна на три установщика: пока идёт установка модлоадера, она // показывает его стадии, включая вложенную загрузку файлов версии. Установка // Java начинается только когда остальные не заняты, поэтому за панель они не спорят. bool LauncherBackend::downloading() const { return m_loaderInstaller->isRunning() || m_installer->isRunning() || m_javaInstaller->isRunning() || m_packDownloader->isRunning(); } // Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок. bool LauncherBackend::javaDownloadShown() const { return m_javaInstaller->isRunning() && !m_installer->isRunning() && !m_loaderInstaller->isRunning(); } // Пак качается отдельным шагом цепочки, когда остальные установщики уже // отработали, — за панель они не спорят. bool LauncherBackend::packDownloadShown() const { return m_packDownloader->isRunning() && !m_installer->isRunning() && !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning(); } double LauncherBackend::downloadProgress() const { if (packDownloadShown()) return m_packDownloader->fraction(); if (javaDownloadShown()) return m_javaInstaller->fraction(); if (!m_loaderInstaller->isRunning()) return m_installer->fraction(); // Внутри установки лоадера файлы качает VersionInstaller — его доля точнее. return m_installer->isRunning() ? m_installer->fraction() : m_loaderInstaller->fraction(); } QString LauncherBackend::downloadVersion() const { if (packDownloadShown()) return m_packDownloader->label(); if (javaDownloadShown()) return m_javaInstaller->label(); 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 (packDownloadShown()) { stage = m_packDownloader->stage(); file = m_packDownloader->currentFile(); } else if (javaDownloadShown()) { stage = m_javaInstaller->stage(); file = m_javaInstaller->currentFile(); } else 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 (packDownloadShown()) return m_packDownloader->bytesDone(); if (javaDownloadShown()) return m_javaInstaller->bytesDone(); if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) return m_loaderInstaller->bytesDone(); return m_installer->bytesDone(); } qint64 LauncherBackend::downloadBytesTotal() const { if (packDownloadShown()) return m_packDownloader->bytesTotal(); if (javaDownloadShown()) return m_javaInstaller->bytesTotal(); 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 += Loc::text("loader.label.recommended"); else if (!entry.stable) label += Loc::text("loader.label.unstable"); 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(Loc::text("common.error.busyDownload")); return; } const CustomBuild &build = m_builds[index]; if (build.minecraftVersion.trimmed().isEmpty()) { emit launchError(Loc::text("build.error.pickMinecraftVersion").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(Loc::text("build.error.pickLoaderVersion") .arg(loaderTitle(*loader), build.name)); return; } m_loaderInstallBuildId = build.id; m_loaderInstaller->install(gameDir(), *loader, build.minecraftVersion, build.loaderVersion, managedJavaPath().isEmpty() ? m_settings.value(QStringLiteral("javaPath")).toString() : managedJavaPath()); } void LauncherBackend::launchGame(int profileIndex, int buildIndex) { if (m_loaderInstaller->isRunning()) { emit launchError(Loc::text("build.error.installBusy") .arg(m_loaderInstaller->label())); return; } if (m_installer->isRunning()) { emit launchError(Loc::text("version.error.downloadBusy") .arg(m_installer->versionId())); return; } // Игра ищет java при запуске: стартовать посреди её установки — значит // найти половину распакованного дерева. if (m_javaInstaller->isRunning()) { emit launchError(Loc::text("java.error.installBusy") .arg(m_javaInstaller->label())); return; } if (m_busy) { emit launchError(Loc::text("launch.error.alreadyLaunching")); return; } if (m_launcher->isRunning()) { emit launchError(Loc::text("common.error.gameRunning")); return; } if (profileIndex < 0 || profileIndex >= m_profiles.size()) { emit launchError(Loc::text("launch.error.noProfile")); return; } if (buildIndex < 0 || buildIndex >= m_builds.size()) { emit launchError(Loc::text("launch.error.noBuild")); return; } // Дальше проверяется только папка игры: свои файлы лаунчер уже разложил // в galeonLauncher при запуске. const QString directory = gameDir(); if (!QDir(directory).exists()) { emit launchError(Loc::text("launch.error.gameDirMissing") .arg(QDir::toNativeSeparators(directory))); return; } const CustomBuild &entry = m_builds[buildIndex]; const QString id = launchVersionIdOf(entry); if (id.trimmed().isEmpty()) { emit launchError(Loc::text("launch.error.buildNoVersion") .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() ? Loc::text("launch.error.noVersionsInstalled").arg(versionsDir) : Loc::text("launch.error.versionNotInstalled") .arg(id, entry.name, versionsDir, installed.join(QStringLiteral(", ")))); return; } setBusy(true); emit launchProgress(Loc::text("launch.progress.readingVersion").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() ? Loc::text("common.error.versionReadFailed").arg(id) : error); return; } LaunchOptions probe; probe.gameDir = directory; probe.versionId = id; const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData); if (!missing.isEmpty()) { failLaunch(Loc::text("launch.error.gameFilesMissing") .arg(missing.size()) .arg(missing.join(u'\n'))); return; } m_pendingProfile = profileIndex; // Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из // модального окна, и индекс уехал бы на чужую запись. m_pendingBuild = entry.id; m_pendingInjectorPath.clear(); beginAuthentication(); } void LauncherBackend::startMicrosoftLogin(int profileIndex) { if (!microsoftAvailable()) { emit microsoftLoginFailed(Loc::text("auth.msa.error.noWebEngine")); return; } // -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать // это состояние от закрытого окна. if (profileIndex >= m_profiles.size()) { emit microsoftLoginFailed(Loc::text("profile.error.notFound")); return; } m_msaLoginProfile = profileIndex < 0 ? -2 : profileIndex; emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl().toString()); } void LauncherBackend::cancelMicrosoftLogin() { m_msaLoginProfile = -1; } QVariantMap LauncherBackend::inspectMicrosoftRedirect(const QString &url) const { QString code; QString error; const bool matched = MsaAuthService::matchRedirect(QUrl(url), &code, &error); return {{QStringLiteral("matched"), matched}, {QStringLiteral("code"), code}, {QStringLiteral("error"), error}}; } void LauncherBackend::finishMicrosoftLogin(const QString &code) { if (m_msaLoginProfile == -1) { emit microsoftLoginFailed(Loc::text("auth.msa.status.alreadyCancelled")); return; } setBusy(true); m_msa->loginWithCode(code, [this](const AuthResult &result) { setBusy(false); const int target = m_msaLoginProfile; m_msaLoginProfile = -1; if (target == -1) return; // окно закрыли, пока шла авторизация if (!result.ok) { emit microsoftLoginFailed(result.error); return; } if (target == -2) { // Профиля ещё нет — заводим его на ник из аккаунта. Profile profile; profile.id = m_nextProfileId++; profile.name = result.playerName; profile.authType = kMicrosoft; profile.clientToken = AuthService::generateClientToken(); applyMicrosoftResult(profile, result); m_profiles.append(profile); } else if (target < m_profiles.size()) { Profile &profile = m_profiles[target]; profile.authType = kMicrosoft; applyMicrosoftResult(profile, result); } else { emit microsoftLoginFailed(Loc::text("profile.error.removedDuringLogin")); return; } saveProfiles(); emit profilesChanged(); emit microsoftLoginSucceeded(result.playerName); }); } void LauncherBackend::applyMicrosoftResult(Profile &profile, const AuthResult &auth) { profile.accessToken = auth.accessToken; // Microsoft выдаёт новый refresh-токен и при продлении, но если в ответе // его не было — прежний остаётся рабочим. if (!auth.refreshToken.isEmpty()) profile.refreshToken = auth.refreshToken; if (!auth.uuid.isEmpty()) profile.uuid = auth.uuid; if (!auth.playerName.isEmpty()) profile.resolvedName = auth.playerName; profile.xuid = auth.xuid; profile.userType = auth.userType; profile.tokenExpiry = auth.expiresAt.isValid() ? auth.expiresAt.toUTC().toString(Qt::ISODate) : QString(); } void LauncherBackend::beginMicrosoftAuthentication() { const Profile &profile = m_profiles[m_pendingProfile]; if (profile.refreshToken.isEmpty()) { const int index = m_pendingProfile; failLaunch(Loc::text("auth.msa.error.profileNeedsLogin") .arg(profile.name)); emit microsoftReloginRequired(index); return; } // Сохранённый токен ещё жив — идём в игру, не дёргая Microsoft. const QDateTime expiry = QDateTime::fromString(profile.tokenExpiry, Qt::ISODate); if (!profile.accessToken.isEmpty() && expiry.isValid() && QDateTime::currentDateTimeUtc().addSecs(kTokenRenewMarginSecs) < expiry) { AuthResult stored; stored.ok = true; stored.playerName = profile.resolvedName; stored.uuid = profile.uuid; stored.accessToken = profile.accessToken; stored.refreshToken = profile.refreshToken; stored.clientToken = MsaAuthService::clientId(); stored.xuid = profile.xuid; stored.userType = profile.userType.isEmpty() ? QStringLiteral("msa") : profile.userType; stored.expiresAt = expiry; continueLaunch(stored); return; } m_msa->loginWithRefreshToken(profile.refreshToken, [this](const AuthResult &result) { if (m_pendingProfile < 0) return; // запуск отменили, пока шло продление if (result.licenseMissing) { failLaunch(result.error); return; } if (!result.ok) { const int index = m_pendingProfile; failLaunch(Loc::text("auth.msa.error.sessionExpired") .arg(result.error)); emit microsoftReloginRequired(index); return; } continueLaunch(result); }); } void LauncherBackend::beginAuthentication() { const Profile &profile = m_profiles[m_pendingProfile]; if (profile.authType == kMicrosoft) { beginMicrosoftAuthentication(); return; } 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(Loc::text("launch.status.alreadyCancelled")); return; } if (code.trimmed().isEmpty()) { emit launchError(Loc::text("auth.ely.error.enterCode")); 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(Loc::text("launch.status.cancelled")); return; } const int buildIndex = indexOfBuildId(m_pendingBuild); if (buildIndex < 0) { failLaunch(Loc::text("build.error.removedDuringAuth")); return; } Profile &profile = m_profiles[m_pendingProfile]; const CustomBuild &build = m_builds[buildIndex]; // Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем, // чтобы в следующий раз обойтись без пароля. if (profile.authType == kMicrosoft && auth.ok) { applyMicrosoftResult(profile, auth); saveProfiles(); } else 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.xuid = auth.xuid; // Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в // окне выбора версии Java. options.javaPath = managedJavaPath(); if (options.javaPath.isEmpty()) { if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty()) emit launchProgress(Loc::text("java.warning.runtimeMissing")); 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_javaInstaller->isRunning() || m_switcher->isRunning() || m_packDownloader->isRunning(); } bool LauncherBackend::microsoftAvailable() const { #ifdef LAUNCHER_HAS_WEBENGINE return true; #else return false; #endif } 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(); const QString previousLanguage = m_settings.value(QStringLiteral("language")).toString(); for (auto it = values.cbegin(); it != values.cend(); ++it) m_settings.insert(it.key(), it.value()); saveSettings(); emit settingsChanged(); // Язык переключается после записи: порядок «сохранили → переключили» // гарантирует, что выбор переживёт падение сразу после переключения. // Сам Localization в settings.json не пишет — файл ведёт только бэкенд. const QString language = m_settings.value(QStringLiteral("language")).toString(); if (language != previousLanguage) Localization::instance().setLanguage(language); 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; } // ── Сборки Java ──────────────────────────────────────────────────────────── void LauncherBackend::invalidateJavaCatalog() { m_javaCatalogDirty = true; emit javaCatalogChanged(); } bool LauncherBackend::javaCatalogLoading() const { return m_javaMeta->isRefreshing(); } void LauncherBackend::refreshJavaCatalog(bool force) { // Список установленного мог поменяться и без нас (папку удалили руками). invalidateJavaCatalog(); m_javaMeta->ensureLoaded([this](bool ok, const QString &warning) { if (!ok && !warning.isEmpty()) emit launchError(warning); }, force); } QVariantList LauncherBackend::javaCatalog() const { if (m_javaCatalogDirty) { rebuildJavaCatalog(); m_javaCatalogDirty = false; } return m_javaCatalog; } void LauncherBackend::rebuildJavaCatalog() const { m_javaCatalog.clear(); const QList installed = JavaRuntimeStore::installed(); QHash installedSizes; for (const InstalledJavaRuntime &runtime : installed) installedSizes.insert(runtime.id, runtime.size); const auto megabytes = [](qint64 bytes) { return bytes > 0 ? int((bytes + 512 * 1024) / (1024 * 1024)) : 0; }; const auto makeRow = [&megabytes](const QString &id, const QString &kind, int major, const QString &version, const QString &detail, qint64 size, bool isInstalled, bool lts, bool downloadable) { return QVariantMap{ {QStringLiteral("id"), id}, {QStringLiteral("kind"), kind}, {QStringLiteral("major"), major}, {QStringLiteral("version"), version}, {QStringLiteral("label"), QStringLiteral("Java %1").arg(version)}, {QStringLiteral("detail"), detail}, {QStringLiteral("coverage"), JavaRequirement::coverage(major)}, {QStringLiteral("sizeMb"), megabytes(size)}, {QStringLiteral("installed"), isInstalled}, {QStringLiteral("lts"), lts}, {QStringLiteral("downloadable"), downloadable}, {QStringLiteral("search"), QStringLiteral("%1 %2 %3 %4").arg(version, QString::number(major), kind, detail).toLower()}}; }; // Скачанное — сверху: этим уже можно пользоваться прямо сейчас. QSet listed; for (const InstalledJavaRuntime &runtime : installed) { listed.insert(runtime.id); const auto remote = m_javaMeta->find(runtime.id); const QString detail = remote ? (remote->kind == JavaRuntimeKind::Mojang ? Loc::text("java.label.mojang").arg(remote->component) : Loc::text("java.label.temurin").arg(remote->architecture)) : Loc::text("java.label.downloadedByLauncher"); m_javaCatalog.append(makeRow(runtime.id, runtime.kind, runtime.major, runtime.version, detail, runtime.size, true, remote && remote->lts, remote.has_value())); } const QList kinds{JavaRuntimeKind::Mojang, JavaRuntimeKind::Jdk, JavaRuntimeKind::Jre}; for (const JavaRuntimeKind kind : kinds) { const QList entries = m_javaMeta->entries(kind); for (const JavaRuntimeEntry &entry : entries) { if (listed.contains(entry.id)) continue; const QString detail = kind == JavaRuntimeKind::Mojang ? Loc::text("java.label.mojang").arg(entry.component) : Loc::text("java.label.temurin").arg(entry.architecture); m_javaCatalog.append(makeRow(entry.id, javaKindKey(kind), entry.major, entry.version, detail, entry.size, false, entry.lts, true)); } } } void LauncherBackend::installJavaRuntime(const QString &runtimeId) { if (m_javaInstaller->isRunning()) { emit launchError(Loc::text("java.error.installBusy") .arg(m_javaInstaller->label())); return; } if (m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning()) { emit launchError(Loc::text("common.error.busyOperation")); return; } const auto entry = m_javaMeta->find(runtimeId); if (!entry) { emit launchError(Loc::text("java.error.buildNotInCatalog").arg(runtimeId)); return; } m_javaInstaller->install(*entry); } void LauncherBackend::removeJavaRuntime(const QString &runtimeId) { if (m_javaInstaller->isRunning() && m_javaInstaller->runtimeId() == runtimeId) { emit launchError(Loc::text("java.error.currentlyDownloading")); return; } QString error; if (!JavaRuntimeStore::remove(runtimeId, &error)) { emit launchError(Loc::text("java.error.removeFailed").arg(error)); return; } // Удалили выбранную — настройка указывала бы в пустоту. if (m_settings.value(QStringLiteral("javaRuntime")).toString() == runtimeId) { m_settings.insert(QStringLiteral("javaRuntime"), QString()); saveSettings(); emit settingsChanged(); } invalidateJavaCatalog(); emit launchProgress(Loc::text("java.status.removed")); } QVariantMap LauncherBackend::javaRuntimeInfo(const QString &runtimeId) const { if (runtimeId.trimmed().isEmpty()) return {}; const auto runtime = JavaRuntimeStore::find(runtimeId); if (runtime) { return QVariantMap{{QStringLiteral("id"), runtime->id}, {QStringLiteral("kind"), runtime->kind}, {QStringLiteral("major"), runtime->major}, {QStringLiteral("version"), runtime->version}, {QStringLiteral("label"), QStringLiteral("Java %1").arg(runtime->version)}, {QStringLiteral("javaPath"), QDir::toNativeSeparators(runtime->javaPath)}, {QStringLiteral("installed"), true}}; } // Выбрана, но ещё не скачана: подпись всё равно нужна. const auto entry = m_javaMeta->find(runtimeId); if (!entry) return QVariantMap{{QStringLiteral("id"), runtimeId}, {QStringLiteral("label"), runtimeId}, {QStringLiteral("installed"), false}}; return QVariantMap{{QStringLiteral("id"), entry->id}, {QStringLiteral("kind"), javaKindKey(entry->kind)}, {QStringLiteral("major"), entry->major}, {QStringLiteral("version"), entry->version}, {QStringLiteral("label"), QStringLiteral("Java %1").arg(entry->version)}, {QStringLiteral("javaPath"), QString()}, {QStringLiteral("installed"), false}}; } int LauncherBackend::requiredJavaMajor(int buildIndex) const { if (buildIndex < 0 || buildIndex >= m_builds.size()) return 0; return JavaRequirement::minimumFor(m_builds.at(buildIndex).minecraftVersion); } QString LauncherBackend::managedJavaPath() const { const QString id = m_settings.value(QStringLiteral("javaRuntime")).toString(); if (id.trimmed().isEmpty()) return {}; const auto runtime = JavaRuntimeStore::find(id); return runtime ? runtime->javaPath : QString(); } // ── Сезонные сборки ──────────────────────────────────────────────────────── QUrl LauncherBackend::seasonalBaseUrl() const { const QString configured = m_settings.value(QStringLiteral("seasonalBaseUrl")).toString().trimmed(); QUrl url(configured.isEmpty() ? kSeasonalBaseUrl : configured); // Хвостовой слэш превратил бы адрес каталога в «…//index.json». while (url.path().endsWith(u'/')) url.setPath(url.path().chopped(1)); return url; } void LauncherBackend::invalidateSeasonalCatalog() { m_seasonalCatalogDirty = true; emit seasonalCatalogChanged(); } int LauncherBackend::indexOfSeasonalBuild(const QString &seasonalId) const { if (seasonalId.isEmpty()) return -1; for (int i = 0; i < m_builds.size(); ++i) { if (m_builds[i].seasonalId == seasonalId) return i; } return -1; } void LauncherBackend::rebuildSeasonalCatalog() const { m_seasonalCatalog.clear(); const QList builds = m_seasonalMeta->builds(); m_seasonalCatalog.reserve(builds.size()); for (const SeasonalBuildEntry &entry : builds) { int installedRevision = 0; bool installed = false; for (const CustomBuild &build : m_builds) { if (build.seasonalId != entry.id) continue; installed = true; installedRevision = build.seasonalRevision; break; } const bool updateAvailable = installed && installedRevision < entry.revision; QString status; if (!installed) status = Loc::text("seasonal.status.notInstalled"); else if (updateAvailable) status = Loc::text("seasonal.status.updateAvailable"); else status = Loc::text("seasonal.status.installed"); const auto loader = loaderFromKey(entry.loader); m_seasonalCatalog.append(QVariantMap{ {QStringLiteral("id"), entry.id}, {QStringLiteral("name"), entry.name}, {QStringLiteral("minecraftVersion"), entry.minecraftVersion}, {QStringLiteral("loader"), entry.loader}, {QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : Loc::text("loader.label.vanilla")}, {QStringLiteral("loaderVersion"), entry.loaderVersion}, {QStringLiteral("modCount"), entry.modCount}, {QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)}, {QStringLiteral("seasonEnd"), formatSeasonDate(entry.seasonEnd)}, {QStringLiteral("sizeBytes"), entry.archiveSize}, {QStringLiteral("revision"), entry.revision}, {QStringLiteral("description"), entry.description}, {QStringLiteral("serverUrl"), entry.serverUrl}, {QStringLiteral("installed"), installed}, {QStringLiteral("installedRevision"), installedRevision}, {QStringLiteral("updateAvailable"), updateAvailable}, {QStringLiteral("status"), status}}); } m_seasonalCatalogDirty = false; } QVariantList LauncherBackend::seasonalCatalog() const { if (m_seasonalCatalogDirty) rebuildSeasonalCatalog(); return m_seasonalCatalog; } bool LauncherBackend::seasonalCatalogLoading() const { return m_seasonalMeta->isRefreshing(); } QString LauncherBackend::seasonalCatalogError() const { return m_seasonalMeta->lastError(); } bool LauncherBackend::seasonalInstalling() const { return m_season.stage != SeasonStage::Idle; } void LauncherBackend::refreshSeasonalCatalog(bool force) { // Адрес мог смениться в settings.json между запусками окна. m_seasonalMeta->setBaseUrl(seasonalBaseUrl()); m_seasonalMeta->ensureLoaded(nullptr, force); } // ── Установка сезонной сборки ────────────────────────────────────────────── void LauncherBackend::installSeasonalBuild(const QString &seasonalId) { if (m_season.stage != SeasonStage::Idle) { emit launchError(Loc::text("seasonal.error.installInProgress")); return; } if (m_launcher->isRunning()) { emit launchError(Loc::text("build.error.closeGameFilesMoving")); return; } if (busy()) { emit launchError(Loc::text("common.error.busyOperation")); return; } const auto entry = m_seasonalMeta->find(seasonalId); if (!entry) { emit launchError(Loc::text("seasonal.error.notInCatalog").arg(seasonalId)); return; } m_season = {}; m_season.entry = *entry; // Сборка с таким seasonalId уже заведена — обновляем её описание под новую // ревизию, а не плодим вторую запись. int index = indexOfSeasonalBuild(seasonalId); if (index < 0) { CustomBuild build; build.id = m_nextBuildId++; build.name = entry->name; build.serverUrl = entry->serverUrl; build.minecraftVersion = entry->minecraftVersion; build.loader = entry->loader; build.loaderVersion = entry->loaderVersion; build.seasonalId = entry->id; // Ревизия появится только когда файлы лягут на диск: иначе оборванная // установка выглядела бы завершённой. m_builds.append(build); index = m_builds.size() - 1; saveCustomBuilds(); emit customBuildsChanged(); // maybeInstall() здесь намеренно нет: версию и модлоадер поставит шаг // цепочки, а параллельная загрузка только мешала бы ему. } else { updateCustomBuild(index, {{QStringLiteral("name"), entry->name}, {QStringLiteral("serverUrl"), entry->serverUrl}, {QStringLiteral("minecraftVersion"), entry->minecraftVersion}, {QStringLiteral("loader"), entry->loader}, {QStringLiteral("loaderVersion"), entry->loaderVersion}}); } m_season.buildId = m_builds[index].id; setBusy(true); emit launchProgress(Loc::text("seasonal.progress.installing").arg(entry->name)); // Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его // вызова seasonalInstalling() ещё вернул бы false. seasonAdvance(); emit seasonalInstallingChanged(); } void LauncherBackend::seasonAdvance() { if (indexOfBuildId(m_season.buildId) < 0) { seasonFail(Loc::text("seasonal.error.entryGone")); return; } // Каждый шаг либо начинает свою асинхронную работу и обрывает цепочку до // следующего сигнала, либо уступает следующему — отсюда провалы между // ветками. switch (m_season.stage) { case SeasonStage::Idle: if (seasonBeginSwitch()) return; [[fallthrough]]; case SeasonStage::Switching: if (seasonBeginJava()) return; [[fallthrough]]; case SeasonStage::Java: if (seasonBeginLoader()) return; [[fallthrough]]; case SeasonStage::Loader: if (seasonBeginDownload()) return; [[fallthrough]]; case SeasonStage::Downloading: if (seasonBeginApply()) return; [[fallthrough]]; case SeasonStage::Applying: seasonFinish(); return; } } bool LauncherBackend::seasonBeginSwitch() { const int index = indexOfBuildId(m_season.buildId); const int current = activeBuildIndex(); if (current == index) return false; // Пак ложится на то, что сейчас разложено в .minecraft, поэтому сборка // должна стать активной до всего остального. Содержимое прежней уезжает в // её архив обычным путём. m_season.stage = SeasonStage::Switching; emit launchProgress(Loc::text("build.progress.switchingTo").arg(m_builds[index].name)); m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1, current >= 0 ? m_builds[current].name : QString(), m_builds[index].id, m_builds[index].name, gameDir()); emit switchChanged(); emit busyChanged(); return true; } bool LauncherBackend::seasonBeginJava() { // Сервер может назвать нужную Java прямо; иначе спрашиваем таблицу. const int major = m_season.entry.javaMajor > 0 ? m_season.entry.javaMajor : JavaRequirement::minimumFor(m_season.entry.minecraftVersion); if (major <= 0) return false; // Java уже есть — своя, системная или скачанная раньше. QString error; if (!JavaLocator::select(gameDir(), major, QString(), &error).isEmpty()) return false; m_season.stage = SeasonStage::Java; emit launchProgress(Loc::text("seasonal.progress.installingJava").arg(major)); // Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать. m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) { if (m_season.stage != SeasonStage::Java) return; if (!ok) { seasonFail(warning.isEmpty() ? Loc::text("seasonal.error.javaCatalogFailed") : warning); return; } const auto entry = m_javaMeta->bestFor(major); if (!entry) { seasonFail(Loc::text("seasonal.error.noJavaBuild").arg(major)); return; } m_javaInstaller->install(*entry); }); return true; } bool LauncherBackend::seasonBeginLoader() { const int index = indexOfBuildId(m_season.buildId); const CustomBuild &build = m_builds[index]; const auto loader = loaderFromKey(build.loader); // Ванильная сборка: ставить надо только саму версию игры. if (!loader) { if (checkInstallation(index).isEmpty()) return false; m_season.stage = SeasonStage::Loader; m_installer->install(gameDir(), build.minecraftVersion); return true; } // Профиль лоадера уже собран и укомплектован — второй раз не ставим. if (!build.resolvedVersionId.isEmpty() && checkInstallation(index).isEmpty()) return false; m_season.stage = SeasonStage::Loader; m_loaderInstallBuildId = build.id; m_loaderInstaller->install(gameDir(), *loader, build.minecraftVersion, build.loaderVersion, managedJavaPath().isEmpty() ? m_settings.value(QStringLiteral("javaPath")).toString() : managedJavaPath()); return true; } bool LauncherBackend::seasonBeginDownload() { const SeasonalBuildEntry &entry = m_season.entry; // Точка в начале имени: файл служебный, и BuildSwitcher по этому признаку // не примет его за архив сборки и не сотрёт при следующем переключении. const QString path = LauncherPaths::buildDir(m_season.buildId) + QStringLiteral("/.pack-r") + QString::number(entry.revision) + QStringLiteral(".zip"); m_season.stage = SeasonStage::Downloading; m_packDownloader->download(entry.archiveUrl, path, entry.archiveSha256, entry.archiveSize, entry.name); return true; } bool LauncherBackend::seasonBeginApply() { if (m_season.packPath.isEmpty()) { seasonFail(Loc::text("seasonal.error.filesNotDownloaded")); return true; } // Под удаление идёт весь список прошлой ревизии, а не разница с новой: // распаковка всё равно кладёт каждый файл пака заново, поэтому разница // сэкономила бы лишь перезапись того, что и так перезаписывается, а вот // ушедшие из сборки моды так убираются наверняка. Миров и скриншотов в // списке нет — их пак не приносил. const QStringList remove = seasonalPackEntries(m_season.buildId); const QJsonObject note{{QStringLiteral("seasonalId"), m_season.entry.id}, {QStringLiteral("revision"), m_season.entry.revision}, {QStringLiteral("sha256"), m_season.entry.archiveSha256}}; const int index = indexOfBuildId(m_season.buildId); m_season.stage = SeasonStage::Applying; m_switcher->applyPack(m_season.buildId, m_builds[index].name, m_season.packPath, remove, note, gameDir()); emit switchChanged(); emit busyChanged(); return true; } void LauncherBackend::seasonFinish() { const int index = indexOfBuildId(m_season.buildId); const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name; const QString seasonalId = m_season.entry.id; m_season = {}; setBusy(false); invalidateSeasonalCatalog(); emit seasonalInstallingChanged(); emit launchProgress(Loc::text("seasonal.status.buildInstalled").arg(name)); emit seasonalInstallFinished(seasonalId, name); } void LauncherBackend::seasonFail(const QString &message) { const int index = indexOfBuildId(m_season.buildId); const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name; // Недокачанный или нераскатанный пак не должен занимать место: цепочку // всегда можно начать заново с чистого листа. if (!m_season.packPath.isEmpty()) QFile::remove(m_season.packPath); m_season = {}; setBusy(false); emit seasonalInstallingChanged(); // Пустое message означает, что о причине уже сообщил тот, кто её нашёл, — // второй раз ту же ошибку не показываем. emit launchError(message.isEmpty() ? Loc::text("seasonal.error.installAborted").arg(name) : Loc::text("seasonal.error.installAbortedWhy").arg(name, message)); } void LauncherBackend::cancelSeasonalInstall() { switch (m_season.stage) { case SeasonStage::Idle: return; case SeasonStage::Java: m_javaInstaller->cancel(); return; case SeasonStage::Loader: if (m_loaderInstaller->isRunning()) m_loaderInstaller->cancel(); else m_installer->cancel(); return; case SeasonStage::Downloading: m_packDownloader->cancel(); return; case SeasonStage::Switching: case SeasonStage::Applying: // На этих шагах содержимое .minecraft уже разобрано: отступать некуда, // операцию нужно довести до конца. emit launchError(Loc::text("switch.error.cannotCancel")); return; } } // ── Состояние установленной сезонной сборки ──────────────────────────────── QStringList LauncherBackend::seasonalPackEntries(int buildId) const { QFile file(LauncherPaths::seasonalStateFile(buildId)); if (!file.open(QIODevice::ReadOnly)) return {}; const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); QStringList entries; const QJsonArray array = root.value(QStringLiteral("entries")).toArray(); entries.reserve(array.size()); for (const QJsonValue &value : array) entries << value.toString(); return entries; } void LauncherBackend::recordSeasonalPack(int buildId, const QJsonObject ¬e, const QStringList &entries) { const QString seasonalId = note.value(QStringLiteral("seasonalId")).toString(); const int revision = note.value(QStringLiteral("revision")).toInt(); if (seasonalId.isEmpty() || revision <= 0) return; const QJsonObject state{{QStringLiteral("seasonalId"), seasonalId}, {QStringLiteral("revision"), revision}, {QStringLiteral("sha256"), note.value(QStringLiteral("sha256")).toString()}, {QStringLiteral("installedAt"), QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}, {QStringLiteral("entries"), QJsonArray::fromStringList(entries)}}; // Не через writeJsonFile: файл лежит в папке сборки, а не в корне лаунчера, // и его отсутствие не мешает игре — ошибку достаточно показать. QDir().mkpath(LauncherPaths::buildDir(buildId)); QSaveFile file(LauncherPaths::seasonalStateFile(buildId)); if (file.open(QIODevice::WriteOnly)) { const QByteArray payload = QJsonDocument(state).toJson(QJsonDocument::Indented); if (file.write(payload) == payload.size()) file.commit(); else file.cancelWriting(); } const int index = indexOfBuildId(buildId); if (index < 0) return; // Ревизия проставляется только здесь: до этого момента файлы сборки на // диске не лежали. updateCustomBuild(index, {{QStringLiteral("seasonalId"), seasonalId}, {QStringLiteral("seasonalRevision"), revision}}); } void LauncherBackend::openMinecraftFolder() { const QString modsDir = gameDir() + QStringLiteral("/mods"); QDir().mkpath(modsDir); if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir))) emit launchError(Loc::text("launch.error.openModsFailed")); } void LauncherBackend::openGameFolder() { const QString directory = gameDir(); QDir().mkpath(directory); if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory))) emit launchError(Loc::text("launch.error.openGameDirFailed")); } // Повреждённый файл не удаляем: отводим в .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 << Loc::text("storage.error.corruptNotSetAside") .arg(QDir::toNativeSeparators(path), reason); return false; } m_storageIssues << Loc::text("storage.warning.corruptRecreated") .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 << Loc::text("common.error.readFailed") .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, Loc::text("storage.error.expectedArray")); return {}; } return document.array(); } QJsonObject LauncherBackend::readObjectFile(const QString &path) { QFile file(path); if (!file.exists()) return {}; if (!file.open(QIODevice::ReadOnly)) { m_storageIssues << Loc::text("common.error.readFailed") .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, Loc::text("storage.error.expectedObject")); return {}; } return document.object(); } // Перед каждой записью убеждаемся, что папка лаунчера на месте: её могли удалить // уже после запуска, и тогда данные молча пропадали бы. bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &document) { QString storageError; if (!LauncherPaths::ensureRootExists(&storageError)) { emit launchError(Loc::text("storage.error.launcherDirFailed").arg(storageError)); return false; } const QByteArray payload = document.toJson(); QFile file(path); if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) { emit launchError(Loc::text("storage.error.saveFailedWhy") .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(); profile.refreshToken = o.value("refreshToken").toString(); profile.xuid = o.value("xuid").toString(); profile.tokenExpiry = o.value("tokenExpiry").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(); build.seasonalId = o.value("seasonalId").toString(); build.seasonalRevision = o.value("seasonalRevision").toInt(); // Без имени и без версии запись нерабочая: 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 << Loc::text("storage.warning.buildsSkipped") .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 << Loc::text("storage.warning.migrationLeftover") .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}, {"refreshToken", p.refreshToken}, {"xuid", p.xuid}, {"tokenExpiry", p.tokenExpiry}}); 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}, {"seasonalId", b.seasonalId}, {"seasonalRevision", b.seasonalRevision}}); writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr)); } void LauncherBackend::loadSettings() { m_settings = {{QStringLiteral("gameDir"), QString()}, {QStringLiteral("javaPath"), QString()}, // id сборки Java из /java. Пусто — java ищется сама. {QStringLiteral("javaRuntime"), QString()}, {QStringLiteral("minMemoryMb"), 512}, {QStringLiteral("maxMemoryMb"), 4096}, {QStringLiteral("jvmArgs"), QString()}, {QStringLiteral("windowWidth"), 0}, {QStringLiteral("windowHeight"), 0}, {QStringLiteral("fullscreen"), false}, // Язык интерфейса: "system" — по системной локали. {QStringLiteral("language"), QStringLiteral("system")}, {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}, {QStringLiteral("activeBuildId"), 0}, // Сервер готовых сборок. В интерфейс не вынесен: менять его // приходится только при отладке против локального сервера. {QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}}; 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))); }