Files
minecraft-launcher/launcherbackend.cpp
T
2026-09-01 22:25:23 +03:00

2562 lines
107 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 "msaauthservice.h"
#include "modloaderversionservice.h"
#include "buildswitcher.h"
#include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h"
#include <QDate>
#include <QDateTime>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHash>
#include <QRegularExpression>
#include <QSaveFile>
#include <QSet>
#include <QTimer>
#include <QUrl>
#include <algorithm>
namespace {
const QString kOffline = QStringLiteral("offline");
const QString kElyBy = QStringLiteral("elyby");
const QString kMicrosoft = QStringLiteral("microsoft");
// Токен Minecraft живёт около суток, но обновляем его чуть раньше срока: игра
// проверяет сессию не в первую секунду после старта.
const int kTokenRenewMarginSecs = 120;
// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl
// в settings.json — это нужно, чтобы прогонять установку против локального
// http-сервера, не трогая боевой.
const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com");
// Тип авторизации профиля: всё, что лаунчер не знает, считается офлайном.
QString normalizedAuthType(const QString &value)
{
if (value == kElyBy)
return kElyBy;
if (value == kMicrosoft)
return kMicrosoft;
return kOffline;
}
// Дата сезона в таблице: год нужен, время — нет.
QString formatSeasonDate(const QDate &date)
{
return date.isValid() ? date.toString(QStringLiteral("dd.MM.yyyy")) : QStringLiteral("");
}
int settingInt(const QVariantMap &settings, const QString &key, int fallback)
{
const QVariant value = settings.value(key);
bool ok = false;
const int parsed = value.toInt(&ok);
return ok ? parsed : fallback;
}
} // namespace
LauncherBackend::LauncherBackend(QObject *parent)
: QObject(parent)
, m_auth(new AuthService(this))
, m_msa(new MsaAuthService(this))
, m_launcher(new GameLauncher(this))
, m_manifest(new VersionManifestService(this))
, m_installer(new VersionInstaller(m_manifest, this))
, m_loaderMeta(new ModLoaderVersionService(this))
, m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this))
, m_switcher(new BuildSwitcher(this))
, m_javaMeta(new JavaRuntimeService(this))
, m_javaInstaller(new JavaInstaller(this))
, m_seasonalMeta(new SeasonalBuildService(this))
, m_packDownloader(new SeasonalPackDownloader(this))
{
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
// следующем — если её удалили, она появится снова.
QString storageError;
if (!LauncherPaths::ensureRootExists(&storageError))
m_storageIssues << 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_msa, &MsaAuthService::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
emit gameOutput(commandLine);
emit gameRunningChanged();
});
connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) {
emit gameRunningChanged();
emit gameFinished(exitCode, crashed);
});
connect(m_manifest, &VersionManifestService::versionsChanged,
this, &LauncherBackend::invalidateCatalog);
connect(m_manifest, &VersionManifestService::refreshingChanged,
this, &LauncherBackend::catalogLoadingChanged);
connect(m_javaMeta, &JavaRuntimeService::catalogChanged,
this, &LauncherBackend::invalidateJavaCatalog);
connect(m_javaMeta, &JavaRuntimeService::refreshingChanged,
this, &LauncherBackend::javaCatalogLoadingChanged);
connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(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)));
if (m_season.stage == SeasonStage::Java)
seasonAdvance();
});
connect(m_javaInstaller, &JavaInstaller::failed, this,
[this](const QString &label, const QString &message) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message));
if (m_season.stage == SeasonStage::Java)
seasonFail({});
});
connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label));
if (m_season.stage == SeasonStage::Java)
seasonFail({});
});
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(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));
// У ванильной сезонной сборки этот установщик и есть весь шаг Loader.
// Внутри установки модлоадера он тоже отчитывается — тогда ход
// передаст ModLoaderInstaller, а не он.
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonAdvance();
});
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
const QString &message) {
// Часть файлов всё же появилась — список установленного мог измениться.
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonFail({});
});
connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
emit switchChanged();
emit busyChanged();
});
connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) {
// Активной сборка становится только здесь: провалившееся переключение
// оставляет активной прежнюю.
m_settings.insert(QStringLiteral("activeBuildId"), toBuildId);
saveSettings();
invalidateCatalog();
emit activeBuildChanged();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
const int index = indexOfBuildId(toBuildId);
emit launchProgress(tr("Активна сборка «%1»")
.arg(index >= 0 ? m_builds[index].name : QString()));
// Раскатка пака могла быть и той, что доигралась после перезапуска —
// тогда цепочки установки уже нет, а записать ревизию всё равно надо.
const QJsonObject note = m_switcher->lastPackNote();
if (!note.isEmpty())
recordSeasonalPack(toBuildId, note, m_switcher->lastPackEntries());
if (m_season.stage == SeasonStage::Applying)
seasonFinish();
else if (m_season.stage == SeasonStage::Switching)
seasonAdvance();
});
connect(m_switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) {
invalidateCatalog();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
// Раскатка пака и смена сборки приходят одним сигналом, а
// сказать пользователю надо разное: после сорванного
// переключения .minecraft лежит разобранной, а сорванная
// раскатка означает лишь наполовину обновлённую сборку.
if (m_season.stage == SeasonStage::Applying) {
emit launchError(gameDirIntact
? tr("Файлы сборки не установлены: %1").arg(message)
: tr("Файлы сборки установлены не полностью: %1\n"
"Повторите установку сборки.")
.arg(message));
} else {
emit launchError(gameDirIntact
? tr("Сборка не переключена: %1").arg(message)
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
"осталось разобранным — повторите переключение.")
.arg(message));
}
if (m_season.stage == SeasonStage::Switching
|| m_season.stage == SeasonStage::Applying) {
seasonFail({});
}
});
connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
this, &LauncherBackend::loaderVersionsChanged);
connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged,
this, &LauncherBackend::downloadChanged);
// Установщик лоадера делит панель прогресса и флаг занятости с обычным
// установщиком версий — сигналы у них одни и те же.
connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) {
emit busyChanged();
emit downloadChanged();
emit launchProgress(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));
if (m_season.stage == SeasonStage::Loader)
seasonAdvance();
});
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &label, const QString &message) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchError(tr("%1: %2").arg(label, message));
if (m_season.stage == SeasonStage::Loader)
seasonFail({});
});
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Установка %1 отменена").arg(label));
if (m_season.stage == SeasonStage::Loader)
seasonFail({});
});
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonFail({});
});
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
connect(m_seasonalMeta, &SeasonalBuildService::buildsChanged,
this, &LauncherBackend::invalidateSeasonalCatalog);
connect(m_seasonalMeta, &SeasonalBuildService::refreshingChanged,
this, &LauncherBackend::seasonalCatalogLoadingChanged);
// Статус строки («установлена», «доступно обновление») считается по
// локальным записям — их изменение обновляет и таблицу.
connect(this, &LauncherBackend::customBuildsChanged,
this, &LauncherBackend::invalidateSeasonalCatalog);
connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка файлов сборки «%1»…").arg(label));
});
connect(m_packDownloader, &SeasonalPackDownloader::progressChanged,
this, &LauncherBackend::downloadChanged);
connect(m_packDownloader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
emit downloadChanged();
emit busyChanged();
if (m_season.stage != SeasonStage::Downloading)
return;
m_season.packPath = path;
seasonAdvance();
});
connect(m_packDownloader, &SeasonalPackDownloader::failed, this,
[this](const QString &label, const QString &message) {
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Сборка «%1»: %2").arg(label, message));
if (m_season.stage == SeasonStage::Downloading)
seasonFail({});
});
connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка сборки «%1» отменена").arg(label));
if (m_season.stage == SeasonStage::Downloading)
seasonFail({});
});
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n'));
});
}
}
LauncherBackend::~LauncherBackend() = default;
QStringList LauncherBackend::profileNames() const
{
QStringList names;
for (const auto &p : m_profiles)
names << p.name;
return names;
}
QStringList LauncherBackend::customBuildNames() const
{
QStringList names;
for (const auto &b : m_builds)
names << b.name;
return names;
}
QStringList LauncherBackend::installedVersions() const
{
return VersionLoader::installedVersions(gameDir());
}
bool LauncherBackend::gameRunning() const
{
return m_launcher->isRunning();
}
void LauncherBackend::addProfile(const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
Profile profile;
profile.id = m_nextProfileId++;
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = normalizedAuthType(authType);
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::addCustomBuild(const QString &name,
const QString &serverUrl,
const QString &minecraftVersion)
{
CustomBuild build;
build.id = m_nextBuildId++;
build.name = name;
build.serverUrl = serverUrl;
build.minecraftVersion = minecraftVersion;
m_builds.append(build);
saveCustomBuilds();
emit customBuildsChanged();
maybeInstall(launchVersionIdOf(m_builds.last()));
}
void LauncherBackend::updateProfile(int index,
const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
if (index < 0 || index >= m_profiles.size())
return;
Profile &profile = m_profiles[index];
const QString type = normalizedAuthType(authType);
// Смена логина или пароля обесценивает сохранённый токен Ely.by. У аккаунта
// Microsoft ни того ни другого нет, и переименование профиля не должно
// выкидывать из аккаунта.
if (profile.authType == kElyBy && (profile.login != login || profile.password != password)) {
profile.accessToken.clear();
profile.uuid.clear();
profile.resolvedName.clear();
}
// Смена типа авторизации: чужие учётные данные к новому типу не подходят.
if (profile.authType != type) {
profile.accessToken.clear();
profile.refreshToken.clear();
profile.uuid.clear();
profile.resolvedName.clear();
profile.xuid.clear();
profile.tokenExpiry.clear();
profile.userType.clear();
}
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = type;
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
{
if (index < 0 || index >= m_builds.size())
return;
CustomBuild &build = m_builds[index];
const auto take = [&fields](const char *key, QString &target) {
const auto it = fields.constFind(QLatin1String(key));
if (it == fields.constEnd())
return false;
target = it->toString();
return true;
};
// Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит.
// Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию.
const QString previousMinecraft = build.minecraftVersion;
take("name", build.name);
take("serverUrl", build.serverUrl);
take("minecraftVersion", build.minecraftVersion);
take("loader", build.loader);
take("loaderVersion", build.loaderVersion);
take("resolvedVersionId", build.resolvedVersionId);
take("seasonalId", build.seasonalId);
if (fields.contains(QStringLiteral("seasonalRevision")))
build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt();
if (build.minecraftVersion != previousMinecraft
&& !fields.contains(QStringLiteral("loaderVersion"))) {
build.loaderVersion.clear();
build.resolvedVersionId.clear();
}
saveCustomBuilds();
emit customBuildsChanged();
emit activeBuildChanged(); // имя активной сборки могло измениться
// maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу
// правки, и загрузка стартовала бы почти на каждое действие. Для этого есть
// installCustomBuild().
}
QVariantMap LauncherBackend::profileAt(int index) const
{
if (index < 0 || index >= m_profiles.size())
return {};
const auto &p = m_profiles[index];
return {{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType},
{"resolvedName", p.resolvedName},
{"hasMicrosoftSession", !p.refreshToken.isEmpty()}};
}
QVariantMap LauncherBackend::customBuildAt(int index) const
{
if (index < 0 || index >= m_builds.size())
return {};
const auto &b = m_builds[index];
return {{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision},
{"launchVersionId", launchVersionIdOf(b)}};
}
void LauncherBackend::removeProfile(int index)
{
if (index < 0 || index >= m_profiles.size())
return;
m_profiles.removeAt(index);
saveProfiles();
emit profilesChanged();
}
QVariantMap LauncherBackend::customBuildRemovalInfo(int index) const
{
if (index < 0 || index >= m_builds.size())
return {};
const CustomBuild &build = m_builds[index];
const bool active = m_settings.value(QStringLiteral("activeBuildId")).toInt() == build.id
|| m_builds.size() == 1;
return {{QStringLiteral("name"), build.name},
{QStringLiteral("hasArchive"), m_switcher->hasArchive(build.id)},
{QStringLiteral("active"), active},
{QStringLiteral("lastOne"), m_builds.size() == 1}};
}
void LauncherBackend::removeCustomBuild(int index)
{
if (index < 0 || index >= m_builds.size())
return;
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|| m_loaderInstaller->isRunning()) {
emit launchError(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 if (m_javaInstaller->isRunning())
m_javaInstaller->cancel();
else
m_packDownloader->cancel();
}
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
// показывает его стадии, включая вложенную загрузку файлов версии. Установка
// Java начинается только когда остальные не заняты, поэтому за панель они не спорят.
bool LauncherBackend::downloading() const
{
return m_loaderInstaller->isRunning() || m_installer->isRunning()
|| m_javaInstaller->isRunning() || m_packDownloader->isRunning();
}
// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
bool LauncherBackend::javaDownloadShown() const
{
return m_javaInstaller->isRunning() && !m_installer->isRunning()
&& !m_loaderInstaller->isRunning();
}
// Пак качается отдельным шагом цепочки, когда остальные установщики уже
// отработали, — за панель они не спорят.
bool LauncherBackend::packDownloadShown() const
{
return m_packDownloader->isRunning() && !m_installer->isRunning()
&& !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning();
}
double LauncherBackend::downloadProgress() const
{
if (packDownloadShown())
return m_packDownloader->fraction();
if (javaDownloadShown())
return m_javaInstaller->fraction();
if (!m_loaderInstaller->isRunning())
return m_installer->fraction();
// Внутри установки лоадера файлы качает VersionInstaller — его доля точнее.
return m_installer->isRunning() ? m_installer->fraction() : m_loaderInstaller->fraction();
}
QString LauncherBackend::downloadVersion() const
{
if (packDownloadShown())
return m_packDownloader->label();
if (javaDownloadShown())
return m_javaInstaller->label();
if (m_loaderInstaller->isRunning())
return m_loaderInstaller->label();
return m_installer->versionId();
}
QString LauncherBackend::downloadStatus() const
{
QString stage = m_installer->stage();
QString file = m_installer->currentFile();
if (packDownloadShown()) {
stage = m_packDownloader->stage();
file = m_packDownloader->currentFile();
} else if (javaDownloadShown()) {
stage = m_javaInstaller->stage();
file = m_javaInstaller->currentFile();
} else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
stage = m_loaderInstaller->stage();
file = m_loaderInstaller->currentFile();
}
if (stage.isEmpty())
return file;
if (file.isEmpty())
return stage;
return QStringLiteral("%1 — %2").arg(stage, file);
}
qint64 LauncherBackend::downloadBytesDone() const
{
if (packDownloadShown())
return m_packDownloader->bytesDone();
if (javaDownloadShown())
return m_javaInstaller->bytesDone();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
return m_loaderInstaller->bytesDone();
return m_installer->bytesDone();
}
qint64 LauncherBackend::downloadBytesTotal() const
{
if (packDownloadShown())
return m_packDownloader->bytesTotal();
if (javaDownloadShown())
return m_javaInstaller->bytesTotal();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
return m_loaderInstaller->bytesTotal();
return m_installer->bytesTotal();
}
// ── Модлоадеры ─────────────────────────────────────────────────────────────
QVariantList LauncherBackend::loaderVersions(const QString &loaderKey,
const QString &gameVersion) const
{
const auto loader = loaderFromKey(loaderKey);
if (!loader)
return {};
QVariantList result;
const QList<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::startMicrosoftLogin(int profileIndex)
{
if (!microsoftAvailable()) {
emit microsoftLoginFailed(tr("Эта сборка лаунчера собрана без Qt WebEngine — "
"показать окно входа Microsoft нечем"));
return;
}
// -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать
// это состояние от закрытого окна.
if (profileIndex >= m_profiles.size()) {
emit microsoftLoginFailed(tr("Профиль не найден"));
return;
}
m_msaLoginProfile = profileIndex < 0 ? -2 : profileIndex;
emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl().toString());
}
void LauncherBackend::cancelMicrosoftLogin()
{
m_msaLoginProfile = -1;
}
QVariantMap LauncherBackend::inspectMicrosoftRedirect(const QString &url) const
{
QString code;
QString error;
const bool matched = MsaAuthService::matchRedirect(QUrl(url), &code, &error);
return {{QStringLiteral("matched"), matched},
{QStringLiteral("code"), code},
{QStringLiteral("error"), error}};
}
void LauncherBackend::finishMicrosoftLogin(const QString &code)
{
if (m_msaLoginProfile == -1) {
emit microsoftLoginFailed(tr("Вход в Microsoft уже отменён"));
return;
}
setBusy(true);
m_msa->loginWithCode(code, [this](const AuthResult &result) {
setBusy(false);
const int target = m_msaLoginProfile;
m_msaLoginProfile = -1;
if (target == -1)
return; // окно закрыли, пока шла авторизация
if (!result.ok) {
emit microsoftLoginFailed(result.error);
return;
}
if (target == -2) {
// Профиля ещё нет — заводим его на ник из аккаунта.
Profile profile;
profile.id = m_nextProfileId++;
profile.name = result.playerName;
profile.authType = kMicrosoft;
profile.clientToken = AuthService::generateClientToken();
applyMicrosoftResult(profile, result);
m_profiles.append(profile);
} else if (target < m_profiles.size()) {
Profile &profile = m_profiles[target];
profile.authType = kMicrosoft;
applyMicrosoftResult(profile, result);
} else {
emit microsoftLoginFailed(tr("Профиль был удалён, пока шёл вход"));
return;
}
saveProfiles();
emit profilesChanged();
emit microsoftLoginSucceeded(result.playerName);
});
}
void LauncherBackend::applyMicrosoftResult(Profile &profile, const AuthResult &auth)
{
profile.accessToken = auth.accessToken;
// Microsoft выдаёт новый refresh-токен и при продлении, но если в ответе
// его не было — прежний остаётся рабочим.
if (!auth.refreshToken.isEmpty())
profile.refreshToken = auth.refreshToken;
if (!auth.uuid.isEmpty())
profile.uuid = auth.uuid;
if (!auth.playerName.isEmpty())
profile.resolvedName = auth.playerName;
profile.xuid = auth.xuid;
profile.userType = auth.userType;
profile.tokenExpiry = auth.expiresAt.isValid()
? auth.expiresAt.toUTC().toString(Qt::ISODate)
: QString();
}
void LauncherBackend::beginMicrosoftAuthentication()
{
const Profile &profile = m_profiles[m_pendingProfile];
if (profile.refreshToken.isEmpty()) {
const int index = m_pendingProfile;
failLaunch(tr("Профиль «%1» не подключён к аккаунту Microsoft — войдите в него")
.arg(profile.name));
emit microsoftReloginRequired(index);
return;
}
// Сохранённый токен ещё жив — идём в игру, не дёргая Microsoft.
const QDateTime expiry = QDateTime::fromString(profile.tokenExpiry, Qt::ISODate);
if (!profile.accessToken.isEmpty() && expiry.isValid()
&& QDateTime::currentDateTimeUtc().addSecs(kTokenRenewMarginSecs) < expiry) {
AuthResult stored;
stored.ok = true;
stored.playerName = profile.resolvedName;
stored.uuid = profile.uuid;
stored.accessToken = profile.accessToken;
stored.refreshToken = profile.refreshToken;
stored.clientToken = MsaAuthService::clientId();
stored.xuid = profile.xuid;
stored.userType = profile.userType.isEmpty() ? QStringLiteral("msa") : profile.userType;
stored.expiresAt = expiry;
continueLaunch(stored);
return;
}
m_msa->loginWithRefreshToken(profile.refreshToken, [this](const AuthResult &result) {
if (m_pendingProfile < 0)
return; // запуск отменили, пока шло продление
if (result.licenseMissing) {
failLaunch(result.error);
return;
}
if (!result.ok) {
const int index = m_pendingProfile;
failLaunch(tr("Сессия Microsoft истекла — войдите в аккаунт заново (%1)")
.arg(result.error));
emit microsoftReloginRequired(index);
return;
}
continueLaunch(result);
});
}
void LauncherBackend::beginAuthentication()
{
const Profile &profile = m_profiles[m_pendingProfile];
if (profile.authType == kMicrosoft) {
beginMicrosoftAuthentication();
return;
}
if (profile.authType != kElyBy) {
const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
const AuthResult result = AuthService::offline(nickname);
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
return;
}
// Ely.by работает только вместе с authlib-injector — без него игра пойдёт
// в сессионный сервер Mojang и токен окажется недействительным.
m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(),
[this](const QString &path, const QString &error) {
if (m_pendingProfile < 0)
return; // запуск отменили, пока качался инжектор
if (!error.isEmpty()) {
failLaunch(error);
return;
}
m_pendingInjectorPath = path;
const Profile &pending = m_profiles[m_pendingProfile];
m_auth->loginElyBy(pending.login,
pending.password,
pending.clientToken,
pending.accessToken,
[this](const AuthResult &result) {
if (m_pendingProfile < 0)
return;
if (result.twoFactorRequired) {
setBusy(false);
emit twoFactorRequired(m_profiles[m_pendingProfile].name);
return;
}
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
});
});
}
void LauncherBackend::submitTwoFactorCode(const QString &code)
{
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) {
emit launchError(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 (profile.authType == kMicrosoft && auth.ok) {
applyMicrosoftResult(profile, auth);
saveProfiles();
} else if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
profile.accessToken = auth.accessToken;
profile.clientToken = auth.clientToken;
if (!auth.uuid.isEmpty())
profile.uuid = auth.uuid;
if (!auth.playerName.isEmpty())
profile.resolvedName = auth.playerName;
profile.userType = auth.userType;
saveProfiles();
}
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = m_pendingVersionData.id;
options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName;
if (options.playerName.isEmpty())
options.playerName = profile.login.isEmpty() ? profile.name : profile.login;
options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid;
options.accessToken = auth.accessToken;
options.clientToken = auth.clientToken;
options.userType = auth.userType;
options.xuid = auth.xuid;
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
// окне выбора версии Java.
options.javaPath = managedJavaPath();
if (options.javaPath.isEmpty()) {
if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty())
emit launchProgress(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()
|| m_packDownloader->isRunning();
}
bool LauncherBackend::microsoftAvailable() const
{
#ifdef LAUNCHER_HAS_WEBENGINE
return true;
#else
return false;
#endif
}
void LauncherBackend::setBusy(bool busy)
{
if (m_busy == busy)
return;
m_busy = busy;
emit busyChanged();
}
QString LauncherBackend::gameDir() const
{
const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed();
return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir()
: QDir::fromNativeSeparators(configured);
}
QVariantMap LauncherBackend::settings() const
{
QVariantMap result = m_settings;
result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir());
return result;
}
void LauncherBackend::updateSettings(const QVariantMap &values)
{
const QString previousGameDir = gameDir();
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();
}
// ── Сезонные сборки ────────────────────────────────────────────────────────
QUrl LauncherBackend::seasonalBaseUrl() const
{
const QString configured =
m_settings.value(QStringLiteral("seasonalBaseUrl")).toString().trimmed();
QUrl url(configured.isEmpty() ? kSeasonalBaseUrl : configured);
// Хвостовой слэш превратил бы адрес каталога в «…//index.json».
while (url.path().endsWith(u'/'))
url.setPath(url.path().chopped(1));
return url;
}
void LauncherBackend::invalidateSeasonalCatalog()
{
m_seasonalCatalogDirty = true;
emit seasonalCatalogChanged();
}
int LauncherBackend::indexOfSeasonalBuild(const QString &seasonalId) const
{
if (seasonalId.isEmpty())
return -1;
for (int i = 0; i < m_builds.size(); ++i) {
if (m_builds[i].seasonalId == seasonalId)
return i;
}
return -1;
}
void LauncherBackend::rebuildSeasonalCatalog() const
{
m_seasonalCatalog.clear();
const QList<SeasonalBuildEntry> builds = m_seasonalMeta->builds();
m_seasonalCatalog.reserve(builds.size());
for (const SeasonalBuildEntry &entry : builds) {
int installedRevision = 0;
bool installed = false;
for (const CustomBuild &build : m_builds) {
if (build.seasonalId != entry.id)
continue;
installed = true;
installedRevision = build.seasonalRevision;
break;
}
const bool updateAvailable = installed && installedRevision < entry.revision;
QString status;
if (!installed)
status = tr("Не установлена");
else if (updateAvailable)
status = tr("Есть обновление");
else
status = tr("Установлена");
const auto loader = loaderFromKey(entry.loader);
m_seasonalCatalog.append(QVariantMap{
{QStringLiteral("id"), entry.id},
{QStringLiteral("name"), entry.name},
{QStringLiteral("minecraftVersion"), entry.minecraftVersion},
{QStringLiteral("loader"), entry.loader},
{QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : tr("Ванильная")},
{QStringLiteral("loaderVersion"), entry.loaderVersion},
{QStringLiteral("modCount"), entry.modCount},
{QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)},
{QStringLiteral("seasonEnd"), formatSeasonDate(entry.seasonEnd)},
{QStringLiteral("sizeBytes"), entry.archiveSize},
{QStringLiteral("revision"), entry.revision},
{QStringLiteral("description"), entry.description},
{QStringLiteral("serverUrl"), entry.serverUrl},
{QStringLiteral("installed"), installed},
{QStringLiteral("installedRevision"), installedRevision},
{QStringLiteral("updateAvailable"), updateAvailable},
{QStringLiteral("status"), status}});
}
m_seasonalCatalogDirty = false;
}
QVariantList LauncherBackend::seasonalCatalog() const
{
if (m_seasonalCatalogDirty)
rebuildSeasonalCatalog();
return m_seasonalCatalog;
}
bool LauncherBackend::seasonalCatalogLoading() const
{
return m_seasonalMeta->isRefreshing();
}
QString LauncherBackend::seasonalCatalogError() const
{
return m_seasonalMeta->lastError();
}
bool LauncherBackend::seasonalInstalling() const
{
return m_season.stage != SeasonStage::Idle;
}
void LauncherBackend::refreshSeasonalCatalog(bool force)
{
// Адрес мог смениться в settings.json между запусками окна.
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
m_seasonalMeta->ensureLoaded(nullptr, force);
}
// ── Установка сезонной сборки ──────────────────────────────────────────────
void LauncherBackend::installSeasonalBuild(const QString &seasonalId)
{
if (m_season.stage != SeasonStage::Idle) {
emit launchError(tr("Установка сборки уже идёт"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
return;
}
if (busy()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
const auto entry = m_seasonalMeta->find(seasonalId);
if (!entry) {
emit launchError(tr("Сборка «%1» не найдена в списке — обновите его").arg(seasonalId));
return;
}
m_season = {};
m_season.entry = *entry;
// Сборка с таким seasonalId уже заведена — обновляем её описание под новую
// ревизию, а не плодим вторую запись.
int index = indexOfSeasonalBuild(seasonalId);
if (index < 0) {
CustomBuild build;
build.id = m_nextBuildId++;
build.name = entry->name;
build.serverUrl = entry->serverUrl;
build.minecraftVersion = entry->minecraftVersion;
build.loader = entry->loader;
build.loaderVersion = entry->loaderVersion;
build.seasonalId = entry->id;
// Ревизия появится только когда файлы лягут на диск: иначе оборванная
// установка выглядела бы завершённой.
m_builds.append(build);
index = m_builds.size() - 1;
saveCustomBuilds();
emit customBuildsChanged();
// maybeInstall() здесь намеренно нет: версию и модлоадер поставит шаг
// цепочки, а параллельная загрузка только мешала бы ему.
} else {
updateCustomBuild(index,
{{QStringLiteral("name"), entry->name},
{QStringLiteral("serverUrl"), entry->serverUrl},
{QStringLiteral("minecraftVersion"), entry->minecraftVersion},
{QStringLiteral("loader"), entry->loader},
{QStringLiteral("loaderVersion"), entry->loaderVersion}});
}
m_season.buildId = m_builds[index].id;
setBusy(true);
emit launchProgress(tr("Установка сборки «%1»…").arg(entry->name));
// Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его
// вызова seasonalInstalling() ещё вернул бы false.
seasonAdvance();
emit seasonalInstallingChanged();
}
void LauncherBackend::seasonAdvance()
{
if (indexOfBuildId(m_season.buildId) < 0) {
seasonFail(tr("запись сборки исчезла"));
return;
}
// Каждый шаг либо начинает свою асинхронную работу и обрывает цепочку до
// следующего сигнала, либо уступает следующему — отсюда провалы между
// ветками.
switch (m_season.stage) {
case SeasonStage::Idle:
if (seasonBeginSwitch())
return;
[[fallthrough]];
case SeasonStage::Switching:
if (seasonBeginJava())
return;
[[fallthrough]];
case SeasonStage::Java:
if (seasonBeginLoader())
return;
[[fallthrough]];
case SeasonStage::Loader:
if (seasonBeginDownload())
return;
[[fallthrough]];
case SeasonStage::Downloading:
if (seasonBeginApply())
return;
[[fallthrough]];
case SeasonStage::Applying:
seasonFinish();
return;
}
}
bool LauncherBackend::seasonBeginSwitch()
{
const int index = indexOfBuildId(m_season.buildId);
const int current = activeBuildIndex();
if (current == index)
return false;
// Пак ложится на то, что сейчас разложено в .minecraft, поэтому сборка
// должна стать активной до всего остального. Содержимое прежней уезжает в
// её архив обычным путём.
m_season.stage = SeasonStage::Switching;
emit launchProgress(tr("Смена сборки на «%1»…").arg(m_builds[index].name));
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
current >= 0 ? m_builds[current].name : QString(),
m_builds[index].id,
m_builds[index].name,
gameDir());
emit switchChanged();
emit busyChanged();
return true;
}
bool LauncherBackend::seasonBeginJava()
{
// Сервер может назвать нужную Java прямо; иначе спрашиваем таблицу.
const int major = m_season.entry.javaMajor > 0
? m_season.entry.javaMajor
: JavaRequirement::minimumFor(m_season.entry.minecraftVersion);
if (major <= 0)
return false;
// Java уже есть — своя, системная или скачанная раньше.
QString error;
if (!JavaLocator::select(gameDir(), major, QString(), &error).isEmpty())
return false;
m_season.stage = SeasonStage::Java;
emit launchProgress(tr("Для сборки нужна Java %1 — устанавливаем").arg(major));
// Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать.
m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) {
if (m_season.stage != SeasonStage::Java)
return;
if (!ok) {
seasonFail(warning.isEmpty() ? tr("не удалось получить каталог Java") : warning);
return;
}
const auto entry = m_javaMeta->bestFor(major);
if (!entry) {
seasonFail(tr("в каталоге нет сборки Java %1 для этой системы").arg(major));
return;
}
m_javaInstaller->install(*entry);
});
return true;
}
bool LauncherBackend::seasonBeginLoader()
{
const int index = indexOfBuildId(m_season.buildId);
const CustomBuild &build = m_builds[index];
const auto loader = loaderFromKey(build.loader);
// Ванильная сборка: ставить надо только саму версию игры.
if (!loader) {
if (checkInstallation(index).isEmpty())
return false;
m_season.stage = SeasonStage::Loader;
m_installer->install(gameDir(), build.minecraftVersion);
return true;
}
// Профиль лоадера уже собран и укомплектован — второй раз не ставим.
if (!build.resolvedVersionId.isEmpty() && checkInstallation(index).isEmpty())
return false;
m_season.stage = SeasonStage::Loader;
m_loaderInstallBuildId = build.id;
m_loaderInstaller->install(gameDir(),
*loader,
build.minecraftVersion,
build.loaderVersion,
managedJavaPath().isEmpty()
? m_settings.value(QStringLiteral("javaPath")).toString()
: managedJavaPath());
return true;
}
bool LauncherBackend::seasonBeginDownload()
{
const SeasonalBuildEntry &entry = m_season.entry;
// Точка в начале имени: файл служебный, и BuildSwitcher по этому признаку
// не примет его за архив сборки и не сотрёт при следующем переключении.
const QString path = LauncherPaths::buildDir(m_season.buildId)
+ QStringLiteral("/.pack-r") + QString::number(entry.revision)
+ QStringLiteral(".zip");
m_season.stage = SeasonStage::Downloading;
m_packDownloader->download(entry.archiveUrl,
path,
entry.archiveSha256,
entry.archiveSize,
entry.name);
return true;
}
bool LauncherBackend::seasonBeginApply()
{
if (m_season.packPath.isEmpty()) {
seasonFail(tr("файлы сборки не скачаны"));
return true;
}
// Под удаление идёт весь список прошлой ревизии, а не разница с новой:
// распаковка всё равно кладёт каждый файл пака заново, поэтому разница
// сэкономила бы лишь перезапись того, что и так перезаписывается, а вот
// ушедшие из сборки моды так убираются наверняка. Миров и скриншотов в
// списке нет — их пак не приносил.
const QStringList remove = seasonalPackEntries(m_season.buildId);
const QJsonObject note{{QStringLiteral("seasonalId"), m_season.entry.id},
{QStringLiteral("revision"), m_season.entry.revision},
{QStringLiteral("sha256"), m_season.entry.archiveSha256}};
const int index = indexOfBuildId(m_season.buildId);
m_season.stage = SeasonStage::Applying;
m_switcher->applyPack(m_season.buildId,
m_builds[index].name,
m_season.packPath,
remove,
note,
gameDir());
emit switchChanged();
emit busyChanged();
return true;
}
void LauncherBackend::seasonFinish()
{
const int index = indexOfBuildId(m_season.buildId);
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
const QString seasonalId = m_season.entry.id;
m_season = {};
setBusy(false);
invalidateSeasonalCatalog();
emit seasonalInstallingChanged();
emit launchProgress(tr("Сборка «%1» установлена").arg(name));
emit seasonalInstallFinished(seasonalId, name);
}
void LauncherBackend::seasonFail(const QString &message)
{
const int index = indexOfBuildId(m_season.buildId);
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
// Недокачанный или нераскатанный пак не должен занимать место: цепочку
// всегда можно начать заново с чистого листа.
if (!m_season.packPath.isEmpty())
QFile::remove(m_season.packPath);
m_season = {};
setBusy(false);
emit seasonalInstallingChanged();
// Пустое message означает, что о причине уже сообщил тот, кто её нашёл, —
// второй раз ту же ошибку не показываем.
emit launchError(message.isEmpty()
? tr("Установка сборки «%1» прервана").arg(name)
: tr("Установка сборки «%1» прервана: %2").arg(name, message));
}
void LauncherBackend::cancelSeasonalInstall()
{
switch (m_season.stage) {
case SeasonStage::Idle:
return;
case SeasonStage::Java:
m_javaInstaller->cancel();
return;
case SeasonStage::Loader:
if (m_loaderInstaller->isRunning())
m_loaderInstaller->cancel();
else
m_installer->cancel();
return;
case SeasonStage::Downloading:
m_packDownloader->cancel();
return;
case SeasonStage::Switching:
case SeasonStage::Applying:
// На этих шагах содержимое .minecraft уже разобрано: отступать некуда,
// операцию нужно довести до конца.
emit launchError(tr("Сейчас переносятся файлы — отменить нельзя"));
return;
}
}
// ── Состояние установленной сезонной сборки ────────────────────────────────
QStringList LauncherBackend::seasonalPackEntries(int buildId) const
{
QFile file(LauncherPaths::seasonalStateFile(buildId));
if (!file.open(QIODevice::ReadOnly))
return {};
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QStringList entries;
const QJsonArray array = root.value(QStringLiteral("entries")).toArray();
entries.reserve(array.size());
for (const QJsonValue &value : array)
entries << value.toString();
return entries;
}
void LauncherBackend::recordSeasonalPack(int buildId,
const QJsonObject &note,
const QStringList &entries)
{
const QString seasonalId = note.value(QStringLiteral("seasonalId")).toString();
const int revision = note.value(QStringLiteral("revision")).toInt();
if (seasonalId.isEmpty() || revision <= 0)
return;
const QJsonObject state{{QStringLiteral("seasonalId"), seasonalId},
{QStringLiteral("revision"), revision},
{QStringLiteral("sha256"),
note.value(QStringLiteral("sha256")).toString()},
{QStringLiteral("installedAt"),
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)},
{QStringLiteral("entries"), QJsonArray::fromStringList(entries)}};
// Не через writeJsonFile: файл лежит в папке сборки, а не в корне лаунчера,
// и его отсутствие не мешает игре — ошибку достаточно показать.
QDir().mkpath(LauncherPaths::buildDir(buildId));
QSaveFile file(LauncherPaths::seasonalStateFile(buildId));
if (file.open(QIODevice::WriteOnly)) {
const QByteArray payload = QJsonDocument(state).toJson(QJsonDocument::Indented);
if (file.write(payload) == payload.size())
file.commit();
else
file.cancelWriting();
}
const int index = indexOfBuildId(buildId);
if (index < 0)
return;
// Ревизия проставляется только здесь: до этого момента файлы сборки на
// диске не лежали.
updateCustomBuild(index,
{{QStringLiteral("seasonalId"), seasonalId},
{QStringLiteral("seasonalRevision"), revision}});
}
void LauncherBackend::openMinecraftFolder()
{
const QString modsDir = gameDir() + QStringLiteral("/mods");
QDir().mkpath(modsDir);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir)))
emit launchError(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();
profile.refreshToken = o.value("refreshToken").toString();
profile.xuid = o.value("xuid").toString();
profile.tokenExpiry = o.value("tokenExpiry").toString();
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
if (profile.id >= m_nextProfileId)
m_nextProfileId = profile.id + 1;
});
std::sort(m_profiles.begin(), m_profiles.end(),
[](const Profile &a, const Profile &b) { return a.id < b.id; });
int skippedBuilds = 0;
loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) {
CustomBuild build;
build.id = o["id"].toInt();
build.name = o["name"].toString();
build.serverUrl = o["serverUrl"].toString();
build.minecraftVersion = o.value("minecraftVersion").toString();
// Файл мог остаться от версии лаунчера до переименования или быть
// поправлен руками — старый ключ тоже принимаем.
if (build.minecraftVersion.isEmpty())
build.minecraftVersion = o.value("versionId").toString();
build.loader = o.value("loader").toString();
build.loaderVersion = o.value("loaderVersion").toString();
build.resolvedVersionId = o.value("resolvedVersionId").toString();
build.seasonalId = o.value("seasonalId").toString();
build.seasonalRevision = o.value("seasonalRevision").toInt();
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
// пустую строку.
if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) {
++skippedBuilds;
return;
}
m_builds.append(build);
if (build.id >= m_nextBuildId)
m_nextBuildId = build.id + 1;
});
if (skippedBuilds > 0) {
m_storageIssues << 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},
{"refreshToken", p.refreshToken},
{"xuid", p.xuid},
{"tokenExpiry", p.tokenExpiry}});
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
}
void LauncherBackend::saveCustomBuilds()
{
QJsonArray arr;
for (const auto &b : m_builds)
arr.append(QJsonObject{{"id", b.id},
{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision}});
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
}
void LauncherBackend::loadSettings()
{
m_settings = {{QStringLiteral("gameDir"), QString()},
{QStringLiteral("javaPath"), QString()},
// id сборки Java из <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},
// Сервер готовых сборок. В интерфейс не вынесен: менять его
// приходится только при отладке против локального сервера.
{QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}};
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
m_settings.insert(it.key(), it.value().toVariant());
}
void LauncherBackend::saveSettings()
{
writeJsonFile(LauncherPaths::settingsFile(),
QJsonDocument(QJsonObject::fromVariantMap(m_settings)));
}