Files
minecraft-launcher/launcherbackend.cpp
T
2026-08-27 21:05:37 +03:00

1761 lines
72 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "launcherbackend.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 "modloaderversionservice.h"
#include "buildswitcher.h"
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHash>
#include <QRegularExpression>
#include <QSet>
#include <QTimer>
#include <QUrl>
#include <algorithm>
namespace {
const QString kOffline = QStringLiteral("offline");
const QString kElyBy = QStringLiteral("elyby");
int settingInt(const QVariantMap &settings, const QString &key, int fallback)
{
const QVariant value = settings.value(key);
bool ok = false;
const int parsed = value.toInt(&ok);
return ok ? parsed : fallback;
}
} // namespace
LauncherBackend::LauncherBackend(QObject *parent)
: QObject(parent)
, m_auth(new AuthService(this))
, m_launcher(new GameLauncher(this))
, m_manifest(new VersionManifestService(this))
, m_installer(new VersionInstaller(m_manifest, this))
, m_loaderMeta(new ModLoaderVersionService(this))
, m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this))
, m_switcher(new BuildSwitcher(this))
, m_javaMeta(new JavaRuntimeService(this))
, m_javaInstaller(new JavaInstaller(this))
{
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
// следующем — если её удалили, она появится снова.
QString storageError;
if (!LauncherPaths::ensureRootExists(&storageError))
m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError);
loadSettings();
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
// создаст пустой customBuilds.json поверх старых данных.
migrateLegacyBuildsFile();
loadData();
ensureDataFiles();
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
// предлагаем пользователю переключиться заново: архив прежней сборки уже
// записан целиком, а вот повторное переключение сначала заархивировало бы
// поверх него нынешнее — наполовину разобранное — содержимое .minecraft.
const QString interrupted = m_switcher->interruptedSwitchWarning();
if (!interrupted.isEmpty()) {
m_storageIssues << interrupted;
QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); });
}
connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
emit gameOutput(commandLine);
emit gameRunningChanged();
});
connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) {
emit gameRunningChanged();
emit gameFinished(exitCode, crashed);
});
connect(m_manifest, &VersionManifestService::versionsChanged,
this, &LauncherBackend::invalidateCatalog);
connect(m_manifest, &VersionManifestService::refreshingChanged,
this, &LauncherBackend::catalogLoadingChanged);
connect(m_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));
});
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1»…").arg(id));
});
connect(m_installer, &VersionInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
// Порядок важен: на installedVersionsChanged интерфейс перечитывает каталог,
// и устаревший кэш вернулся бы наружу.
connect(m_installer, &VersionInstaller::finished, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Версия «%1» загружена").arg(id));
});
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
const QString &message) {
// Часть файлов всё же появилась — список установленного мог измениться.
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
});
connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
emit switchChanged();
emit busyChanged();
});
connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) {
// Активной сборка становится только здесь: провалившееся переключение
// оставляет активной прежнюю.
m_settings.insert(QStringLiteral("activeBuildId"), toBuildId);
saveSettings();
invalidateCatalog();
emit activeBuildChanged();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
const int index = indexOfBuildId(toBuildId);
emit launchProgress(tr("Активна сборка «%1»")
.arg(index >= 0 ? m_builds[index].name : QString()));
});
connect(m_switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) {
invalidateCatalog();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
emit launchError(gameDirIntact
? tr("Сборка не переключена: %1").arg(message)
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
"осталось разобранным — повторите переключение.")
.arg(message));
});
connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
this, &LauncherBackend::loaderVersionsChanged);
connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged,
this, &LauncherBackend::downloadChanged);
// Установщик лоадера делит панель прогресса и флаг занятости с обычным
// установщиком версий — сигналы у них одни и те же.
connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) {
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Установка %1…").arg(label));
});
connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
connect(m_loaderInstaller, &ModLoaderInstaller::log, this, &LauncherBackend::gameOutput);
connect(m_loaderInstaller, &ModLoaderInstaller::finished, this,
[this](const QString &loader, const QString &gameVersion,
const QString &loaderVersion, const QString &producedVersionId) {
const int index = indexOfBuildId(m_loaderInstallBuildId);
m_loaderInstallBuildId = -1;
if (index >= 0) {
updateCustomBuild(index,
{{QStringLiteral("loader"), loader},
{QStringLiteral("loaderVersion"), loaderVersion},
{QStringLiteral("resolvedVersionId"), producedVersionId}});
}
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Готово: %1 для Minecraft %2")
.arg(producedVersionId, gameVersion));
});
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &label, const QString &message) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchError(tr("%1: %2").arg(label, message));
});
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Установка %1 отменена").arg(label));
});
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
});
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n'));
});
}
}
LauncherBackend::~LauncherBackend() = default;
QStringList LauncherBackend::profileNames() const
{
QStringList names;
for (const auto &p : m_profiles)
names << p.name;
return names;
}
QStringList LauncherBackend::customBuildNames() const
{
QStringList names;
for (const auto &b : m_builds)
names << b.name;
return names;
}
QStringList LauncherBackend::installedVersions() const
{
return VersionLoader::installedVersions(gameDir());
}
bool LauncherBackend::gameRunning() const
{
return m_launcher->isRunning();
}
void LauncherBackend::addProfile(const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
Profile profile;
profile.id = m_nextProfileId++;
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = (authType == kElyBy) ? kElyBy : kOffline;
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::addCustomBuild(const QString &name,
const QString &serverUrl,
const QString &minecraftVersion)
{
CustomBuild build;
build.id = m_nextBuildId++;
build.name = name;
build.serverUrl = serverUrl;
build.minecraftVersion = minecraftVersion;
m_builds.append(build);
saveCustomBuilds();
emit customBuildsChanged();
maybeInstall(launchVersionIdOf(m_builds.last()));
}
void LauncherBackend::updateProfile(int index,
const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
if (index < 0 || index >= m_profiles.size())
return;
Profile &profile = m_profiles[index];
// Смена логина или пароля обесценивает сохранённый токен Ely.by.
if (profile.login != login || profile.password != password) {
profile.accessToken.clear();
profile.uuid.clear();
profile.resolvedName.clear();
}
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = (authType == kElyBy) ? kElyBy : kOffline;
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
{
if (index < 0 || index >= m_builds.size())
return;
CustomBuild &build = m_builds[index];
const auto take = [&fields](const char *key, QString &target) {
const auto it = fields.constFind(QLatin1String(key));
if (it == fields.constEnd())
return false;
target = it->toString();
return true;
};
// Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит.
// Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию.
const QString previousMinecraft = build.minecraftVersion;
take("name", build.name);
take("serverUrl", build.serverUrl);
take("minecraftVersion", build.minecraftVersion);
take("loader", build.loader);
take("loaderVersion", build.loaderVersion);
take("resolvedVersionId", build.resolvedVersionId);
if (build.minecraftVersion != previousMinecraft
&& !fields.contains(QStringLiteral("loaderVersion"))) {
build.loaderVersion.clear();
build.resolvedVersionId.clear();
}
saveCustomBuilds();
emit customBuildsChanged();
emit activeBuildChanged(); // имя активной сборки могло измениться
// maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу
// правки, и загрузка стартовала бы почти на каждое действие. Для этого есть
// installCustomBuild().
}
QVariantMap LauncherBackend::profileAt(int index) const
{
if (index < 0 || index >= m_profiles.size())
return {};
const auto &p = m_profiles[index];
return {{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType}};
}
QVariantMap LauncherBackend::customBuildAt(int index) const
{
if (index < 0 || index >= m_builds.size())
return {};
const auto &b = m_builds[index];
return {{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId},
{"launchVersionId", launchVersionIdOf(b)}};
}
void LauncherBackend::removeProfile(int index)
{
if (index < 0 || index >= m_profiles.size())
return;
m_profiles.removeAt(index);
saveProfiles();
emit profilesChanged();
}
QVariantMap LauncherBackend::customBuildRemovalInfo(int index) const
{
if (index < 0 || index >= m_builds.size())
return {};
const CustomBuild &build = m_builds[index];
const bool active = m_settings.value(QStringLiteral("activeBuildId")).toInt() == build.id
|| m_builds.size() == 1;
return {{QStringLiteral("name"), build.name},
{QStringLiteral("hasArchive"), m_switcher->hasArchive(build.id)},
{QStringLiteral("active"), active},
{QStringLiteral("lastOne"), m_builds.size() == 1}};
}
void LauncherBackend::removeCustomBuild(int index)
{
if (index < 0 || index >= m_builds.size())
return;
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|| m_loaderInstaller->isRunning()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Сначала закройте игру"));
return;
}
const CustomBuild removed = m_builds.takeAt(index);
const bool wasActive = m_settings.value(QStringLiteral("activeBuildId")).toInt() == removed.id
|| m_builds.isEmpty();
saveCustomBuilds();
// Раньше удалялась только запись, а архив сборки оставался лежать в
// galeonLauncher/builds вместе со ссылкой на него в index.json.
if (!m_switcher->forgetBuild(removed.id)) {
emit launchError(tr("Сборка «%1» удалена, но её архив в %2 стереть не удалось")
.arg(removed.name,
QDir::toNativeSeparators(LauncherPaths::buildStorageDir())));
}
emit customBuildsChanged();
emit activeBuildChanged();
if (!wasActive)
return;
// Удалили активную сборку: в .minecraft лежит её содержимое, а активной
// становится другая. Оставить как есть нельзя — при следующем переключении
// чужое содержимое уехало бы в архив новой сборки поверх её собственного.
m_settings.remove(QStringLiteral("activeBuildId"));
saveSettings();
if (m_builds.isEmpty()) {
emit activeBuildChanged();
return;
}
const CustomBuild &next = m_builds.first();
emit launchProgress(tr("Активна сборка «%1»…").arg(next.name));
// from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе
// с ней. Дальше обычная очистка и распаковка новой активной сборки.
m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir());
emit switchChanged();
emit busyChanged();
}
int LauncherBackend::indexOfBuildId(int buildId) const
{
for (int i = 0; i < m_builds.size(); ++i) {
if (m_builds[i].id == buildId)
return i;
}
return -1;
}
int LauncherBackend::activeBuildIndex() const
{
if (m_builds.isEmpty())
return -1;
const int index = indexOfBuildId(m_settings.value(QStringLiteral("activeBuildId")).toInt());
// Настройка могла указывать на удалённую сборку — тогда активна первая.
return index >= 0 ? index : 0;
}
void LauncherBackend::setActiveBuildIndex(int index)
{
const int current = activeBuildIndex();
if (index < 0 || index >= m_builds.size() || index == current)
return;
if (m_switcher->isRunning()) {
emit launchError(tr("Смена сборки уже выполняется"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
return;
}
if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
// Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в
// её архив, и только потом на его место разворачивается выбранная.
const CustomBuild &target = m_builds[index];
emit launchProgress(tr("Смена сборки на «%1»…").arg(target.name));
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
current >= 0 ? m_builds[current].name : QString(),
target.id,
target.name,
gameDir());
emit switchChanged();
emit busyChanged();
}
QString LauncherBackend::activeBuildName() const
{
const int index = activeBuildIndex();
return index < 0 ? QString() : m_builds[index].name;
}
void LauncherBackend::installCustomBuild(int index)
{
// Установка сборки — это установка её версии игры и, если выбран,
// модлоадера: они ставятся одной цепочкой.
installLoaderForBuild(index);
}
QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const
{
// resolvedVersionId появляется после установки модлоадера. Пользователь мог
// назвать сборку как угодно — тогда версией считаем само имя: так работали
// записи, созданные до появления каталога версий.
if (!build.resolvedVersionId.isEmpty())
return build.resolvedVersionId;
return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion;
}
QStringList LauncherBackend::checkInstallation(int buildIndex) const
{
if (buildIndex < 0 || buildIndex >= m_builds.size())
return {tr("Сборка не выбрана")};
const CustomBuild &entry = m_builds[buildIndex];
const QString id = launchVersionIdOf(entry);
if (id.trimmed().isEmpty())
return {tr("У сборки не выбрана версия Minecraft. Проверьте %1")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))};
QString error;
const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error);
if (!version.isValid()) {
return {error.isEmpty()
? tr("Версия «%1» из сборки «%2» не установлена в %3")
.arg(id,
entry.name,
QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions")))
: error};
}
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = id;
return GameLauncher::missingFiles(options, version);
}
// ── Каталог версий ─────────────────────────────────────────────────────────
void LauncherBackend::invalidateCatalog()
{
m_catalogDirty = true;
emit versionCatalogChanged();
}
bool LauncherBackend::catalogLoading() const
{
return m_manifest->isRefreshing();
}
void LauncherBackend::refreshVersionCatalog(bool force)
{
// Список установленного мог поменяться и без нас (папку добавили руками).
invalidateCatalog();
m_manifest->ensureLoaded([this](bool ok, const QString &warning) {
if (!ok && !warning.isEmpty())
emit launchError(warning);
}, force);
}
bool LauncherBackend::isVersionInstalled(const QString &versionId) const
{
if (versionId.trimmed().isEmpty())
return false;
return VersionLoader::installedVersions(gameDir()).contains(versionId);
}
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));
}
QVariantList LauncherBackend::versionCatalog() const
{
if (m_catalogDirty) {
rebuildCatalog();
m_catalogDirty = false;
}
return m_catalog;
}
void LauncherBackend::rebuildCatalog() const
{
m_catalog.clear();
// Плоский список в порядке манифеста — новые версии сверху. Раскладку по
// категориям и отбор делает окно выбора версии: ему нужен один список,
// который можно фильтровать, а не готовая структура с разделителями.
const QList<RemoteVersionEntry> remote = m_manifest->versions();
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},
{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<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)));
}
// Сборки модлоадеров манифест не знает — они попадают в «Прочие».
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
m_javaInstaller->cancel();
}
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
// показывает его стадии, включая вложенную загрузку файлов версии. Установка
// Java начинается только когда остальные не заняты, поэтому за панель они не спорят.
bool LauncherBackend::downloading() const
{
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();
}
double LauncherBackend::downloadProgress() const
{
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 (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 (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 (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 (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<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,
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;
}
if (m_installer->isRunning()) {
emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания")
.arg(m_installer->versionId()));
return;
}
// Игра ищет java при запуске: стартовать посреди её установки — значит
// найти половину распакованного дерева.
if (m_javaInstaller->isRunning()) {
emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания")
.arg(m_javaInstaller->label()));
return;
}
if (m_busy) {
emit launchError(tr("Запуск уже выполняется"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Игра уже запущена"));
return;
}
if (profileIndex < 0 || profileIndex >= m_profiles.size()) {
emit launchError(tr("Выберите профиль перед запуском"));
return;
}
if (buildIndex < 0 || buildIndex >= m_builds.size()) {
emit launchError(tr("Выберите сборку перед запуском"));
return;
}
// Дальше проверяется только папка игры: свои файлы лаунчер уже разложил
// в galeonLauncher при запуске.
const QString directory = gameDir();
if (!QDir(directory).exists()) {
emit launchError(tr("Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.")
.arg(QDir::toNativeSeparators(directory)));
return;
}
const CustomBuild &entry = m_builds[buildIndex];
const QString id = launchVersionIdOf(entry);
if (id.trimmed().isEmpty()) {
emit launchError(tr("У сборки «%1» не выбрана версия Minecraft. Проверьте %2")
.arg(entry.name,
QDir::toNativeSeparators(LauncherPaths::customBuildsFile())));
return;
}
const QString versionsDir = QDir::toNativeSeparators(directory + QStringLiteral("/versions"));
const QStringList installed = VersionLoader::installedVersions(directory);
if (!installed.contains(id)) {
emit launchError(installed.isEmpty()
? tr("В %1 нет ни одной установленной версии").arg(versionsDir)
: tr("Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4")
.arg(id,
entry.name,
versionsDir,
installed.join(QStringLiteral(", "))));
return;
}
setBusy(true);
emit launchProgress(tr("Чтение описания версии «%1»…").arg(id));
QSet<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;
m_pendingInjectorPath.clear();
beginAuthentication();
}
void LauncherBackend::beginAuthentication()
{
const Profile &profile = m_profiles[m_pendingProfile];
if (profile.authType != kElyBy) {
const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
const AuthResult result = AuthService::offline(nickname);
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
return;
}
// Ely.by работает только вместе с authlib-injector — без него игра пойдёт
// в сессионный сервер Mojang и токен окажется недействительным.
m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(),
[this](const QString &path, const QString &error) {
if (m_pendingProfile < 0)
return; // запуск отменили, пока качался инжектор
if (!error.isEmpty()) {
failLaunch(error);
return;
}
m_pendingInjectorPath = path;
const Profile &pending = m_profiles[m_pendingProfile];
m_auth->loginElyBy(pending.login,
pending.password,
pending.clientToken,
pending.accessToken,
[this](const AuthResult &result) {
if (m_pendingProfile < 0)
return;
if (result.twoFactorRequired) {
setBusy(false);
emit twoFactorRequired(m_profiles[m_pendingProfile].name);
return;
}
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
});
});
}
void LauncherBackend::submitTwoFactorCode(const QString &code)
{
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) {
emit launchError(tr("Запуск уже отменён"));
return;
}
if (code.trimmed().isEmpty()) {
emit launchError(tr("Введите код двухфакторной аутентификации"));
return;
}
setBusy(true);
const Profile &profile = m_profiles[m_pendingProfile];
m_auth->loginElyByWithTotp(profile.login,
profile.password,
code.trimmed(),
profile.clientToken,
[this](const AuthResult &result) {
if (m_pendingProfile < 0)
return;
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
});
}
void LauncherBackend::cancelPendingLaunch()
{
m_pendingProfile = -1;
m_pendingBuild = -1;
m_pendingInjectorPath.clear();
setBusy(false);
}
void LauncherBackend::continueLaunch(const AuthResult &auth)
{
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) {
failLaunch(tr("Запуск был отменён"));
return;
}
const int buildIndex = indexOfBuildId(m_pendingBuild);
if (buildIndex < 0) {
failLaunch(tr("Сборка была удалена, пока шла авторизация"));
return;
}
Profile &profile = m_profiles[m_pendingProfile];
const CustomBuild &build = m_builds[buildIndex];
// Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем,
// чтобы в следующий раз обойтись без пароля.
if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
profile.accessToken = auth.accessToken;
profile.clientToken = auth.clientToken;
if (!auth.uuid.isEmpty())
profile.uuid = auth.uuid;
if (!auth.playerName.isEmpty())
profile.resolvedName = auth.playerName;
profile.userType = auth.userType;
saveProfiles();
}
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = m_pendingVersionData.id;
options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName;
if (options.playerName.isEmpty())
options.playerName = profile.login.isEmpty() ? profile.name : profile.login;
options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid;
options.accessToken = auth.accessToken;
options.clientToken = auth.clientToken;
options.userType = auth.userType;
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
// окне выбора версии 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();
}
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();
}
void LauncherBackend::setBusy(bool busy)
{
if (m_busy == busy)
return;
m_busy = busy;
emit busyChanged();
}
QString LauncherBackend::gameDir() const
{
const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed();
return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir()
: QDir::fromNativeSeparators(configured);
}
QVariantMap LauncherBackend::settings() const
{
QVariantMap result = m_settings;
result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir());
return result;
}
void LauncherBackend::updateSettings(const QVariantMap &values)
{
const QString previousGameDir = gameDir();
for (auto it = values.cbegin(); it != values.cend(); ++it)
m_settings.insert(it.key(), it.value());
saveSettings();
emit settingsChanged();
if (gameDir() != previousGameDir) {
invalidateCatalog();
emit installedVersionsChanged();
}
}
QStringList LauncherBackend::detectedJava() const
{
QStringList result;
const QStringList candidates = JavaLocator::findAll(gameDir());
for (const QString &candidate : candidates) {
const int major = JavaLocator::majorVersion(candidate);
result << (major > 0 ? QStringLiteral("Java %1 — %2")
.arg(major)
.arg(QDir::toNativeSeparators(candidate))
: QDir::toNativeSeparators(candidate));
}
return result;
}
// ── Сборки 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();
}
void LauncherBackend::openMinecraftFolder()
{
const QString modsDir = gameDir() + QStringLiteral("/mods");
QDir().mkpath(modsDir);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir)))
emit launchError(tr("Не удалось открыть папку с модами"));
}
void LauncherBackend::openGameFolder()
{
const QString directory = gameDir();
QDir().mkpath(directory);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory)))
emit launchError(tr("Не удалось открыть папку игры"));
}
// Повреждённый файл не удаляем: отводим в .bak, чтобы данные можно было спасти
// руками, а ensureDataFiles() создал на его месте пустой.
bool LauncherBackend::moveAsideBroken(const QString &path, const QString &reason)
{
const QString backup = path + QStringLiteral(".bak");
QFile::remove(backup);
if (!QFile::rename(path, backup)) {
m_storageIssues << tr("Файл %1 повреждён (%2), и его не удалось отложить в сторону")
.arg(QDir::toNativeSeparators(path), reason);
return false;
}
m_storageIssues << tr("Файл %1 повреждён (%2). Копия сохранена как %3, файл создан заново.")
.arg(QDir::toNativeSeparators(path),
reason,
QDir::toNativeSeparators(backup));
return true;
}
QJsonArray LauncherBackend::readArrayFile(const QString &path)
{
QFile file(path);
if (!file.exists())
return {}; // первый запуск — это не ошибка
if (!file.open(QIODevice::ReadOnly)) {
m_storageIssues << tr("Не удалось прочитать %1: %2")
.arg(QDir::toNativeSeparators(path), file.errorString());
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
file.close();
moveAsideBroken(path, parseError.errorString());
return {};
}
if (!document.isArray()) {
file.close();
moveAsideBroken(path, tr("ожидался список записей"));
return {};
}
return document.array();
}
QJsonObject LauncherBackend::readObjectFile(const QString &path)
{
QFile file(path);
if (!file.exists())
return {};
if (!file.open(QIODevice::ReadOnly)) {
m_storageIssues << tr("Не удалось прочитать %1: %2")
.arg(QDir::toNativeSeparators(path), file.errorString());
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
file.close();
moveAsideBroken(path, parseError.errorString());
return {};
}
if (!document.isObject()) {
file.close();
moveAsideBroken(path, tr("ожидался набор настроек"));
return {};
}
return document.object();
}
// Перед каждой записью убеждаемся, что папка лаунчера на месте: её могли удалить
// уже после запуска, и тогда данные молча пропадали бы.
bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &document)
{
QString storageError;
if (!LauncherPaths::ensureRootExists(&storageError)) {
emit launchError(tr("Не удалось создать папку лаунчера: %1").arg(storageError));
return false;
}
const QByteArray payload = document.toJson();
QFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
emit launchError(tr("Не удалось сохранить %1: %2")
.arg(QDir::toNativeSeparators(path), file.errorString()));
return false;
}
return true;
}
void LauncherBackend::loadData()
{
auto loadFile = [this](const QString &path, auto handler) {
for (const auto &val : readArrayFile(path))
handler(val.toObject());
};
loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) {
Profile profile;
profile.id = o["id"].toInt();
profile.name = o["name"].toString();
profile.login = o["login"].toString();
profile.password = o["password"].toString();
profile.authType = o.value("authType").toString(kOffline);
profile.clientToken = o.value("clientToken").toString();
profile.accessToken = o.value("accessToken").toString();
profile.uuid = o.value("uuid").toString();
profile.resolvedName = o.value("resolvedName").toString();
profile.userType = o.value("userType").toString();
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
if (profile.id >= m_nextProfileId)
m_nextProfileId = profile.id + 1;
});
std::sort(m_profiles.begin(), m_profiles.end(),
[](const Profile &a, const Profile &b) { return a.id < b.id; });
int skippedBuilds = 0;
loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) {
CustomBuild build;
build.id = o["id"].toInt();
build.name = o["name"].toString();
build.serverUrl = o["serverUrl"].toString();
build.minecraftVersion = o.value("minecraftVersion").toString();
// Файл мог остаться от версии лаунчера до переименования или быть
// поправлен руками — старый ключ тоже принимаем.
if (build.minecraftVersion.isEmpty())
build.minecraftVersion = o.value("versionId").toString();
build.loader = o.value("loader").toString();
build.loaderVersion = o.value("loaderVersion").toString();
build.resolvedVersionId = o.value("resolvedVersionId").toString();
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
// пустую строку.
if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) {
++skippedBuilds;
return;
}
m_builds.append(build);
if (build.id >= m_nextBuildId)
m_nextBuildId = build.id + 1;
});
if (skippedBuilds > 0) {
m_storageIssues << tr("В %1 пропущены сборки без имени и версии Minecraft: %2")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))
.arg(skippedBuilds);
}
std::sort(m_builds.begin(), m_builds.end(),
[](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; });
}
// Первый запуск после переименования «версий» в «сборки»: перекладываем
// <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;
}
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles()
{
if (!QFile::exists(LauncherPaths::settingsFile()))
saveSettings();
if (!QFile::exists(LauncherPaths::profilesFile()))
saveProfiles();
if (!QFile::exists(LauncherPaths::customBuildsFile()))
saveCustomBuilds();
}
void LauncherBackend::saveProfiles()
{
QJsonArray arr;
for (const auto &p : m_profiles)
arr.append(QJsonObject{{"id", p.id},
{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType},
{"clientToken", p.clientToken},
{"accessToken", p.accessToken},
{"uuid", p.uuid},
{"resolvedName", p.resolvedName},
{"userType", p.userType}});
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
}
void LauncherBackend::saveCustomBuilds()
{
QJsonArray arr;
for (const auto &b : m_builds)
arr.append(QJsonObject{{"id", b.id},
{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId}});
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
}
void LauncherBackend::loadSettings()
{
m_settings = {{QStringLiteral("gameDir"), QString()},
{QStringLiteral("javaPath"), QString()},
// id сборки Java из <root>/java. Пусто — java ищется сама.
{QStringLiteral("javaRuntime"), QString()},
{QStringLiteral("minMemoryMb"), 512},
{QStringLiteral("maxMemoryMb"), 4096},
{QStringLiteral("jvmArgs"), QString()},
{QStringLiteral("windowWidth"), 0},
{QStringLiteral("windowHeight"), 0},
{QStringLiteral("fullscreen"), false},
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0}};
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
m_settings.insert(it.key(), it.value().toVariant());
}
void LauncherBackend::saveSettings()
{
writeJsonFile(LauncherPaths::settingsFile(),
QJsonDocument(QJsonObject::fromVariantMap(m_settings)));
}