#include "launcherbackend.h" #include "authservice.h" #include "gamelauncher.h" #include "javalocator.h" #include "launcherpaths.h" #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)) { // Папка лаунчера создаётся при первом запуске и проверяется при каждом // следующем — если её удалили, она появится снова. QString storageError; if (!LauncherPaths::ensureRootExists(&storageError)) m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError); loadSettings(); loadData(); ensureDataFiles(); 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); }); // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно. 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::versionNames() const { QStringList names; for (const auto &v : m_versions) names << v.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::addVersion(const QString &name, const QString &serverUrl, const QString &versionId) { m_versions.append({m_nextVersionId++, name, serverUrl, versionId}); saveVersions(); emit versionsChanged(); } 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::updateVersion(int index, const QString &name, const QString &serverUrl, const QString &versionId) { if (index < 0 || index >= m_versions.size()) return; m_versions[index].name = name; m_versions[index].serverUrl = serverUrl; m_versions[index].versionId = versionId; saveVersions(); emit versionsChanged(); } 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::versionAt(int index) const { if (index < 0 || index >= m_versions.size()) return {}; const auto &v = m_versions[index]; return {{"name", v.name}, {"serverUrl", v.serverUrl}, {"versionId", versionIdOf(v)}}; } void LauncherBackend::removeProfile(int index) { if (index < 0 || index >= m_profiles.size()) return; m_profiles.removeAt(index); saveProfiles(); emit profilesChanged(); } void LauncherBackend::removeVersion(int index) { if (index < 0 || index >= m_versions.size()) return; m_versions.removeAt(index); saveVersions(); emit versionsChanged(); } QString LauncherBackend::versionIdOf(const Version &version) const { // Пользователь мог назвать запись как угодно — тогда версией считаем само имя. return version.versionId.isEmpty() ? version.name : version.versionId; } QStringList LauncherBackend::checkInstallation(int versionIndex) const { if (versionIndex < 0 || versionIndex >= m_versions.size()) return {tr("Версия не выбрана")}; const Version &entry = m_versions[versionIndex]; const QString id = versionIdOf(entry); if (id.trimmed().isEmpty()) return {tr("У записи лаунчера не указан ID версии. Проверьте %1") .arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))}; 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::launchGame(int profileIndex, int versionIndex) { 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 (versionIndex < 0 || versionIndex >= m_versions.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 Version &entry = m_versions[versionIndex]; const QString id = versionIdOf(entry); if (id.trimmed().isEmpty()) { emit launchError(tr("У записи лаунчера не указан ID версии. Проверьте %1") .arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))); 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; m_pendingVersion = versionIndex; 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_pendingVersion = -1; m_pendingInjectorPath.clear(); setBusy(false); } void LauncherBackend::continueLaunch(const AuthResult &auth) { if (m_pendingProfile < 0 || m_pendingVersion < 0) { failLaunch(tr("Запуск был отменён")); return; } Profile &profile = m_profiles[m_pendingProfile]; const Version &version = m_versions[m_pendingVersion]; // 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 = version.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 versionName = version.name; const QString serverUrl = version.serverUrl; cancelPendingLaunch(); if (!started) { emit launchError(error); return; } emit gameRunningChanged(); emit launched(profileName, versionName, serverUrl); } void LauncherBackend::stopGame() { m_launcher->terminate(); } void LauncherBackend::failLaunch(const QString &message) { cancelPendingLaunch(); emit launchError(message); } 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) 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 skippedVersions = 0; loadFile(LauncherPaths::versionsFile(), [this, &skippedVersions](const QJsonObject &o) { const int id = o["id"].toInt(); const QString name = o["name"].toString(); const QString versionId = o.value("versionId").toString(); // Без имени и без ID запись нерабочая: versionIdOf() вернёт пустую строку. if (name.trimmed().isEmpty() && versionId.trimmed().isEmpty()) { ++skippedVersions; return; } m_versions.append({id, name, o["serverUrl"].toString(), versionId}); if (id >= m_nextVersionId) m_nextVersionId = id + 1; }); if (skippedVersions > 0) { m_storageIssues << tr("В %1 пропущены записи без имени и ID версии: %2") .arg(QDir::toNativeSeparators(LauncherPaths::versionsFile())) .arg(skippedVersions); } std::sort(m_versions.begin(), m_versions.end(), [](const Version &a, const Version &b) { return a.id < b.id; }); } // Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak — // создаём заново, чтобы папка лаунчера всегда была укомплектована. void LauncherBackend::ensureDataFiles() { if (!QFile::exists(LauncherPaths::settingsFile())) saveSettings(); if (!QFile::exists(LauncherPaths::profilesFile())) saveProfiles(); if (!QFile::exists(LauncherPaths::versionsFile())) saveVersions(); } 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::saveVersions() { QJsonArray arr; for (const auto &v : m_versions) arr.append(QJsonObject{{"id", v.id}, {"name", v.name}, {"serverUrl", v.serverUrl}, {"versionId", v.versionId}}); writeJsonFile(LauncherPaths::versionsFile(), 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")}}; 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))); }