Files
minecraft-launcher/launcherbackend.cpp
T

2572 lines
105 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 "localization.h"
#include "authservice.h"
#include "gamelauncher.h"
#include "javainstaller.h"
#include "javalocator.h"
#include "javaruntime.h"
#include "javaruntimeservice.h"
#include "launcherpaths.h"
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include "modloaderinstaller.h"
#include "msaauthservice.h"
#include "modloaderversionservice.h"
#include "buildswitcher.h"
#include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h"
#include <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 << Loc::text("storage.error.launcherDirFailed").arg(storageError);
loadSettings();
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
// создаст пустой customBuilds.json поверх старых данных.
migrateLegacyBuildsFile();
loadData();
ensureDataFiles();
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
// предлагаем пользователю переключиться заново: архив прежней сборки уже
// записан целиком, а вот повторное переключение сначала заархивировало бы
// поверх него нынешнее — наполовину разобранное — содержимое .minecraft.
const QString interrupted = m_switcher->interruptedSwitchWarning();
if (!interrupted.isEmpty()) {
m_storageIssues << interrupted;
QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); });
}
connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress);
connect(m_msa, &MsaAuthService::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
emit gameOutput(commandLine);
emit gameRunningChanged();
});
connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) {
emit gameRunningChanged();
emit gameFinished(exitCode, crashed);
});
connect(m_manifest, &VersionManifestService::versionsChanged,
this, &LauncherBackend::invalidateCatalog);
connect(m_manifest, &VersionManifestService::refreshingChanged,
this, &LauncherBackend::catalogLoadingChanged);
connect(m_javaMeta, &JavaRuntimeService::catalogChanged,
this, &LauncherBackend::invalidateJavaCatalog);
connect(m_javaMeta, &JavaRuntimeService::refreshingChanged,
this, &LauncherBackend::javaCatalogLoadingChanged);
connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(Loc::text("java.progress.downloading").arg(label));
});
connect(m_javaInstaller, &JavaInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
connect(m_javaInstaller, &JavaInstaller::finished, this,
[this](const QString &runtimeId, const QString &javaPath) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit javaRuntimeInstalled(runtimeId);
emit launchProgress(Loc::text("java.status.installed")
.arg(QDir::toNativeSeparators(javaPath)));
if (m_season.stage == SeasonStage::Java)
seasonAdvance();
});
connect(m_javaInstaller, &JavaInstaller::failed, this,
[this](const QString &label, const QString &message) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit launchError(Loc::text("java.error.installFailed").arg(label, message));
if (m_season.stage == SeasonStage::Java)
seasonFail({});
});
connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit launchProgress(Loc::text("java.status.downloadCancelled").arg(label));
if (m_season.stage == SeasonStage::Java)
seasonFail({});
});
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(Loc::text("version.progress.downloading").arg(id));
});
connect(m_installer, &VersionInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
// Порядок важен: на installedVersionsChanged интерфейс перечитывает каталог,
// и устаревший кэш вернулся бы наружу.
connect(m_installer, &VersionInstaller::finished, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(Loc::text("version.status.downloaded").arg(id));
// У ванильной сезонной сборки этот установщик и есть весь шаг Loader.
// Внутри установки модлоадера он тоже отчитывается — тогда ход
// передаст ModLoaderInstaller, а не он.
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonAdvance();
});
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
const QString &message) {
// Часть файлов всё же появилась — список установленного мог измениться.
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchError(Loc::text("version.error.downloadFailed").arg(id, message));
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonFail({});
});
connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
emit switchChanged();
emit busyChanged();
});
connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) {
// Активной сборка становится только здесь: провалившееся переключение
// оставляет активной прежнюю.
m_settings.insert(QStringLiteral("activeBuildId"), toBuildId);
saveSettings();
invalidateCatalog();
emit activeBuildChanged();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
const int index = indexOfBuildId(toBuildId);
emit launchProgress(Loc::text("build.status.active")
.arg(index >= 0 ? m_builds[index].name : QString()));
// Раскатка пака могла быть и той, что доигралась после перезапуска —
// тогда цепочки установки уже нет, а записать ревизию всё равно надо.
const QJsonObject note = m_switcher->lastPackNote();
if (!note.isEmpty())
recordSeasonalPack(toBuildId, note, m_switcher->lastPackEntries());
if (m_season.stage == SeasonStage::Applying)
seasonFinish();
else if (m_season.stage == SeasonStage::Switching)
seasonAdvance();
});
connect(m_switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) {
invalidateCatalog();
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
// Раскатка пака и смена сборки приходят одним сигналом, а
// сказать пользователю надо разное: после сорванного
// переключения .minecraft лежит разобранной, а сорванная
// раскатка означает лишь наполовину обновлённую сборку.
if (m_season.stage == SeasonStage::Applying) {
emit launchError(gameDirIntact
? Loc::text("build.error.filesNotInstalled").arg(message)
: Loc::text("build.error.filesIncomplete")
.arg(message));
} else {
emit launchError(gameDirIntact
? Loc::text("build.error.switchFailed").arg(message)
: Loc::text("build.error.switchFailedDirty")
.arg(message));
}
if (m_season.stage == SeasonStage::Switching
|| m_season.stage == SeasonStage::Applying) {
seasonFail({});
}
});
connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
this, &LauncherBackend::loaderVersionsChanged);
connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged,
this, &LauncherBackend::downloadChanged);
// Установщик лоадера делит панель прогресса и флаг занятости с обычным
// установщиком версий — сигналы у них одни и те же.
connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) {
emit busyChanged();
emit downloadChanged();
emit launchProgress(Loc::text("loader.progress.installingShort").arg(label));
});
connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
connect(m_loaderInstaller, &ModLoaderInstaller::log, this, &LauncherBackend::gameOutput);
connect(m_loaderInstaller, &ModLoaderInstaller::finished, this,
[this](const QString &loader, const QString &gameVersion,
const QString &loaderVersion, const QString &producedVersionId) {
const int index = indexOfBuildId(m_loaderInstallBuildId);
m_loaderInstallBuildId = -1;
if (index >= 0) {
updateCustomBuild(index,
{{QStringLiteral("loader"), loader},
{QStringLiteral("loaderVersion"), loaderVersion},
{QStringLiteral("resolvedVersionId"), producedVersionId}});
}
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchProgress(Loc::text("loader.status.done")
.arg(producedVersionId, gameVersion));
if (m_season.stage == SeasonStage::Loader)
seasonAdvance();
});
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &label, const QString &message) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchError(Loc::text("common.status.pair").arg(label, message));
if (m_season.stage == SeasonStage::Loader)
seasonFail({});
});
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
m_loaderInstallBuildId = -1;
invalidateCatalog();
emit installedVersionsChanged();
emit busyChanged();
emit downloadChanged();
emit launchProgress(Loc::text("loader.status.installCancelled").arg(label));
if (m_season.stage == SeasonStage::Loader)
seasonFail({});
});
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(Loc::text("version.status.downloadCancelled").arg(id));
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonFail({});
});
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
connect(m_seasonalMeta, &SeasonalBuildService::buildsChanged,
this, &LauncherBackend::invalidateSeasonalCatalog);
connect(m_seasonalMeta, &SeasonalBuildService::refreshingChanged,
this, &LauncherBackend::seasonalCatalogLoadingChanged);
// Статус строки («установлена», «доступно обновление») считается по
// локальным записям — их изменение обновляет и таблицу.
connect(this, &LauncherBackend::customBuildsChanged,
this, &LauncherBackend::invalidateSeasonalCatalog);
connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(Loc::text("seasonal.progress.downloadingBuild").arg(label));
});
connect(m_packDownloader, &SeasonalPackDownloader::progressChanged,
this, &LauncherBackend::downloadChanged);
connect(m_packDownloader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
emit downloadChanged();
emit busyChanged();
if (m_season.stage != SeasonStage::Downloading)
return;
m_season.packPath = path;
seasonAdvance();
});
connect(m_packDownloader, &SeasonalPackDownloader::failed, this,
[this](const QString &label, const QString &message) {
emit downloadChanged();
emit busyChanged();
emit launchError(Loc::text("build.status.named").arg(label, message));
if (m_season.stage == SeasonStage::Downloading)
seasonFail({});
});
connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(Loc::text("seasonal.status.downloadCancelled").arg(label));
if (m_season.stage == SeasonStage::Downloading)
seasonFail({});
});
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n'));
});
}
}
LauncherBackend::~LauncherBackend() = default;
QStringList LauncherBackend::profileNames() const
{
QStringList names;
for (const auto &p : m_profiles)
names << p.name;
return names;
}
QStringList LauncherBackend::customBuildNames() const
{
QStringList names;
for (const auto &b : m_builds)
names << b.name;
return names;
}
QStringList LauncherBackend::installedVersions() const
{
return VersionLoader::installedVersions(gameDir());
}
bool LauncherBackend::gameRunning() const
{
return m_launcher->isRunning();
}
void LauncherBackend::addProfile(const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
Profile profile;
profile.id = m_nextProfileId++;
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = normalizedAuthType(authType);
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::addCustomBuild(const QString &name,
const QString &serverUrl,
const QString &minecraftVersion)
{
CustomBuild build;
build.id = m_nextBuildId++;
build.name = name;
build.serverUrl = serverUrl;
build.minecraftVersion = minecraftVersion;
m_builds.append(build);
saveCustomBuilds();
emit customBuildsChanged();
maybeInstall(launchVersionIdOf(m_builds.last()));
}
void LauncherBackend::updateProfile(int index,
const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
if (index < 0 || index >= m_profiles.size())
return;
Profile &profile = m_profiles[index];
const QString type = normalizedAuthType(authType);
// Смена логина или пароля обесценивает сохранённый токен Ely.by. У аккаунта
// Microsoft ни того ни другого нет, и переименование профиля не должно
// выкидывать из аккаунта.
if (profile.authType == kElyBy && (profile.login != login || profile.password != password)) {
profile.accessToken.clear();
profile.uuid.clear();
profile.resolvedName.clear();
}
// Смена типа авторизации: чужие учётные данные к новому типу не подходят.
if (profile.authType != type) {
profile.accessToken.clear();
profile.refreshToken.clear();
profile.uuid.clear();
profile.resolvedName.clear();
profile.xuid.clear();
profile.tokenExpiry.clear();
profile.userType.clear();
}
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = type;
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
{
if (index < 0 || index >= m_builds.size())
return;
CustomBuild &build = m_builds[index];
const auto take = [&fields](const char *key, QString &target) {
const auto it = fields.constFind(QLatin1String(key));
if (it == fields.constEnd())
return false;
target = it->toString();
return true;
};
// Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит.
// Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию.
const QString previousMinecraft = build.minecraftVersion;
take("name", build.name);
take("serverUrl", build.serverUrl);
take("minecraftVersion", build.minecraftVersion);
take("loader", build.loader);
take("loaderVersion", build.loaderVersion);
take("resolvedVersionId", build.resolvedVersionId);
take("seasonalId", build.seasonalId);
if (fields.contains(QStringLiteral("seasonalRevision")))
build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt();
if (build.minecraftVersion != previousMinecraft
&& !fields.contains(QStringLiteral("loaderVersion"))) {
build.loaderVersion.clear();
build.resolvedVersionId.clear();
}
saveCustomBuilds();
emit customBuildsChanged();
emit activeBuildChanged(); // имя активной сборки могло измениться
// maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу
// правки, и загрузка стартовала бы почти на каждое действие. Для этого есть
// installCustomBuild().
}
QVariantMap LauncherBackend::profileAt(int index) const
{
if (index < 0 || index >= m_profiles.size())
return {};
const auto &p = m_profiles[index];
return {{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType},
{"resolvedName", p.resolvedName},
{"hasMicrosoftSession", !p.refreshToken.isEmpty()}};
}
QVariantMap LauncherBackend::customBuildAt(int index) const
{
if (index < 0 || index >= m_builds.size())
return {};
const auto &b = m_builds[index];
return {{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision},
{"launchVersionId", launchVersionIdOf(b)}};
}
void LauncherBackend::removeProfile(int index)
{
if (index < 0 || index >= m_profiles.size())
return;
m_profiles.removeAt(index);
saveProfiles();
emit profilesChanged();
}
QVariantMap LauncherBackend::customBuildRemovalInfo(int index) const
{
if (index < 0 || index >= m_builds.size())
return {};
const CustomBuild &build = m_builds[index];
const bool active = m_settings.value(QStringLiteral("activeBuildId")).toInt() == build.id
|| m_builds.size() == 1;
return {{QStringLiteral("name"), build.name},
{QStringLiteral("hasArchive"), m_switcher->hasArchive(build.id)},
{QStringLiteral("active"), active},
{QStringLiteral("lastOne"), m_builds.size() == 1}};
}
void LauncherBackend::removeCustomBuild(int index)
{
if (index < 0 || index >= m_builds.size())
return;
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|| m_loaderInstaller->isRunning()) {
emit launchError(Loc::text("common.error.busyOperation"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(Loc::text("common.error.closeGameFirst"));
return;
}
const CustomBuild removed = m_builds.takeAt(index);
const bool wasActive = m_settings.value(QStringLiteral("activeBuildId")).toInt() == removed.id
|| m_builds.isEmpty();
saveCustomBuilds();
// Раньше удалялась только запись, а архив сборки оставался лежать в
// galeonLauncher/builds вместе со ссылкой на него в index.json.
if (!m_switcher->forgetBuild(removed.id)) {
emit launchError(Loc::text("build.warning.archiveNotRemoved")
.arg(removed.name,
QDir::toNativeSeparators(LauncherPaths::buildStorageDir())));
}
emit customBuildsChanged();
emit activeBuildChanged();
if (!wasActive)
return;
// Удалили активную сборку: в .minecraft лежит её содержимое, а активной
// становится другая. Оставить как есть нельзя — при следующем переключении
// чужое содержимое уехало бы в архив новой сборки поверх её собственного.
m_settings.remove(QStringLiteral("activeBuildId"));
saveSettings();
if (m_builds.isEmpty()) {
emit activeBuildChanged();
return;
}
const CustomBuild &next = m_builds.first();
emit launchProgress(Loc::text("build.progress.activating").arg(next.name));
// from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе
// с ней. Дальше обычная очистка и распаковка новой активной сборки.
m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir());
emit switchChanged();
emit busyChanged();
}
int LauncherBackend::indexOfBuildId(int buildId) const
{
for (int i = 0; i < m_builds.size(); ++i) {
if (m_builds[i].id == buildId)
return i;
}
return -1;
}
int LauncherBackend::activeBuildIndex() const
{
if (m_builds.isEmpty())
return -1;
const int index = indexOfBuildId(m_settings.value(QStringLiteral("activeBuildId")).toInt());
// Настройка могла указывать на удалённую сборку — тогда активна первая.
return index >= 0 ? index : 0;
}
void LauncherBackend::setActiveBuildIndex(int index)
{
const int current = activeBuildIndex();
if (index < 0 || index >= m_builds.size() || index == current)
return;
if (m_switcher->isRunning()) {
emit launchError(Loc::text("common.error.switchInProgress"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(Loc::text("build.error.closeGameFilesMoving"));
return;
}
if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) {
emit launchError(Loc::text("common.error.busyOperation"));
return;
}
// Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в
// её архив, и только потом на его место разворачивается выбранная.
const CustomBuild &target = m_builds[index];
emit launchProgress(Loc::text("build.progress.switchingTo").arg(target.name));
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
current >= 0 ? m_builds[current].name : QString(),
target.id,
target.name,
gameDir());
emit switchChanged();
emit busyChanged();
}
QString LauncherBackend::activeBuildName() const
{
const int index = activeBuildIndex();
return index < 0 ? QString() : m_builds[index].name;
}
void LauncherBackend::installCustomBuild(int index)
{
// Установка сборки — это установка её версии игры и, если выбран,
// модлоадера: они ставятся одной цепочкой.
installLoaderForBuild(index);
}
QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const
{
// resolvedVersionId появляется после установки модлоадера. Пользователь мог
// назвать сборку как угодно — тогда версией считаем само имя: так работали
// записи, созданные до появления каталога версий.
if (!build.resolvedVersionId.isEmpty())
return build.resolvedVersionId;
return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion;
}
QStringList LauncherBackend::checkInstallation(int buildIndex) const
{
if (buildIndex < 0 || buildIndex >= m_builds.size())
return {Loc::text("common.status.noBuildSelected")};
const CustomBuild &entry = m_builds[buildIndex];
const QString id = launchVersionIdOf(entry);
if (id.trimmed().isEmpty())
return {Loc::text("build.error.noMinecraftVersion")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))};
QString error;
const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error);
if (!version.isValid()) {
return {error.isEmpty()
? Loc::text("build.error.versionNotInstalled")
.arg(id,
entry.name,
QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions")))
: error};
}
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = id;
return GameLauncher::missingFiles(options, version);
}
// ── Каталог версий ─────────────────────────────────────────────────────────
void LauncherBackend::invalidateCatalog()
{
m_catalogDirty = true;
emit versionCatalogChanged();
}
bool LauncherBackend::catalogLoading() const
{
return m_manifest->isRefreshing();
}
void LauncherBackend::refreshVersionCatalog(bool force)
{
// Список установленного мог поменяться и без нас (папку добавили руками).
invalidateCatalog();
m_manifest->ensureLoaded([this](bool ok, const QString &warning) {
if (!ok && !warning.isEmpty())
emit launchError(warning);
}, force);
}
bool LauncherBackend::isVersionInstalled(const QString &versionId) const
{
if (versionId.trimmed().isEmpty())
return false;
return VersionLoader::installedVersions(gameDir()).contains(versionId);
}
QVariantMap LauncherBackend::versionRemovalInfo(const QString &versionId) const
{
const QString id = versionId.trimmed();
if (id.isEmpty())
return {};
const qint64 bytes = VersionLoader::installedSize(gameDir(), id);
// Сборки не правим при удалении — ссылка на неустановленную версию это
// рабочее состояние. Но перечислить, кого заденет, надо.
QStringList builds;
for (const CustomBuild &build : m_builds) {
if (build.minecraftVersion == id || build.resolvedVersionId == id
|| launchVersionIdOf(build) == id) {
builds << build.name;
}
}
return {{QStringLiteral("id"), id},
{QStringLiteral("installed"), isVersionInstalled(id)},
{QStringLiteral("sizeMb"), static_cast<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(Loc::text("version.error.currentlyDownloading"));
return;
}
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|| m_loaderInstaller->isRunning()) {
emit launchError(Loc::text("common.error.busyOperation"));
return;
}
// Какой версией сейчас играют, GameLauncher наружу не отдаёт, поэтому на
// время игры удаление запрещено целиком.
if (m_launcher->isRunning()) {
emit launchError(Loc::text("common.error.closeGameFirst"));
return;
}
QString error;
if (!VersionLoader::remove(gameDir(), id, &error)) {
emit launchError(Loc::text("version.error.removeFailed").arg(error));
return;
}
invalidateCatalog();
emit installedVersionsChanged();
emit launchProgress(Loc::text("version.status.removed").arg(id));
}
QVariantList LauncherBackend::versionCatalog() const
{
if (m_catalogDirty) {
rebuildCatalog();
m_catalogDirty = false;
}
return m_catalog;
}
void LauncherBackend::rebuildCatalog() const
{
m_catalog.clear();
// Плоский список в порядке манифеста — новые версии сверху. Раскладку по
// категориям и отбор делает окно выбора версии: ему нужен один список,
// который можно фильтровать, а не готовая структура с разделителями.
const QList<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 += Loc::text("loader.label.recommended");
else if (!entry.stable)
label += Loc::text("loader.label.unstable");
result.append(QVariantMap{{QStringLiteral("version"), entry.loaderVersion},
{QStringLiteral("label"), label},
{QStringLiteral("recommended"), entry.recommended},
{QStringLiteral("stable"), entry.stable}});
}
return result;
}
void LauncherBackend::refreshLoaderVersions(const QString &loaderKey,
const QString &gameVersion,
bool force)
{
const auto loader = loaderFromKey(loaderKey);
if (!loader)
return;
m_loaderMeta->ensureLoaded(*loader, gameVersion, [this, loaderKey, gameVersion](
bool ok, const QString &warning) {
if (!ok && !warning.isEmpty())
emit launchError(warning);
// Кэш мог ответить сразу, без сигнала versionsChanged.
emit loaderVersionsChanged(loaderKey, gameVersion);
}, force);
}
bool LauncherBackend::loaderVersionsLoading(const QString &loaderKey,
const QString &gameVersion) const
{
const auto loader = loaderFromKey(loaderKey);
return loader && m_loaderMeta->isRefreshing(*loader, gameVersion);
}
void LauncherBackend::installLoaderForBuild(int index)
{
if (index < 0 || index >= m_builds.size())
return;
if (busy()) {
emit launchError(Loc::text("common.error.busyDownload"));
return;
}
const CustomBuild &build = m_builds[index];
if (build.minecraftVersion.trimmed().isEmpty()) {
emit launchError(Loc::text("build.error.pickMinecraftVersion").arg(build.name));
return;
}
// Лоадер не выбран — сборка ванильная, ставим саму версию игры.
const auto loader = loaderFromKey(build.loader);
if (!loader) {
if (build.resolvedVersionId != build.minecraftVersion)
updateCustomBuild(index, {{QStringLiteral("resolvedVersionId"), QString()}});
maybeInstall(build.minecraftVersion);
return;
}
if (build.loaderVersion.trimmed().isEmpty()) {
emit launchError(Loc::text("build.error.pickLoaderVersion")
.arg(loaderTitle(*loader), build.name));
return;
}
m_loaderInstallBuildId = build.id;
m_loaderInstaller->install(gameDir(),
*loader,
build.minecraftVersion,
build.loaderVersion,
managedJavaPath().isEmpty()
? m_settings.value(QStringLiteral("javaPath")).toString()
: managedJavaPath());
}
void LauncherBackend::launchGame(int profileIndex, int buildIndex)
{
if (m_loaderInstaller->isRunning()) {
emit launchError(Loc::text("build.error.installBusy")
.arg(m_loaderInstaller->label()));
return;
}
if (m_installer->isRunning()) {
emit launchError(Loc::text("version.error.downloadBusy")
.arg(m_installer->versionId()));
return;
}
// Игра ищет java при запуске: стартовать посреди её установки — значит
// найти половину распакованного дерева.
if (m_javaInstaller->isRunning()) {
emit launchError(Loc::text("java.error.installBusy")
.arg(m_javaInstaller->label()));
return;
}
if (m_busy) {
emit launchError(Loc::text("launch.error.alreadyLaunching"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(Loc::text("common.error.gameRunning"));
return;
}
if (profileIndex < 0 || profileIndex >= m_profiles.size()) {
emit launchError(Loc::text("launch.error.noProfile"));
return;
}
if (buildIndex < 0 || buildIndex >= m_builds.size()) {
emit launchError(Loc::text("launch.error.noBuild"));
return;
}
// Дальше проверяется только папка игры: свои файлы лаунчер уже разложил
// в galeonLauncher при запуске.
const QString directory = gameDir();
if (!QDir(directory).exists()) {
emit launchError(Loc::text("launch.error.gameDirMissing")
.arg(QDir::toNativeSeparators(directory)));
return;
}
const CustomBuild &entry = m_builds[buildIndex];
const QString id = launchVersionIdOf(entry);
if (id.trimmed().isEmpty()) {
emit launchError(Loc::text("launch.error.buildNoVersion")
.arg(entry.name,
QDir::toNativeSeparators(LauncherPaths::customBuildsFile())));
return;
}
const QString versionsDir = QDir::toNativeSeparators(directory + QStringLiteral("/versions"));
const QStringList installed = VersionLoader::installedVersions(directory);
if (!installed.contains(id)) {
emit launchError(installed.isEmpty()
? Loc::text("launch.error.noVersionsInstalled").arg(versionsDir)
: Loc::text("launch.error.versionNotInstalled")
.arg(id,
entry.name,
versionsDir,
installed.join(QStringLiteral(", "))));
return;
}
setBusy(true);
emit launchProgress(Loc::text("launch.progress.readingVersion").arg(id));
QSet<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() ? Loc::text("common.error.versionReadFailed").arg(id) : error);
return;
}
LaunchOptions probe;
probe.gameDir = directory;
probe.versionId = id;
const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData);
if (!missing.isEmpty()) {
failLaunch(Loc::text("launch.error.gameFilesMissing")
.arg(missing.size())
.arg(missing.join(u'\n')));
return;
}
m_pendingProfile = profileIndex;
// Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из
// модального окна, и индекс уехал бы на чужую запись.
m_pendingBuild = entry.id;
m_pendingInjectorPath.clear();
beginAuthentication();
}
void LauncherBackend::startMicrosoftLogin(int profileIndex)
{
if (!microsoftAvailable()) {
emit microsoftLoginFailed(Loc::text("auth.msa.error.noWebEngine"));
return;
}
// -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать
// это состояние от закрытого окна.
if (profileIndex >= m_profiles.size()) {
emit microsoftLoginFailed(Loc::text("profile.error.notFound"));
return;
}
m_msaLoginProfile = profileIndex < 0 ? -2 : profileIndex;
emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl().toString());
}
void LauncherBackend::cancelMicrosoftLogin()
{
m_msaLoginProfile = -1;
}
QVariantMap LauncherBackend::inspectMicrosoftRedirect(const QString &url) const
{
QString code;
QString error;
const bool matched = MsaAuthService::matchRedirect(QUrl(url), &code, &error);
return {{QStringLiteral("matched"), matched},
{QStringLiteral("code"), code},
{QStringLiteral("error"), error}};
}
void LauncherBackend::finishMicrosoftLogin(const QString &code)
{
if (m_msaLoginProfile == -1) {
emit microsoftLoginFailed(Loc::text("auth.msa.status.alreadyCancelled"));
return;
}
setBusy(true);
m_msa->loginWithCode(code, [this](const AuthResult &result) {
setBusy(false);
const int target = m_msaLoginProfile;
m_msaLoginProfile = -1;
if (target == -1)
return; // окно закрыли, пока шла авторизация
if (!result.ok) {
emit microsoftLoginFailed(result.error);
return;
}
if (target == -2) {
// Профиля ещё нет — заводим его на ник из аккаунта.
Profile profile;
profile.id = m_nextProfileId++;
profile.name = result.playerName;
profile.authType = kMicrosoft;
profile.clientToken = AuthService::generateClientToken();
applyMicrosoftResult(profile, result);
m_profiles.append(profile);
} else if (target < m_profiles.size()) {
Profile &profile = m_profiles[target];
profile.authType = kMicrosoft;
applyMicrosoftResult(profile, result);
} else {
emit microsoftLoginFailed(Loc::text("profile.error.removedDuringLogin"));
return;
}
saveProfiles();
emit profilesChanged();
emit microsoftLoginSucceeded(result.playerName);
});
}
void LauncherBackend::applyMicrosoftResult(Profile &profile, const AuthResult &auth)
{
profile.accessToken = auth.accessToken;
// Microsoft выдаёт новый refresh-токен и при продлении, но если в ответе
// его не было — прежний остаётся рабочим.
if (!auth.refreshToken.isEmpty())
profile.refreshToken = auth.refreshToken;
if (!auth.uuid.isEmpty())
profile.uuid = auth.uuid;
if (!auth.playerName.isEmpty())
profile.resolvedName = auth.playerName;
profile.xuid = auth.xuid;
profile.userType = auth.userType;
profile.tokenExpiry = auth.expiresAt.isValid()
? auth.expiresAt.toUTC().toString(Qt::ISODate)
: QString();
}
void LauncherBackend::beginMicrosoftAuthentication()
{
const Profile &profile = m_profiles[m_pendingProfile];
if (profile.refreshToken.isEmpty()) {
const int index = m_pendingProfile;
failLaunch(Loc::text("auth.msa.error.profileNeedsLogin")
.arg(profile.name));
emit microsoftReloginRequired(index);
return;
}
// Сохранённый токен ещё жив — идём в игру, не дёргая Microsoft.
const QDateTime expiry = QDateTime::fromString(profile.tokenExpiry, Qt::ISODate);
if (!profile.accessToken.isEmpty() && expiry.isValid()
&& QDateTime::currentDateTimeUtc().addSecs(kTokenRenewMarginSecs) < expiry) {
AuthResult stored;
stored.ok = true;
stored.playerName = profile.resolvedName;
stored.uuid = profile.uuid;
stored.accessToken = profile.accessToken;
stored.refreshToken = profile.refreshToken;
stored.clientToken = MsaAuthService::clientId();
stored.xuid = profile.xuid;
stored.userType = profile.userType.isEmpty() ? QStringLiteral("msa") : profile.userType;
stored.expiresAt = expiry;
continueLaunch(stored);
return;
}
m_msa->loginWithRefreshToken(profile.refreshToken, [this](const AuthResult &result) {
if (m_pendingProfile < 0)
return; // запуск отменили, пока шло продление
if (result.licenseMissing) {
failLaunch(result.error);
return;
}
if (!result.ok) {
const int index = m_pendingProfile;
failLaunch(Loc::text("auth.msa.error.sessionExpired")
.arg(result.error));
emit microsoftReloginRequired(index);
return;
}
continueLaunch(result);
});
}
void LauncherBackend::beginAuthentication()
{
const Profile &profile = m_profiles[m_pendingProfile];
if (profile.authType == kMicrosoft) {
beginMicrosoftAuthentication();
return;
}
if (profile.authType != kElyBy) {
const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
const AuthResult result = AuthService::offline(nickname);
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
return;
}
// Ely.by работает только вместе с authlib-injector — без него игра пойдёт
// в сессионный сервер Mojang и токен окажется недействительным.
m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(),
[this](const QString &path, const QString &error) {
if (m_pendingProfile < 0)
return; // запуск отменили, пока качался инжектор
if (!error.isEmpty()) {
failLaunch(error);
return;
}
m_pendingInjectorPath = path;
const Profile &pending = m_profiles[m_pendingProfile];
m_auth->loginElyBy(pending.login,
pending.password,
pending.clientToken,
pending.accessToken,
[this](const AuthResult &result) {
if (m_pendingProfile < 0)
return;
if (result.twoFactorRequired) {
setBusy(false);
emit twoFactorRequired(m_profiles[m_pendingProfile].name);
return;
}
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
});
});
}
void LauncherBackend::submitTwoFactorCode(const QString &code)
{
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) {
emit launchError(Loc::text("launch.status.alreadyCancelled"));
return;
}
if (code.trimmed().isEmpty()) {
emit launchError(Loc::text("auth.ely.error.enterCode"));
return;
}
setBusy(true);
const Profile &profile = m_profiles[m_pendingProfile];
m_auth->loginElyByWithTotp(profile.login,
profile.password,
code.trimmed(),
profile.clientToken,
[this](const AuthResult &result) {
if (m_pendingProfile < 0)
return;
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
});
}
void LauncherBackend::cancelPendingLaunch()
{
m_pendingProfile = -1;
m_pendingBuild = -1;
m_pendingInjectorPath.clear();
setBusy(false);
}
void LauncherBackend::continueLaunch(const AuthResult &auth)
{
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) {
failLaunch(Loc::text("launch.status.cancelled"));
return;
}
const int buildIndex = indexOfBuildId(m_pendingBuild);
if (buildIndex < 0) {
failLaunch(Loc::text("build.error.removedDuringAuth"));
return;
}
Profile &profile = m_profiles[m_pendingProfile];
const CustomBuild &build = m_builds[buildIndex];
// Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем,
// чтобы в следующий раз обойтись без пароля.
if (profile.authType == kMicrosoft && auth.ok) {
applyMicrosoftResult(profile, auth);
saveProfiles();
} else if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
profile.accessToken = auth.accessToken;
profile.clientToken = auth.clientToken;
if (!auth.uuid.isEmpty())
profile.uuid = auth.uuid;
if (!auth.playerName.isEmpty())
profile.resolvedName = auth.playerName;
profile.userType = auth.userType;
saveProfiles();
}
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = m_pendingVersionData.id;
options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName;
if (options.playerName.isEmpty())
options.playerName = profile.login.isEmpty() ? profile.name : profile.login;
options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid;
options.accessToken = auth.accessToken;
options.clientToken = auth.clientToken;
options.userType = auth.userType;
options.xuid = auth.xuid;
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
// окне выбора версии Java.
options.javaPath = managedJavaPath();
if (options.javaPath.isEmpty()) {
if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty())
emit launchProgress(Loc::text("java.warning.runtimeMissing"));
options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString();
}
options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512);
options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096);
options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0);
options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0);
options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool();
options.serverAddress = build.serverUrl.trimmed();
options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed();
if (!extra.isEmpty())
options.extraJvmArgs = extra.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts);
if (profile.authType == kElyBy)
options.authlibInjectorPath = m_pendingInjectorPath;
QString error;
const bool started = m_launcher->launch(options, m_pendingVersionData, &error);
const QString profileName = profile.name;
const QString buildName = build.name;
const QString serverUrl = build.serverUrl;
cancelPendingLaunch();
if (!started) {
emit launchError(error);
return;
}
emit gameRunningChanged();
emit launched(profileName, buildName, serverUrl);
}
void LauncherBackend::stopGame()
{
m_launcher->terminate();
}
void LauncherBackend::failLaunch(const QString &message)
{
cancelPendingLaunch();
emit launchError(message);
}
bool LauncherBackend::switching() const
{
return m_switcher->isRunning();
}
double LauncherBackend::switchProgress() const
{
return m_switcher->fraction();
}
QString LauncherBackend::switchStage() const
{
return m_switcher->stage();
}
QString LauncherBackend::switchStatus() const
{
return m_switcher->status();
}
bool LauncherBackend::busy() const
{
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
|| m_javaInstaller->isRunning() || m_switcher->isRunning()
|| m_packDownloader->isRunning();
}
bool LauncherBackend::microsoftAvailable() const
{
#ifdef LAUNCHER_HAS_WEBENGINE
return true;
#else
return false;
#endif
}
void LauncherBackend::setBusy(bool busy)
{
if (m_busy == busy)
return;
m_busy = busy;
emit busyChanged();
}
QString LauncherBackend::gameDir() const
{
const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed();
return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir()
: QDir::fromNativeSeparators(configured);
}
QVariantMap LauncherBackend::settings() const
{
QVariantMap result = m_settings;
result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir());
return result;
}
void LauncherBackend::updateSettings(const QVariantMap &values)
{
const QString previousGameDir = gameDir();
const QString previousLanguage = m_settings.value(QStringLiteral("language")).toString();
for (auto it = values.cbegin(); it != values.cend(); ++it)
m_settings.insert(it.key(), it.value());
saveSettings();
emit settingsChanged();
// Язык переключается после записи: порядок «сохранили → переключили»
// гарантирует, что выбор переживёт падение сразу после переключения.
// Сам Localization в settings.json не пишет — файл ведёт только бэкенд.
const QString language = m_settings.value(QStringLiteral("language")).toString();
if (language != previousLanguage)
Localization::instance().setLanguage(language);
if (gameDir() != previousGameDir) {
invalidateCatalog();
emit installedVersionsChanged();
}
}
QStringList LauncherBackend::detectedJava() const
{
QStringList result;
const QStringList candidates = JavaLocator::findAll(gameDir());
for (const QString &candidate : candidates) {
const int major = JavaLocator::majorVersion(candidate);
result << (major > 0 ? QStringLiteral("Java %1 — %2")
.arg(major)
.arg(QDir::toNativeSeparators(candidate))
: QDir::toNativeSeparators(candidate));
}
return result;
}
// ── Сборки Java ────────────────────────────────────────────────────────────
void LauncherBackend::invalidateJavaCatalog()
{
m_javaCatalogDirty = true;
emit javaCatalogChanged();
}
bool LauncherBackend::javaCatalogLoading() const
{
return m_javaMeta->isRefreshing();
}
void LauncherBackend::refreshJavaCatalog(bool force)
{
// Список установленного мог поменяться и без нас (папку удалили руками).
invalidateJavaCatalog();
m_javaMeta->ensureLoaded([this](bool ok, const QString &warning) {
if (!ok && !warning.isEmpty())
emit launchError(warning);
}, force);
}
QVariantList LauncherBackend::javaCatalog() const
{
if (m_javaCatalogDirty) {
rebuildJavaCatalog();
m_javaCatalogDirty = false;
}
return m_javaCatalog;
}
void LauncherBackend::rebuildJavaCatalog() const
{
m_javaCatalog.clear();
const QList<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
? Loc::text("java.label.mojang").arg(remote->component)
: Loc::text("java.label.temurin").arg(remote->architecture))
: Loc::text("java.label.downloadedByLauncher");
m_javaCatalog.append(makeRow(runtime.id,
runtime.kind,
runtime.major,
runtime.version,
detail,
runtime.size,
true,
remote && remote->lts,
remote.has_value()));
}
const QList<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
? Loc::text("java.label.mojang").arg(entry.component)
: Loc::text("java.label.temurin").arg(entry.architecture);
m_javaCatalog.append(makeRow(entry.id,
javaKindKey(kind),
entry.major,
entry.version,
detail,
entry.size,
false,
entry.lts,
true));
}
}
}
void LauncherBackend::installJavaRuntime(const QString &runtimeId)
{
if (m_javaInstaller->isRunning()) {
emit launchError(Loc::text("java.error.installBusy")
.arg(m_javaInstaller->label()));
return;
}
if (m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning()) {
emit launchError(Loc::text("common.error.busyOperation"));
return;
}
const auto entry = m_javaMeta->find(runtimeId);
if (!entry) {
emit launchError(Loc::text("java.error.buildNotInCatalog").arg(runtimeId));
return;
}
m_javaInstaller->install(*entry);
}
void LauncherBackend::removeJavaRuntime(const QString &runtimeId)
{
if (m_javaInstaller->isRunning() && m_javaInstaller->runtimeId() == runtimeId) {
emit launchError(Loc::text("java.error.currentlyDownloading"));
return;
}
QString error;
if (!JavaRuntimeStore::remove(runtimeId, &error)) {
emit launchError(Loc::text("java.error.removeFailed").arg(error));
return;
}
// Удалили выбранную — настройка указывала бы в пустоту.
if (m_settings.value(QStringLiteral("javaRuntime")).toString() == runtimeId) {
m_settings.insert(QStringLiteral("javaRuntime"), QString());
saveSettings();
emit settingsChanged();
}
invalidateJavaCatalog();
emit launchProgress(Loc::text("java.status.removed"));
}
QVariantMap LauncherBackend::javaRuntimeInfo(const QString &runtimeId) const
{
if (runtimeId.trimmed().isEmpty())
return {};
const auto runtime = JavaRuntimeStore::find(runtimeId);
if (runtime) {
return QVariantMap{{QStringLiteral("id"), runtime->id},
{QStringLiteral("kind"), runtime->kind},
{QStringLiteral("major"), runtime->major},
{QStringLiteral("version"), runtime->version},
{QStringLiteral("label"), QStringLiteral("Java %1").arg(runtime->version)},
{QStringLiteral("javaPath"), QDir::toNativeSeparators(runtime->javaPath)},
{QStringLiteral("installed"), true}};
}
// Выбрана, но ещё не скачана: подпись всё равно нужна.
const auto entry = m_javaMeta->find(runtimeId);
if (!entry)
return QVariantMap{{QStringLiteral("id"), runtimeId},
{QStringLiteral("label"), runtimeId},
{QStringLiteral("installed"), false}};
return QVariantMap{{QStringLiteral("id"), entry->id},
{QStringLiteral("kind"), javaKindKey(entry->kind)},
{QStringLiteral("major"), entry->major},
{QStringLiteral("version"), entry->version},
{QStringLiteral("label"), QStringLiteral("Java %1").arg(entry->version)},
{QStringLiteral("javaPath"), QString()},
{QStringLiteral("installed"), false}};
}
int LauncherBackend::requiredJavaMajor(int buildIndex) const
{
if (buildIndex < 0 || buildIndex >= m_builds.size())
return 0;
return JavaRequirement::minimumFor(m_builds.at(buildIndex).minecraftVersion);
}
QString LauncherBackend::managedJavaPath() const
{
const QString id = m_settings.value(QStringLiteral("javaRuntime")).toString();
if (id.trimmed().isEmpty())
return {};
const auto runtime = JavaRuntimeStore::find(id);
return runtime ? runtime->javaPath : QString();
}
// ── Сезонные сборки ────────────────────────────────────────────────────────
QUrl LauncherBackend::seasonalBaseUrl() const
{
const QString configured =
m_settings.value(QStringLiteral("seasonalBaseUrl")).toString().trimmed();
QUrl url(configured.isEmpty() ? kSeasonalBaseUrl : configured);
// Хвостовой слэш превратил бы адрес каталога в «…//index.json».
while (url.path().endsWith(u'/'))
url.setPath(url.path().chopped(1));
return url;
}
void LauncherBackend::invalidateSeasonalCatalog()
{
m_seasonalCatalogDirty = true;
emit seasonalCatalogChanged();
}
int LauncherBackend::indexOfSeasonalBuild(const QString &seasonalId) const
{
if (seasonalId.isEmpty())
return -1;
for (int i = 0; i < m_builds.size(); ++i) {
if (m_builds[i].seasonalId == seasonalId)
return i;
}
return -1;
}
void LauncherBackend::rebuildSeasonalCatalog() const
{
m_seasonalCatalog.clear();
const QList<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 = Loc::text("seasonal.status.notInstalled");
else if (updateAvailable)
status = Loc::text("seasonal.status.updateAvailable");
else
status = Loc::text("seasonal.status.installed");
const auto loader = loaderFromKey(entry.loader);
m_seasonalCatalog.append(QVariantMap{
{QStringLiteral("id"), entry.id},
{QStringLiteral("name"), entry.name},
{QStringLiteral("minecraftVersion"), entry.minecraftVersion},
{QStringLiteral("loader"), entry.loader},
{QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : Loc::text("loader.label.vanilla")},
{QStringLiteral("loaderVersion"), entry.loaderVersion},
{QStringLiteral("modCount"), entry.modCount},
{QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)},
{QStringLiteral("seasonEnd"), formatSeasonDate(entry.seasonEnd)},
{QStringLiteral("sizeBytes"), entry.archiveSize},
{QStringLiteral("revision"), entry.revision},
{QStringLiteral("description"), entry.description},
{QStringLiteral("serverUrl"), entry.serverUrl},
{QStringLiteral("installed"), installed},
{QStringLiteral("installedRevision"), installedRevision},
{QStringLiteral("updateAvailable"), updateAvailable},
{QStringLiteral("status"), status}});
}
m_seasonalCatalogDirty = false;
}
QVariantList LauncherBackend::seasonalCatalog() const
{
if (m_seasonalCatalogDirty)
rebuildSeasonalCatalog();
return m_seasonalCatalog;
}
bool LauncherBackend::seasonalCatalogLoading() const
{
return m_seasonalMeta->isRefreshing();
}
QString LauncherBackend::seasonalCatalogError() const
{
return m_seasonalMeta->lastError();
}
bool LauncherBackend::seasonalInstalling() const
{
return m_season.stage != SeasonStage::Idle;
}
void LauncherBackend::refreshSeasonalCatalog(bool force)
{
// Адрес мог смениться в settings.json между запусками окна.
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
m_seasonalMeta->ensureLoaded(nullptr, force);
}
// ── Установка сезонной сборки ──────────────────────────────────────────────
void LauncherBackend::installSeasonalBuild(const QString &seasonalId)
{
if (m_season.stage != SeasonStage::Idle) {
emit launchError(Loc::text("seasonal.error.installInProgress"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(Loc::text("build.error.closeGameFilesMoving"));
return;
}
if (busy()) {
emit launchError(Loc::text("common.error.busyOperation"));
return;
}
const auto entry = m_seasonalMeta->find(seasonalId);
if (!entry) {
emit launchError(Loc::text("seasonal.error.notInCatalog").arg(seasonalId));
return;
}
m_season = {};
m_season.entry = *entry;
// Сборка с таким seasonalId уже заведена — обновляем её описание под новую
// ревизию, а не плодим вторую запись.
int index = indexOfSeasonalBuild(seasonalId);
if (index < 0) {
CustomBuild build;
build.id = m_nextBuildId++;
build.name = entry->name;
build.serverUrl = entry->serverUrl;
build.minecraftVersion = entry->minecraftVersion;
build.loader = entry->loader;
build.loaderVersion = entry->loaderVersion;
build.seasonalId = entry->id;
// Ревизия появится только когда файлы лягут на диск: иначе оборванная
// установка выглядела бы завершённой.
m_builds.append(build);
index = m_builds.size() - 1;
saveCustomBuilds();
emit customBuildsChanged();
// maybeInstall() здесь намеренно нет: версию и модлоадер поставит шаг
// цепочки, а параллельная загрузка только мешала бы ему.
} else {
updateCustomBuild(index,
{{QStringLiteral("name"), entry->name},
{QStringLiteral("serverUrl"), entry->serverUrl},
{QStringLiteral("minecraftVersion"), entry->minecraftVersion},
{QStringLiteral("loader"), entry->loader},
{QStringLiteral("loaderVersion"), entry->loaderVersion}});
}
m_season.buildId = m_builds[index].id;
setBusy(true);
emit launchProgress(Loc::text("seasonal.progress.installing").arg(entry->name));
// Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его
// вызова seasonalInstalling() ещё вернул бы false.
seasonAdvance();
emit seasonalInstallingChanged();
}
void LauncherBackend::seasonAdvance()
{
if (indexOfBuildId(m_season.buildId) < 0) {
seasonFail(Loc::text("seasonal.error.entryGone"));
return;
}
// Каждый шаг либо начинает свою асинхронную работу и обрывает цепочку до
// следующего сигнала, либо уступает следующему — отсюда провалы между
// ветками.
switch (m_season.stage) {
case SeasonStage::Idle:
if (seasonBeginSwitch())
return;
[[fallthrough]];
case SeasonStage::Switching:
if (seasonBeginJava())
return;
[[fallthrough]];
case SeasonStage::Java:
if (seasonBeginLoader())
return;
[[fallthrough]];
case SeasonStage::Loader:
if (seasonBeginDownload())
return;
[[fallthrough]];
case SeasonStage::Downloading:
if (seasonBeginApply())
return;
[[fallthrough]];
case SeasonStage::Applying:
seasonFinish();
return;
}
}
bool LauncherBackend::seasonBeginSwitch()
{
const int index = indexOfBuildId(m_season.buildId);
const int current = activeBuildIndex();
if (current == index)
return false;
// Пак ложится на то, что сейчас разложено в .minecraft, поэтому сборка
// должна стать активной до всего остального. Содержимое прежней уезжает в
// её архив обычным путём.
m_season.stage = SeasonStage::Switching;
emit launchProgress(Loc::text("build.progress.switchingTo").arg(m_builds[index].name));
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
current >= 0 ? m_builds[current].name : QString(),
m_builds[index].id,
m_builds[index].name,
gameDir());
emit switchChanged();
emit busyChanged();
return true;
}
bool LauncherBackend::seasonBeginJava()
{
// Сервер может назвать нужную Java прямо; иначе спрашиваем таблицу.
const int major = m_season.entry.javaMajor > 0
? m_season.entry.javaMajor
: JavaRequirement::minimumFor(m_season.entry.minecraftVersion);
if (major <= 0)
return false;
// Java уже есть — своя, системная или скачанная раньше.
QString error;
if (!JavaLocator::select(gameDir(), major, QString(), &error).isEmpty())
return false;
m_season.stage = SeasonStage::Java;
emit launchProgress(Loc::text("seasonal.progress.installingJava").arg(major));
// Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать.
m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) {
if (m_season.stage != SeasonStage::Java)
return;
if (!ok) {
seasonFail(warning.isEmpty() ? Loc::text("seasonal.error.javaCatalogFailed") : warning);
return;
}
const auto entry = m_javaMeta->bestFor(major);
if (!entry) {
seasonFail(Loc::text("seasonal.error.noJavaBuild").arg(major));
return;
}
m_javaInstaller->install(*entry);
});
return true;
}
bool LauncherBackend::seasonBeginLoader()
{
const int index = indexOfBuildId(m_season.buildId);
const CustomBuild &build = m_builds[index];
const auto loader = loaderFromKey(build.loader);
// Ванильная сборка: ставить надо только саму версию игры.
if (!loader) {
if (checkInstallation(index).isEmpty())
return false;
m_season.stage = SeasonStage::Loader;
m_installer->install(gameDir(), build.minecraftVersion);
return true;
}
// Профиль лоадера уже собран и укомплектован — второй раз не ставим.
if (!build.resolvedVersionId.isEmpty() && checkInstallation(index).isEmpty())
return false;
m_season.stage = SeasonStage::Loader;
m_loaderInstallBuildId = build.id;
m_loaderInstaller->install(gameDir(),
*loader,
build.minecraftVersion,
build.loaderVersion,
managedJavaPath().isEmpty()
? m_settings.value(QStringLiteral("javaPath")).toString()
: managedJavaPath());
return true;
}
bool LauncherBackend::seasonBeginDownload()
{
const SeasonalBuildEntry &entry = m_season.entry;
// Точка в начале имени: файл служебный, и BuildSwitcher по этому признаку
// не примет его за архив сборки и не сотрёт при следующем переключении.
const QString path = LauncherPaths::buildDir(m_season.buildId)
+ QStringLiteral("/.pack-r") + QString::number(entry.revision)
+ QStringLiteral(".zip");
m_season.stage = SeasonStage::Downloading;
m_packDownloader->download(entry.archiveUrl,
path,
entry.archiveSha256,
entry.archiveSize,
entry.name);
return true;
}
bool LauncherBackend::seasonBeginApply()
{
if (m_season.packPath.isEmpty()) {
seasonFail(Loc::text("seasonal.error.filesNotDownloaded"));
return true;
}
// Под удаление идёт весь список прошлой ревизии, а не разница с новой:
// распаковка всё равно кладёт каждый файл пака заново, поэтому разница
// сэкономила бы лишь перезапись того, что и так перезаписывается, а вот
// ушедшие из сборки моды так убираются наверняка. Миров и скриншотов в
// списке нет — их пак не приносил.
const QStringList remove = seasonalPackEntries(m_season.buildId);
const QJsonObject note{{QStringLiteral("seasonalId"), m_season.entry.id},
{QStringLiteral("revision"), m_season.entry.revision},
{QStringLiteral("sha256"), m_season.entry.archiveSha256}};
const int index = indexOfBuildId(m_season.buildId);
m_season.stage = SeasonStage::Applying;
m_switcher->applyPack(m_season.buildId,
m_builds[index].name,
m_season.packPath,
remove,
note,
gameDir());
emit switchChanged();
emit busyChanged();
return true;
}
void LauncherBackend::seasonFinish()
{
const int index = indexOfBuildId(m_season.buildId);
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
const QString seasonalId = m_season.entry.id;
m_season = {};
setBusy(false);
invalidateSeasonalCatalog();
emit seasonalInstallingChanged();
emit launchProgress(Loc::text("seasonal.status.buildInstalled").arg(name));
emit seasonalInstallFinished(seasonalId, name);
}
void LauncherBackend::seasonFail(const QString &message)
{
const int index = indexOfBuildId(m_season.buildId);
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
// Недокачанный или нераскатанный пак не должен занимать место: цепочку
// всегда можно начать заново с чистого листа.
if (!m_season.packPath.isEmpty())
QFile::remove(m_season.packPath);
m_season = {};
setBusy(false);
emit seasonalInstallingChanged();
// Пустое message означает, что о причине уже сообщил тот, кто её нашёл, —
// второй раз ту же ошибку не показываем.
emit launchError(message.isEmpty()
? Loc::text("seasonal.error.installAborted").arg(name)
: Loc::text("seasonal.error.installAbortedWhy").arg(name, message));
}
void LauncherBackend::cancelSeasonalInstall()
{
switch (m_season.stage) {
case SeasonStage::Idle:
return;
case SeasonStage::Java:
m_javaInstaller->cancel();
return;
case SeasonStage::Loader:
if (m_loaderInstaller->isRunning())
m_loaderInstaller->cancel();
else
m_installer->cancel();
return;
case SeasonStage::Downloading:
m_packDownloader->cancel();
return;
case SeasonStage::Switching:
case SeasonStage::Applying:
// На этих шагах содержимое .minecraft уже разобрано: отступать некуда,
// операцию нужно довести до конца.
emit launchError(Loc::text("switch.error.cannotCancel"));
return;
}
}
// ── Состояние установленной сезонной сборки ────────────────────────────────
QStringList LauncherBackend::seasonalPackEntries(int buildId) const
{
QFile file(LauncherPaths::seasonalStateFile(buildId));
if (!file.open(QIODevice::ReadOnly))
return {};
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QStringList entries;
const QJsonArray array = root.value(QStringLiteral("entries")).toArray();
entries.reserve(array.size());
for (const QJsonValue &value : array)
entries << value.toString();
return entries;
}
void LauncherBackend::recordSeasonalPack(int buildId,
const QJsonObject &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(Loc::text("launch.error.openModsFailed"));
}
void LauncherBackend::openGameFolder()
{
const QString directory = gameDir();
QDir().mkpath(directory);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory)))
emit launchError(Loc::text("launch.error.openGameDirFailed"));
}
// Повреждённый файл не удаляем: отводим в .bak, чтобы данные можно было спасти
// руками, а ensureDataFiles() создал на его месте пустой.
bool LauncherBackend::moveAsideBroken(const QString &path, const QString &reason)
{
const QString backup = path + QStringLiteral(".bak");
QFile::remove(backup);
if (!QFile::rename(path, backup)) {
m_storageIssues << Loc::text("storage.error.corruptNotSetAside")
.arg(QDir::toNativeSeparators(path), reason);
return false;
}
m_storageIssues << Loc::text("storage.warning.corruptRecreated")
.arg(QDir::toNativeSeparators(path),
reason,
QDir::toNativeSeparators(backup));
return true;
}
QJsonArray LauncherBackend::readArrayFile(const QString &path)
{
QFile file(path);
if (!file.exists())
return {}; // первый запуск — это не ошибка
if (!file.open(QIODevice::ReadOnly)) {
m_storageIssues << Loc::text("common.error.readFailed")
.arg(QDir::toNativeSeparators(path), file.errorString());
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
file.close();
moveAsideBroken(path, parseError.errorString());
return {};
}
if (!document.isArray()) {
file.close();
moveAsideBroken(path, Loc::text("storage.error.expectedArray"));
return {};
}
return document.array();
}
QJsonObject LauncherBackend::readObjectFile(const QString &path)
{
QFile file(path);
if (!file.exists())
return {};
if (!file.open(QIODevice::ReadOnly)) {
m_storageIssues << Loc::text("common.error.readFailed")
.arg(QDir::toNativeSeparators(path), file.errorString());
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
file.close();
moveAsideBroken(path, parseError.errorString());
return {};
}
if (!document.isObject()) {
file.close();
moveAsideBroken(path, Loc::text("storage.error.expectedObject"));
return {};
}
return document.object();
}
// Перед каждой записью убеждаемся, что папка лаунчера на месте: её могли удалить
// уже после запуска, и тогда данные молча пропадали бы.
bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &document)
{
QString storageError;
if (!LauncherPaths::ensureRootExists(&storageError)) {
emit launchError(Loc::text("storage.error.launcherDirFailed").arg(storageError));
return false;
}
const QByteArray payload = document.toJson();
QFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
emit launchError(Loc::text("storage.error.saveFailedWhy")
.arg(QDir::toNativeSeparators(path), file.errorString()));
return false;
}
return true;
}
void LauncherBackend::loadData()
{
auto loadFile = [this](const QString &path, auto handler) {
for (const auto &val : readArrayFile(path))
handler(val.toObject());
};
loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) {
Profile profile;
profile.id = o["id"].toInt();
profile.name = o["name"].toString();
profile.login = o["login"].toString();
profile.password = o["password"].toString();
profile.authType = o.value("authType").toString(kOffline);
profile.clientToken = o.value("clientToken").toString();
profile.accessToken = o.value("accessToken").toString();
profile.uuid = o.value("uuid").toString();
profile.resolvedName = o.value("resolvedName").toString();
profile.userType = o.value("userType").toString();
profile.refreshToken = o.value("refreshToken").toString();
profile.xuid = o.value("xuid").toString();
profile.tokenExpiry = o.value("tokenExpiry").toString();
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
if (profile.id >= m_nextProfileId)
m_nextProfileId = profile.id + 1;
});
std::sort(m_profiles.begin(), m_profiles.end(),
[](const Profile &a, const Profile &b) { return a.id < b.id; });
int skippedBuilds = 0;
loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) {
CustomBuild build;
build.id = o["id"].toInt();
build.name = o["name"].toString();
build.serverUrl = o["serverUrl"].toString();
build.minecraftVersion = o.value("minecraftVersion").toString();
// Файл мог остаться от версии лаунчера до переименования или быть
// поправлен руками — старый ключ тоже принимаем.
if (build.minecraftVersion.isEmpty())
build.minecraftVersion = o.value("versionId").toString();
build.loader = o.value("loader").toString();
build.loaderVersion = o.value("loaderVersion").toString();
build.resolvedVersionId = o.value("resolvedVersionId").toString();
build.seasonalId = o.value("seasonalId").toString();
build.seasonalRevision = o.value("seasonalRevision").toInt();
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
// пустую строку.
if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) {
++skippedBuilds;
return;
}
m_builds.append(build);
if (build.id >= m_nextBuildId)
m_nextBuildId = build.id + 1;
});
if (skippedBuilds > 0) {
m_storageIssues << Loc::text("storage.warning.buildsSkipped")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))
.arg(skippedBuilds);
}
std::sort(m_builds.begin(), m_builds.end(),
[](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; });
}
// Первый запуск после переименования «версий» в «сборки»: перекладываем
// <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 << Loc::text("storage.warning.migrationLeftover")
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()),
QDir::toNativeSeparators(
LauncherPaths::legacyCustomBuildsFile()));
}
return true;
}
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles()
{
if (!QFile::exists(LauncherPaths::settingsFile()))
saveSettings();
if (!QFile::exists(LauncherPaths::profilesFile()))
saveProfiles();
if (!QFile::exists(LauncherPaths::customBuildsFile()))
saveCustomBuilds();
}
void LauncherBackend::saveProfiles()
{
QJsonArray arr;
for (const auto &p : m_profiles)
arr.append(QJsonObject{{"id", p.id},
{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType},
{"clientToken", p.clientToken},
{"accessToken", p.accessToken},
{"uuid", p.uuid},
{"resolvedName", p.resolvedName},
{"userType", p.userType},
{"refreshToken", p.refreshToken},
{"xuid", p.xuid},
{"tokenExpiry", p.tokenExpiry}});
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
}
void LauncherBackend::saveCustomBuilds()
{
QJsonArray arr;
for (const auto &b : m_builds)
arr.append(QJsonObject{{"id", b.id},
{"name", b.name},
{"serverUrl", b.serverUrl},
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision}});
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
}
void LauncherBackend::loadSettings()
{
m_settings = {{QStringLiteral("gameDir"), QString()},
{QStringLiteral("javaPath"), QString()},
// id сборки Java из <root>/java. Пусто — java ищется сама.
{QStringLiteral("javaRuntime"), QString()},
{QStringLiteral("minMemoryMb"), 512},
{QStringLiteral("maxMemoryMb"), 4096},
{QStringLiteral("jvmArgs"), QString()},
{QStringLiteral("windowWidth"), 0},
{QStringLiteral("windowHeight"), 0},
{QStringLiteral("fullscreen"), false},
// Язык интерфейса: "system" — по системной локали.
{QStringLiteral("language"), QStringLiteral("system")},
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0},
// Сервер готовых сборок. В интерфейс не вынесен: менять его
// приходится только при отладке против локального сервера.
{QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}};
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
m_settings.insert(it.key(), it.value().toVariant());
}
void LauncherBackend::saveSettings()
{
writeJsonFile(LauncherPaths::settingsFile(),
QJsonDocument(QJsonObject::fromVariantMap(m_settings)));
}