Files
minecraft-launcher/launcherbackend.cpp
T

1761 lines
72 KiB
C++
Raw Normal View History

#include "launcherbackend.h"
2026-08-22 18:19:13 +03:00
#include "authservice.h"
#include "gamelauncher.h"
2026-08-27 19:35:11 +03:00
#include "javainstaller.h"
2026-08-22 18:19:13 +03:00
#include "javalocator.h"
2026-08-27 19:35:11 +03:00
#include "javaruntime.h"
#include "javaruntimeservice.h"
2026-08-23 03:30:31 +03:00
#include "launcherpaths.h"
2026-08-24 21:09:52 +03:00
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include "modloaderinstaller.h"
#include "modloaderversionservice.h"
#include "buildswitcher.h"
2026-08-22 18:19:13 +03:00
2026-06-19 21:03:37 +03:00
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
2026-08-24 21:09:52 +03:00
#include <QHash>
2026-08-22 18:19:13 +03:00
#include <QRegularExpression>
2026-08-24 21:09:52 +03:00
#include <QSet>
2026-08-23 03:30:31 +03:00
#include <QTimer>
2026-06-19 21:03:37 +03:00
#include <QUrl>
#include <algorithm>
2026-08-22 18:19:13 +03:00
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)
2026-08-22 18:19:13 +03:00
, m_auth(new AuthService(this))
, m_launcher(new GameLauncher(this))
2026-08-24 21:09:52 +03:00
, 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))
2026-08-27 19:35:11 +03:00
, m_javaMeta(new JavaRuntimeService(this))
, m_javaInstaller(new JavaInstaller(this))
{
2026-08-23 03:30:31 +03:00
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
// следующем — если её удалили, она появится снова.
QString storageError;
if (!LauncherPaths::ensureRootExists(&storageError))
m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError);
2026-08-22 18:19:13 +03:00
loadSettings();
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
// создаст пустой customBuilds.json поверх старых данных.
migrateLegacyBuildsFile();
loadData();
2026-08-23 03:30:31 +03:00
ensureDataFiles();
2026-08-22 18:19:13 +03:00
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
// предлагаем пользователю переключиться заново: архив прежней сборки уже
// записан целиком, а вот повторное переключение сначала заархивировало бы
// поверх него нынешнее — наполовину разобранное — содержимое .minecraft.
const QString interrupted = m_switcher->interruptedSwitchWarning();
if (!interrupted.isEmpty()) {
m_storageIssues << interrupted;
QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); });
}
2026-08-22 18:19:13 +03:00
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);
});
2026-08-23 03:30:31 +03:00
2026-08-24 21:09:52 +03:00
connect(m_manifest, &VersionManifestService::versionsChanged,
this, &LauncherBackend::invalidateCatalog);
connect(m_manifest, &VersionManifestService::refreshingChanged,
this, &LauncherBackend::catalogLoadingChanged);
2026-08-27 19:35:11 +03:00
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(tr("Загрузка Java «%1»…").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(tr("Java установлена: %1")
.arg(QDir::toNativeSeparators(javaPath)));
});
connect(m_javaInstaller, &JavaInstaller::failed, this,
[this](const QString &label, const QString &message) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message));
});
connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label));
});
2026-08-24 21:09:52 +03:00
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1»…").arg(id));
});
connect(m_installer, &VersionInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
// Порядок важен: на installedVersionsChanged интерфейс перечитывает каталог,
// и устаревший кэш вернулся бы наружу.
connect(m_installer, &VersionInstaller::finished, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Версия «%1» загружена").arg(id));
});
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
const QString &message) {
// Часть файлов всё же появилась — список установленного мог измениться.
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
});
connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
emit switchChanged();
emit busyChanged();
});
connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) {
// Активной сборка становится только здесь: провалившееся переключение
// оставляет активной прежнюю.
m_settings.insert(QStringLiteral("activeBuildId"), toBuildId);
saveSettings();
invalidateCatalog();
emit activeBuildChanged();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
const int index = indexOfBuildId(toBuildId);
emit launchProgress(tr("Активна сборка «%1»")
.arg(index >= 0 ? m_builds[index].name : QString()));
});
connect(m_switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) {
invalidateCatalog();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
emit launchError(gameDirIntact
? tr("Сборка не переключена: %1").arg(message)
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
"осталось разобранным — повторите переключение.")
.arg(message));
});
connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
this, &LauncherBackend::loaderVersionsChanged);
connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged,
this, &LauncherBackend::downloadChanged);
// Установщик лоадера делит панель прогресса и флаг занятости с обычным
// установщиком версий — сигналы у них одни и те же.
connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) {
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Установка %1…").arg(label));
});
connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
connect(m_loaderInstaller, &ModLoaderInstaller::log, this, &LauncherBackend::gameOutput);
connect(m_loaderInstaller, &ModLoaderInstaller::finished, this,
[this](const QString &loader, const QString &gameVersion,
const QString &loaderVersion, const QString &producedVersionId) {
const int index = indexOfBuildId(m_loaderInstallBuildId);
m_loaderInstallBuildId = -1;
if (index >= 0) {
updateCustomBuild(index,
{{QStringLiteral("loader"), loader},
{QStringLiteral("loaderVersion"), loaderVersion},
{QStringLiteral("resolvedVersionId"), producedVersionId}});
}
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Готово: %1 для Minecraft %2")
.arg(producedVersionId, gameVersion));
});
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &label, const QString &message) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchError(tr("%1: %2").arg(label, message));
});
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Установка %1 отменена").arg(label));
});
2026-08-24 21:09:52 +03:00
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
});
2026-08-23 03:30:31 +03:00
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n'));
});
}
}
2026-08-22 18:19:13 +03:00
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;
}
2026-08-22 18:19:13 +03:00
QStringList LauncherBackend::installedVersions() const
{
return VersionLoader::installedVersions(gameDir());
}
bool LauncherBackend::gameRunning() const
{
2026-08-22 18:19:13 +03:00
return m_launcher->isRunning();
}
void LauncherBackend::addProfile(const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
Profile profile;
profile.id = m_nextProfileId++;
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = (authType == kElyBy) ? kElyBy : kOffline;
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::addCustomBuild(const QString &name,
const QString &serverUrl,
const QString &minecraftVersion)
{
CustomBuild build;
build.id = m_nextBuildId++;
build.name = name;
build.serverUrl = serverUrl;
build.minecraftVersion = minecraftVersion;
m_builds.append(build);
saveCustomBuilds();
emit customBuildsChanged();
maybeInstall(launchVersionIdOf(m_builds.last()));
}
2026-08-22 18:19:13 +03:00
void LauncherBackend::updateProfile(int index,
const QString &name,
const QString &login,
const QString &password,
const QString &authType)
2026-06-16 12:16:59 +03:00
{
if (index < 0 || index >= m_profiles.size())
return;
2026-08-22 18:19:13 +03:00
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();
2026-06-16 12:16:59 +03:00
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
2026-06-16 12:16:59 +03:00
{
if (index < 0 || index >= m_builds.size())
2026-06-16 12:16:59 +03:00
return;
CustomBuild &build = m_builds[index];
const auto take = [&fields](const char *key, QString &target) {
const auto it = fields.constFind(QLatin1String(key));
if (it == fields.constEnd())
return false;
target = it->toString();
return true;
};
// Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит.
// Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию.
const QString previousMinecraft = build.minecraftVersion;
take("name", build.name);
take("serverUrl", build.serverUrl);
take("minecraftVersion", build.minecraftVersion);
take("loader", build.loader);
take("loaderVersion", build.loaderVersion);
take("resolvedVersionId", build.resolvedVersionId);
if (build.minecraftVersion != previousMinecraft
&& !fields.contains(QStringLiteral("loaderVersion"))) {
build.loaderVersion.clear();
build.resolvedVersionId.clear();
}
saveCustomBuilds();
emit customBuildsChanged();
emit activeBuildChanged(); // имя активной сборки могло измениться
// maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу
// правки, и загрузка стартовала бы почти на каждое действие. Для этого есть
// installCustomBuild().
2026-06-16 12:16:59 +03:00
}
QVariantMap LauncherBackend::profileAt(int index) const
{
if (index < 0 || index >= m_profiles.size())
return {};
const auto &p = m_profiles[index];
2026-08-22 18:19:13 +03:00
return {{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType}};
2026-06-16 12:16:59 +03:00
}
QVariantMap LauncherBackend::customBuildAt(int index) const
2026-06-16 12:16:59 +03:00
{
if (index < 0 || index >= m_builds.size())
2026-06-16 12:16:59 +03:00
return {};
const auto &b = m_builds[index];
return {{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId},
{"launchVersionId", launchVersionIdOf(b)}};
2026-06-16 12:16:59 +03:00
}
void LauncherBackend::removeProfile(int index)
{
if (index < 0 || index >= m_profiles.size())
return;
m_profiles.removeAt(index);
saveProfiles();
emit profilesChanged();
}
2026-08-27 16:44:42 +03:00
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;
2026-08-27 16:44:42 +03:00
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|| m_loaderInstaller->isRunning()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Сначала закройте игру"));
return;
}
const CustomBuild removed = m_builds.takeAt(index);
const bool wasActive = m_settings.value(QStringLiteral("activeBuildId")).toInt() == removed.id
|| m_builds.isEmpty();
saveCustomBuilds();
2026-08-27 16:44:42 +03:00
// Раньше удалялась только запись, а архив сборки оставался лежать в
// galeonLauncher/builds вместе со ссылкой на него в index.json.
if (!m_switcher->forgetBuild(removed.id)) {
emit launchError(tr("Сборка «%1» удалена, но её архив в %2 стереть не удалось")
.arg(removed.name,
QDir::toNativeSeparators(LauncherPaths::buildStorageDir())));
}
emit customBuildsChanged();
emit activeBuildChanged();
2026-08-27 16:44:42 +03:00
if (!wasActive)
return;
// Удалили активную сборку: в .minecraft лежит её содержимое, а активной
// становится другая. Оставить как есть нельзя — при следующем переключении
// чужое содержимое уехало бы в архив новой сборки поверх её собственного.
m_settings.remove(QStringLiteral("activeBuildId"));
saveSettings();
if (m_builds.isEmpty()) {
emit activeBuildChanged();
return;
}
const CustomBuild &next = m_builds.first();
emit launchProgress(tr("Активна сборка «%1»…").arg(next.name));
// from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе
// с ней. Дальше обычная очистка и распаковка новой активной сборки.
m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir());
emit switchChanged();
emit busyChanged();
}
int LauncherBackend::indexOfBuildId(int buildId) const
{
for (int i = 0; i < m_builds.size(); ++i) {
if (m_builds[i].id == buildId)
return i;
}
return -1;
}
int LauncherBackend::activeBuildIndex() const
{
if (m_builds.isEmpty())
return -1;
const int index = indexOfBuildId(m_settings.value(QStringLiteral("activeBuildId")).toInt());
// Настройка могла указывать на удалённую сборку — тогда активна первая.
return index >= 0 ? index : 0;
}
void LauncherBackend::setActiveBuildIndex(int index)
{
const int current = activeBuildIndex();
if (index < 0 || index >= m_builds.size() || index == current)
return;
if (m_switcher->isRunning()) {
emit launchError(tr("Смена сборки уже выполняется"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
return;
}
if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
// Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в
// её архив, и только потом на его место разворачивается выбранная.
const CustomBuild &target = m_builds[index];
emit launchProgress(tr("Смена сборки на «%1»…").arg(target.name));
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
current >= 0 ? m_builds[current].name : QString(),
target.id,
target.name,
gameDir());
emit switchChanged();
emit busyChanged();
}
QString LauncherBackend::activeBuildName() const
{
const int index = activeBuildIndex();
return index < 0 ? QString() : m_builds[index].name;
}
void LauncherBackend::installCustomBuild(int index)
2026-08-22 18:19:13 +03:00
{
// Установка сборки — это установка её версии игры и, если выбран,
// модлоадера: они ставятся одной цепочкой.
installLoaderForBuild(index);
2026-08-22 18:19:13 +03:00
}
QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const
2026-08-22 18:19:13 +03:00
{
// resolvedVersionId появляется после установки модлоадера. Пользователь мог
// назвать сборку как угодно — тогда версией считаем само имя: так работали
// записи, созданные до появления каталога версий.
if (!build.resolvedVersionId.isEmpty())
return build.resolvedVersionId;
return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion;
}
2026-08-22 18:19:13 +03:00
QStringList LauncherBackend::checkInstallation(int buildIndex) const
{
if (buildIndex < 0 || buildIndex >= m_builds.size())
return {tr("Сборка не выбрана")};
const CustomBuild &entry = m_builds[buildIndex];
const QString id = launchVersionIdOf(entry);
2026-08-23 03:30:31 +03:00
if (id.trimmed().isEmpty())
return {tr("У сборки не выбрана версия Minecraft. Проверьте %1")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))};
2026-08-23 03:30:31 +03:00
2026-08-22 18:19:13 +03:00
QString error;
const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error);
2026-08-23 03:30:31 +03:00
if (!version.isValid()) {
return {error.isEmpty()
? tr("Версия «%1» из сборки «%2» не установлена в %3")
2026-08-23 03:30:31 +03:00
.arg(id,
entry.name,
QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions")))
: error};
}
2026-08-22 18:19:13 +03:00
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = id;
return GameLauncher::missingFiles(options, version);
}
2026-08-24 21:09:52 +03:00
// ── Каталог версий ─────────────────────────────────────────────────────────
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);
}
2026-08-27 21:05:37 +03:00
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<int>((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(tr("Эта версия сейчас скачивается"));
return;
}
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|| m_loaderInstaller->isRunning()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
// Какой версией сейчас играют, GameLauncher наружу не отдаёт, поэтому на
// время игры удаление запрещено целиком.
if (m_launcher->isRunning()) {
emit launchError(tr("Сначала закройте игру"));
return;
}
QString error;
if (!VersionLoader::remove(gameDir(), id, &error)) {
emit launchError(tr("Не удалось удалить версию: %1").arg(error));
return;
}
invalidateCatalog();
emit installedVersionsChanged();
emit launchProgress(tr("Версия «%1» удалена").arg(id));
}
2026-08-24 21:09:52 +03:00
QVariantList LauncherBackend::versionCatalog() const
{
if (m_catalogDirty) {
rebuildCatalog();
m_catalogDirty = false;
}
return m_catalog;
}
void LauncherBackend::rebuildCatalog() const
{
m_catalog.clear();
2026-08-27 16:44:42 +03:00
// Плоский список в порядке манифеста — новые версии сверху. Раскладку по
// категориям и отбор делает окно выбора версии: ему нужен один список,
// который можно фильтровать, а не готовая структура с разделителями.
2026-08-24 21:09:52 +03:00
const QList<RemoteVersionEntry> remote = m_manifest->versions();
2026-08-27 16:44:42 +03:00
const QStringList installed = VersionLoader::installedVersions(gameDir());
const QSet<QString> 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},
2026-08-24 21:09:52 +03:00
{QStringLiteral("type"), type},
2026-08-27 16:44:42 +03:00
{QStringLiteral("category"), category(type)},
2026-08-24 21:09:52 +03:00
{QStringLiteral("label"), id},
2026-08-27 16:44:42 +03:00
{QStringLiteral("installed"), isInstalled},
2026-08-24 21:09:52 +03:00
{QStringLiteral("search"), (id + u' ' + type).toLower()}};
};
2026-08-27 16:44:42 +03:00
m_catalog.reserve(remote.size() + installed.size());
QSet<QString> 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)));
2026-08-24 21:09:52 +03:00
}
2026-08-27 16:44:42 +03:00
// Сборки модлоадеров манифест не знает — они попадают в «Прочие».
QStringList unknown;
for (const QString &id : installed) {
if (!known.contains(id))
unknown << id;
2026-08-24 21:09:52 +03:00
}
2026-08-27 16:44:42 +03:00
std::sort(unknown.begin(), unknown.end());
for (const QString &id : std::as_const(unknown))
m_catalog.append(makeVersionRow(id, QString(), true));
2026-08-24 21:09:52 +03:00
}
// ── Загрузка версий ────────────────────────────────────────────────────────
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();
2026-08-27 19:35:11 +03:00
else if (m_installer->isRunning())
m_installer->cancel();
2026-08-27 19:35:11 +03:00
else
m_javaInstaller->cancel();
2026-08-24 21:09:52 +03:00
}
2026-08-27 19:35:11 +03:00
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
// показывает его стадии, включая вложенную загрузку файлов версии. Установка
// Java начинается только когда остальные не заняты, поэтому за панель они не спорят.
2026-08-24 21:09:52 +03:00
bool LauncherBackend::downloading() const
{
2026-08-27 19:35:11 +03:00
return m_loaderInstaller->isRunning() || m_installer->isRunning()
|| m_javaInstaller->isRunning();
}
// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
bool LauncherBackend::javaDownloadShown() const
{
return m_javaInstaller->isRunning() && !m_installer->isRunning()
&& !m_loaderInstaller->isRunning();
2026-08-24 21:09:52 +03:00
}
double LauncherBackend::downloadProgress() const
{
2026-08-27 19:35:11 +03:00
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();
2026-08-24 21:09:52 +03:00
}
QString LauncherBackend::downloadVersion() const
{
2026-08-27 19:35:11 +03:00
if (javaDownloadShown())
return m_javaInstaller->label();
if (m_loaderInstaller->isRunning())
return m_loaderInstaller->label();
2026-08-24 21:09:52 +03:00
return m_installer->versionId();
}
QString LauncherBackend::downloadStatus() const
{
QString stage = m_installer->stage();
QString file = m_installer->currentFile();
2026-08-27 19:35:11 +03:00
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();
}
2026-08-24 21:09:52 +03:00
if (stage.isEmpty())
return file;
if (file.isEmpty())
return stage;
return QStringLiteral("%1 — %2").arg(stage, file);
}
qint64 LauncherBackend::downloadBytesDone() const
{
2026-08-27 19:35:11 +03:00
if (javaDownloadShown())
return m_javaInstaller->bytesDone();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
return m_loaderInstaller->bytesDone();
2026-08-24 21:09:52 +03:00
return m_installer->bytesDone();
}
qint64 LauncherBackend::downloadBytesTotal() const
{
2026-08-27 19:35:11 +03:00
if (javaDownloadShown())
return m_javaInstaller->bytesTotal();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
return m_loaderInstaller->bytesTotal();
2026-08-24 21:09:52 +03:00
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<LoaderVersionEntry> entries = m_loaderMeta->versions(*loader, gameVersion);
result.reserve(entries.size());
for (const LoaderVersionEntry &entry : entries) {
QString label = entry.loaderVersion;
if (entry.recommended)
label += tr(" — рекомендуемая");
else if (!entry.stable)
label += tr(" — нестабильная");
result.append(QVariantMap{{QStringLiteral("version"), entry.loaderVersion},
{QStringLiteral("label"), label},
{QStringLiteral("recommended"), entry.recommended},
{QStringLiteral("stable"), entry.stable}});
}
return result;
}
void LauncherBackend::refreshLoaderVersions(const QString &loaderKey,
const QString &gameVersion,
bool force)
{
const auto loader = loaderFromKey(loaderKey);
if (!loader)
return;
m_loaderMeta->ensureLoaded(*loader, gameVersion, [this, loaderKey, gameVersion](
bool ok, const QString &warning) {
if (!ok && !warning.isEmpty())
emit launchError(warning);
// Кэш мог ответить сразу, без сигнала versionsChanged.
emit loaderVersionsChanged(loaderKey, gameVersion);
}, force);
}
bool LauncherBackend::loaderVersionsLoading(const QString &loaderKey,
const QString &gameVersion) const
{
const auto loader = loaderFromKey(loaderKey);
return loader && m_loaderMeta->isRefreshing(*loader, gameVersion);
}
void LauncherBackend::installLoaderForBuild(int index)
{
if (index < 0 || index >= m_builds.size())
return;
if (busy()) {
emit launchError(tr("Дождитесь окончания текущей загрузки"));
return;
}
const CustomBuild &build = m_builds[index];
if (build.minecraftVersion.trimmed().isEmpty()) {
emit launchError(tr("Сначала выберите версию Minecraft для сборки «%1»").arg(build.name));
return;
}
// Лоадер не выбран — сборка ванильная, ставим саму версию игры.
const auto loader = loaderFromKey(build.loader);
if (!loader) {
if (build.resolvedVersionId != build.minecraftVersion)
updateCustomBuild(index, {{QStringLiteral("resolvedVersionId"), QString()}});
maybeInstall(build.minecraftVersion);
return;
}
if (build.loaderVersion.trimmed().isEmpty()) {
emit launchError(tr("Выберите версию %1 для сборки «%2»")
.arg(loaderTitle(*loader), build.name));
return;
}
m_loaderInstallBuildId = build.id;
m_loaderInstaller->install(gameDir(),
*loader,
build.minecraftVersion,
build.loaderVersion,
2026-08-27 19:35:11 +03:00
managedJavaPath().isEmpty()
? m_settings.value(QStringLiteral("javaPath")).toString()
: managedJavaPath());
}
void LauncherBackend::launchGame(int profileIndex, int buildIndex)
{
if (m_loaderInstaller->isRunning()) {
emit launchError(tr("Идёт установка «%1» — дождитесь окончания")
.arg(m_loaderInstaller->label()));
return;
}
2026-08-24 21:09:52 +03:00
if (m_installer->isRunning()) {
emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания")
.arg(m_installer->versionId()));
return;
}
2026-08-27 19:35:11 +03:00
// Игра ищет java при запуске: стартовать посреди её установки — значит
// найти половину распакованного дерева.
if (m_javaInstaller->isRunning()) {
emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания")
.arg(m_javaInstaller->label()));
return;
}
2026-08-22 18:19:13 +03:00
if (m_busy) {
emit launchError(tr("Запуск уже выполняется"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Игра уже запущена"));
return;
}
if (profileIndex < 0 || profileIndex >= m_profiles.size()) {
2026-08-22 18:19:13 +03:00
emit launchError(tr("Выберите профиль перед запуском"));
return;
}
if (buildIndex < 0 || buildIndex >= m_builds.size()) {
emit launchError(tr("Выберите сборку перед запуском"));
2026-08-22 18:19:13 +03:00
return;
}
2026-08-23 03:30:31 +03:00
// Дальше проверяется только папка игры: свои файлы лаунчер уже разложил
// в galeonLauncher при запуске.
2026-08-22 18:19:13 +03:00
const QString directory = gameDir();
if (!QDir(directory).exists()) {
2026-08-23 03:30:31 +03:00
emit launchError(tr("Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.")
.arg(QDir::toNativeSeparators(directory)));
return;
}
const CustomBuild &entry = m_builds[buildIndex];
const QString id = launchVersionIdOf(entry);
2026-08-23 03:30:31 +03:00
if (id.trimmed().isEmpty()) {
emit launchError(tr("У сборки «%1» не выбрана версия Minecraft. Проверьте %2")
.arg(entry.name,
QDir::toNativeSeparators(LauncherPaths::customBuildsFile())));
2026-08-22 18:19:13 +03:00
return;
}
2026-08-23 03:30:31 +03:00
const QString versionsDir = QDir::toNativeSeparators(directory + QStringLiteral("/versions"));
2026-08-22 18:19:13 +03:00
const QStringList installed = VersionLoader::installedVersions(directory);
if (!installed.contains(id)) {
2026-08-22 23:39:46 +00:00
emit launchError(installed.isEmpty()
2026-08-23 03:30:31 +03:00
? tr("В %1 нет ни одной установленной версии").arg(versionsDir)
: tr("Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4")
2026-08-23 03:30:31 +03:00
.arg(id,
entry.name,
versionsDir,
installed.join(QStringLiteral(", "))));
2026-08-22 18:19:13 +03:00
return;
}
setBusy(true);
emit launchProgress(tr("Чтение описания версии «%1»…").arg(id));
QSet<QString> features;
if (settingInt(m_settings, QStringLiteral("windowWidth"), 0) > 0
&& settingInt(m_settings, QStringLiteral("windowHeight"), 0) > 0)
features << QStringLiteral("has_custom_resolution");
QString error;
m_pendingVersionData = VersionLoader::load(directory, id, features, &error);
if (!m_pendingVersionData.isValid()) {
failLaunch(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(id) : error);
return;
}
LaunchOptions probe;
probe.gameDir = directory;
probe.versionId = id;
const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData);
if (!missing.isEmpty()) {
failLaunch(tr("В папке игры не хватает файлов (%1):\n%2")
.arg(missing.size())
.arg(missing.join(u'\n')));
return;
}
m_pendingProfile = profileIndex;
// Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из
// модального окна, и индекс уехал бы на чужую запись.
m_pendingBuild = entry.id;
2026-08-22 18:19:13 +03:00
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 и токен окажется недействительным.
2026-08-23 03:30:31 +03:00
m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(),
[this](const QString &path, const QString &error) {
2026-08-22 18:19:13 +03:00
if (m_pendingProfile < 0)
2026-08-22 23:39:46 +00:00
return; // запуск отменили, пока качался инжектор
2026-08-22 18:19:13 +03:00
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;
}
2026-08-22 18:19:13 +03:00
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);
});
}
2026-08-22 18:19:13 +03:00
void LauncherBackend::cancelPendingLaunch()
{
m_pendingProfile = -1;
m_pendingBuild = -1;
2026-08-22 18:19:13 +03:00
m_pendingInjectorPath.clear();
setBusy(false);
}
void LauncherBackend::continueLaunch(const AuthResult &auth)
{
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) {
2026-08-22 18:19:13 +03:00
failLaunch(tr("Запуск был отменён"));
return;
}
const int buildIndex = indexOfBuildId(m_pendingBuild);
if (buildIndex < 0) {
failLaunch(tr("Сборка была удалена, пока шла авторизация"));
return;
}
2026-08-22 18:19:13 +03:00
Profile &profile = m_profiles[m_pendingProfile];
const CustomBuild &build = m_builds[buildIndex];
2026-08-22 18:19:13 +03:00
// 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;
2026-08-27 19:35:11 +03:00
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
// окне выбора версии Java.
options.javaPath = managedJavaPath();
if (options.javaPath.isEmpty()) {
if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty())
emit launchProgress(tr("Выбранная сборка Java не найдена в папке лаунчера — "
"ищем установленную в системе"));
options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString();
}
2026-08-22 18:19:13 +03:00
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();
2026-08-22 18:19:13 +03:00
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;
2026-08-22 18:19:13 +03:00
cancelPendingLaunch();
if (!started) {
emit launchError(error);
return;
}
emit gameRunningChanged();
emit launched(profileName, buildName, serverUrl);
2026-08-22 18:19:13 +03:00
}
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();
}
2026-08-24 21:09:52 +03:00
bool LauncherBackend::busy() const
{
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
2026-08-27 19:35:11 +03:00
|| m_javaInstaller->isRunning() || m_switcher->isRunning();
2026-08-24 21:09:52 +03:00
}
2026-08-22 18:19:13 +03:00
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();
2026-08-23 03:30:31 +03:00
return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir()
: QDir::fromNativeSeparators(configured);
2026-08-22 18:19:13 +03:00
}
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();
2026-08-24 21:09:52 +03:00
if (gameDir() != previousGameDir) {
invalidateCatalog();
2026-08-22 18:19:13 +03:00
emit installedVersionsChanged();
2026-08-24 21:09:52 +03:00
}
2026-08-22 18:19:13 +03:00
}
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;
}
2026-08-27 19:35:11 +03:00
// ── Сборки 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<InstalledJavaRuntime> installed = JavaRuntimeStore::installed();
QHash<QString, qint64> 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<QString> 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
? tr("Mojang · %1").arg(remote->component)
: tr("Temurin · %1").arg(remote->architecture))
: tr("скачана лаунчером");
m_javaCatalog.append(makeRow(runtime.id,
runtime.kind,
runtime.major,
runtime.version,
detail,
runtime.size,
true,
remote && remote->lts,
remote.has_value()));
}
const QList<JavaRuntimeKind> kinds{JavaRuntimeKind::Mojang, JavaRuntimeKind::Jdk,
JavaRuntimeKind::Jre};
for (const JavaRuntimeKind kind : kinds) {
const QList<JavaRuntimeEntry> entries = m_javaMeta->entries(kind);
for (const JavaRuntimeEntry &entry : entries) {
if (listed.contains(entry.id))
continue;
const QString detail = kind == JavaRuntimeKind::Mojang
? tr("Mojang · %1").arg(entry.component)
: tr("Temurin · %1").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(tr("Идёт установка Java «%1» — дождитесь окончания")
.arg(m_javaInstaller->label()));
return;
}
if (m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
const auto entry = m_javaMeta->find(runtimeId);
if (!entry) {
emit launchError(tr("Сборка Java «%1» не найдена в каталоге").arg(runtimeId));
return;
}
m_javaInstaller->install(*entry);
}
void LauncherBackend::removeJavaRuntime(const QString &runtimeId)
{
if (m_javaInstaller->isRunning() && m_javaInstaller->runtimeId() == runtimeId) {
emit launchError(tr("Эта сборка Java сейчас скачивается"));
return;
}
QString error;
if (!JavaRuntimeStore::remove(runtimeId, &error)) {
emit launchError(tr("Не удалось удалить сборку Java: %1").arg(error));
return;
}
// Удалили выбранную — настройка указывала бы в пустоту.
if (m_settings.value(QStringLiteral("javaRuntime")).toString() == runtimeId) {
m_settings.insert(QStringLiteral("javaRuntime"), QString());
saveSettings();
emit settingsChanged();
}
invalidateJavaCatalog();
emit launchProgress(tr("Сборка Java удалена"));
}
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();
}
2026-06-19 21:03:37 +03:00
void LauncherBackend::openMinecraftFolder()
{
2026-08-22 18:19:13 +03:00
const QString modsDir = gameDir() + QStringLiteral("/mods");
2026-06-19 21:03:37 +03:00
QDir().mkpath(modsDir);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir)))
2026-08-22 18:19:13 +03:00
emit launchError(tr("Не удалось открыть папку с модами"));
}
void LauncherBackend::openGameFolder()
{
const QString directory = gameDir();
QDir().mkpath(directory);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory)))
emit launchError(tr("Не удалось открыть папку игры"));
2026-06-19 21:03:37 +03:00
}
2026-08-23 03:30:31 +03:00
// Повреждённый файл не удаляем: отводим в .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()
{
2026-08-23 03:30:31 +03:00
auto loadFile = [this](const QString &path, auto handler) {
for (const auto &val : readArrayFile(path))
handler(val.toObject());
};
2026-08-23 03:30:31 +03:00
loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) {
2026-08-22 18:19:13 +03:00
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;
});
2026-08-22 23:39:46 +00:00
std::sort(m_profiles.begin(), m_profiles.end(),
[](const Profile &a, const Profile &b) { return a.id < b.id; });
int skippedBuilds = 0;
loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) {
CustomBuild build;
build.id = o["id"].toInt();
build.name = o["name"].toString();
build.serverUrl = o["serverUrl"].toString();
build.minecraftVersion = o.value("minecraftVersion").toString();
// Файл мог остаться от версии лаунчера до переименования или быть
// поправлен руками — старый ключ тоже принимаем.
if (build.minecraftVersion.isEmpty())
build.minecraftVersion = o.value("versionId").toString();
build.loader = o.value("loader").toString();
build.loaderVersion = o.value("loaderVersion").toString();
build.resolvedVersionId = o.value("resolvedVersionId").toString();
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
// пустую строку.
if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) {
++skippedBuilds;
2026-08-23 03:30:31 +03:00
return;
}
m_builds.append(build);
if (build.id >= m_nextBuildId)
m_nextBuildId = build.id + 1;
});
if (skippedBuilds > 0) {
m_storageIssues << tr("В %1 пропущены сборки без имени и версии Minecraft: %2")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))
.arg(skippedBuilds);
2026-08-23 03:30:31 +03:00
}
std::sort(m_builds.begin(), m_builds.end(),
[](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; });
}
// Первый запуск после переименования «версий» в «сборки»: перекладываем
// <root>/versions.json в customBuilds.json. Старый файл не удаляем, а
// переименовываем — он остаётся резервной копией.
bool LauncherBackend::migrateLegacyBuildsFile()
{
if (QFile::exists(LauncherPaths::customBuildsFile()))
return false;
if (!QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
return false;
// readArrayFile() сам отводит битый файл в .bak — тогда мигрировать нечего.
const QJsonArray legacy = readArrayFile(LauncherPaths::legacyCustomBuildsFile());
if (legacy.isEmpty() && !QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
return false;
QJsonArray migrated;
for (const auto &value : legacy) {
const QJsonObject o = value.toObject();
migrated.append(QJsonObject{{"id", o.value("id").toInt()},
{"name", o.value("name").toString()},
{"serverUrl", o.value("serverUrl").toString()},
{"minecraftVersion", o.value("versionId").toString()},
{"loader", QString()},
{"loaderVersion", QString()},
{"resolvedVersionId", QString()}});
}
// Не удалось записать — молча читаем в этой сессии из старого файла.
// Без данных пользователь не остаётся ни при каком исходе.
if (!writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(migrated)))
return false;
const QString retired = LauncherPaths::legacyCustomBuildsFile() + QStringLiteral(".migrated");
QFile::remove(retired);
if (!QFile::rename(LauncherPaths::legacyCustomBuildsFile(), retired)) {
m_storageIssues << tr("Сборки перенесены в %1, но старый файл %2 удалить не удалось")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()),
QDir::toNativeSeparators(
LauncherPaths::legacyCustomBuildsFile()));
}
return true;
}
2026-08-23 03:30:31 +03:00
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles()
{
if (!QFile::exists(LauncherPaths::settingsFile()))
saveSettings();
if (!QFile::exists(LauncherPaths::profilesFile()))
saveProfiles();
if (!QFile::exists(LauncherPaths::customBuildsFile()))
saveCustomBuilds();
2026-08-23 03:30:31 +03:00
}
void LauncherBackend::saveProfiles()
{
QJsonArray arr;
for (const auto &p : m_profiles)
2026-08-22 18:19:13 +03:00
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}});
2026-08-23 03:30:31 +03:00
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
}
void LauncherBackend::saveCustomBuilds()
{
QJsonArray arr;
for (const auto &b : m_builds)
arr.append(QJsonObject{{"id", b.id},
{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId}});
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
}
2026-08-22 18:19:13 +03:00
void LauncherBackend::loadSettings()
{
m_settings = {{QStringLiteral("gameDir"), QString()},
{QStringLiteral("javaPath"), QString()},
2026-08-27 19:35:11 +03:00
// id сборки Java из <root>/java. Пусто — java ищется сама.
{QStringLiteral("javaRuntime"), QString()},
2026-08-22 18:19:13 +03:00
{QStringLiteral("minMemoryMb"), 512},
{QStringLiteral("maxMemoryMb"), 4096},
{QStringLiteral("jvmArgs"), QString()},
{QStringLiteral("windowWidth"), 0},
{QStringLiteral("windowHeight"), 0},
{QStringLiteral("fullscreen"), false},
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0}};
2026-08-22 18:19:13 +03:00
2026-08-23 03:30:31 +03:00
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
2026-08-22 18:19:13 +03:00
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
m_settings.insert(it.key(), it.value().toVariant());
}
void LauncherBackend::saveSettings()
{
2026-08-23 03:30:31 +03:00
writeJsonFile(LauncherPaths::settingsFile(),
QJsonDocument(QJsonObject::fromVariantMap(m_settings)));
2026-08-22 18:19:13 +03:00
}