2026-06-09 00:43:06 +03:00
|
|
|
|
#include "launcherbackend.h"
|
|
|
|
|
|
|
2026-09-03 09:18:48 +03:00
|
|
|
|
#include "localization.h"
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
#include "authservice.h"
|
|
|
|
|
|
#include "gamelauncher.h"
|
2026-08-27 19:35:11 +03:00
|
|
|
|
#include "javainstaller.h"
|
2026-08-22 18:19:13 +03:00
|
|
|
|
#include "javalocator.h"
|
2026-08-27 19:35:11 +03:00
|
|
|
|
#include "javaruntime.h"
|
|
|
|
|
|
#include "javaruntimeservice.h"
|
2026-08-23 03:30:31 +03:00
|
|
|
|
#include "launcherpaths.h"
|
2026-08-24 21:09:52 +03:00
|
|
|
|
#include "versioninstaller.h"
|
|
|
|
|
|
#include "versionmanifestservice.h"
|
2026-08-27 09:21:51 +03:00
|
|
|
|
#include "modloaderinstaller.h"
|
2026-09-01 22:25:23 +03:00
|
|
|
|
#include "msaauthservice.h"
|
2026-08-27 09:21:51 +03:00
|
|
|
|
#include "modloaderversionservice.h"
|
|
|
|
|
|
#include "buildswitcher.h"
|
2026-08-31 00:59:14 +03:00
|
|
|
|
#include "seasonalbuildservice.h"
|
|
|
|
|
|
#include "seasonalpackdownloader.h"
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
2026-08-31 00:59:14 +03:00
|
|
|
|
#include <QDate>
|
|
|
|
|
|
#include <QDateTime>
|
2026-06-19 21:03:37 +03:00
|
|
|
|
#include <QDesktopServices>
|
2026-06-09 00:43:06 +03:00
|
|
|
|
#include <QDir>
|
|
|
|
|
|
#include <QFile>
|
|
|
|
|
|
#include <QJsonArray>
|
|
|
|
|
|
#include <QJsonDocument>
|
|
|
|
|
|
#include <QJsonObject>
|
2026-08-24 21:09:52 +03:00
|
|
|
|
#include <QHash>
|
2026-08-22 18:19:13 +03:00
|
|
|
|
#include <QRegularExpression>
|
2026-08-31 00:59:14 +03:00
|
|
|
|
#include <QSaveFile>
|
2026-08-24 21:09:52 +03:00
|
|
|
|
#include <QSet>
|
2026-08-23 03:30:31 +03:00
|
|
|
|
#include <QTimer>
|
2026-06-19 21:03:37 +03:00
|
|
|
|
#include <QUrl>
|
2026-06-09 00:43:06 +03:00
|
|
|
|
#include <algorithm>
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
|
|
const QString kOffline = QStringLiteral("offline");
|
|
|
|
|
|
const QString kElyBy = QStringLiteral("elyby");
|
2026-09-01 22:25:23 +03:00
|
|
|
|
const QString kMicrosoft = QStringLiteral("microsoft");
|
|
|
|
|
|
|
|
|
|
|
|
// Токен Minecraft живёт около суток, но обновляем его чуть раньше срока: игра
|
|
|
|
|
|
// проверяет сессию не в первую секунду после старта.
|
|
|
|
|
|
const int kTokenRenewMarginSecs = 120;
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
2026-08-31 00:59:14 +03:00
|
|
|
|
// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl
|
|
|
|
|
|
// в settings.json — это нужно, чтобы прогонять установку против локального
|
|
|
|
|
|
// http-сервера, не трогая боевой.
|
|
|
|
|
|
const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com");
|
|
|
|
|
|
|
2026-09-01 22:25:23 +03:00
|
|
|
|
// Тип авторизации профиля: всё, что лаунчер не знает, считается офлайном.
|
|
|
|
|
|
QString normalizedAuthType(const QString &value)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (value == kElyBy)
|
|
|
|
|
|
return kElyBy;
|
|
|
|
|
|
if (value == kMicrosoft)
|
|
|
|
|
|
return kMicrosoft;
|
|
|
|
|
|
return kOffline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 00:59:14 +03:00
|
|
|
|
// Дата сезона в таблице: год нужен, время — нет.
|
|
|
|
|
|
QString formatSeasonDate(const QDate &date)
|
|
|
|
|
|
{
|
|
|
|
|
|
return date.isValid() ? date.toString(QStringLiteral("dd.MM.yyyy")) : QStringLiteral("—");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-09 00:43:06 +03:00
|
|
|
|
LauncherBackend::LauncherBackend(QObject *parent)
|
|
|
|
|
|
: QObject(parent)
|
2026-08-22 18:19:13 +03:00
|
|
|
|
, m_auth(new AuthService(this))
|
2026-09-01 22:25:23 +03:00
|
|
|
|
, m_msa(new MsaAuthService(this))
|
2026-08-22 18:19:13 +03:00
|
|
|
|
, m_launcher(new GameLauncher(this))
|
2026-08-24 21:09:52 +03:00
|
|
|
|
, m_manifest(new VersionManifestService(this))
|
|
|
|
|
|
, m_installer(new VersionInstaller(m_manifest, this))
|
2026-08-27 09:21:51 +03:00
|
|
|
|
, m_loaderMeta(new ModLoaderVersionService(this))
|
|
|
|
|
|
, m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this))
|
|
|
|
|
|
, m_switcher(new BuildSwitcher(this))
|
2026-08-27 19:35:11 +03:00
|
|
|
|
, m_javaMeta(new JavaRuntimeService(this))
|
|
|
|
|
|
, m_javaInstaller(new JavaInstaller(this))
|
2026-08-31 00:59:14 +03:00
|
|
|
|
, m_seasonalMeta(new SeasonalBuildService(this))
|
|
|
|
|
|
, m_packDownloader(new SeasonalPackDownloader(this))
|
2026-06-09 00:43:06 +03:00
|
|
|
|
{
|
2026-08-23 03:30:31 +03:00
|
|
|
|
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
|
|
|
|
|
|
// следующем — если её удалили, она появится снова.
|
|
|
|
|
|
QString storageError;
|
|
|
|
|
|
if (!LauncherPaths::ensureRootExists(&storageError))
|
2026-09-03 09:18:48 +03:00
|
|
|
|
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
|
2026-08-23 03:30:31 +03:00
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
loadSettings();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
|
|
|
|
|
// создаст пустой customBuilds.json поверх старых данных.
|
|
|
|
|
|
migrateLegacyBuildsFile();
|
2026-06-09 00:43:06 +03:00
|
|
|
|
loadData();
|
2026-08-23 03:30:31 +03:00
|
|
|
|
ensureDataFiles();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
|
|
|
|
|
// предлагаем пользователю переключиться заново: архив прежней сборки уже
|
|
|
|
|
|
// записан целиком, а вот повторное переключение сначала заархивировало бы
|
|
|
|
|
|
// поверх него нынешнее — наполовину разобранное — содержимое .minecraft.
|
|
|
|
|
|
const QString interrupted = m_switcher->interruptedSwitchWarning();
|
|
|
|
|
|
if (!interrupted.isEmpty()) {
|
|
|
|
|
|
m_storageIssues << interrupted;
|
|
|
|
|
|
QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress);
|
2026-09-01 22:25:23 +03:00
|
|
|
|
connect(m_msa, &MsaAuthService::progress, this, &LauncherBackend::launchProgress);
|
2026-08-22 18:19:13 +03:00
|
|
|
|
connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
|
|
|
|
|
|
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
|
|
|
|
|
|
connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
|
|
|
|
|
|
emit gameOutput(commandLine);
|
|
|
|
|
|
emit gameRunningChanged();
|
|
|
|
|
|
});
|
|
|
|
|
|
connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) {
|
|
|
|
|
|
emit gameRunningChanged();
|
|
|
|
|
|
emit gameFinished(exitCode, crashed);
|
|
|
|
|
|
});
|
2026-08-23 03:30:31 +03:00
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
connect(m_manifest, &VersionManifestService::versionsChanged,
|
|
|
|
|
|
this, &LauncherBackend::invalidateCatalog);
|
|
|
|
|
|
connect(m_manifest, &VersionManifestService::refreshingChanged,
|
|
|
|
|
|
this, &LauncherBackend::catalogLoadingChanged);
|
|
|
|
|
|
|
2026-08-27 19:35:11 +03:00
|
|
|
|
connect(m_javaMeta, &JavaRuntimeService::catalogChanged,
|
|
|
|
|
|
this, &LauncherBackend::invalidateJavaCatalog);
|
|
|
|
|
|
connect(m_javaMeta, &JavaRuntimeService::refreshingChanged,
|
|
|
|
|
|
this, &LauncherBackend::javaCatalogLoadingChanged);
|
|
|
|
|
|
|
|
|
|
|
|
connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) {
|
|
|
|
|
|
emit downloadChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("java.progress.downloading").arg(label));
|
2026-08-27 19:35:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
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);
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("java.status.installed")
|
2026-08-27 19:35:11 +03:00
|
|
|
|
.arg(QDir::toNativeSeparators(javaPath)));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Java)
|
|
|
|
|
|
seasonAdvance();
|
2026-08-27 19:35:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
connect(m_javaInstaller, &JavaInstaller::failed, this,
|
|
|
|
|
|
[this](const QString &label, const QString &message) {
|
|
|
|
|
|
invalidateJavaCatalog();
|
|
|
|
|
|
emit downloadChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("java.error.installFailed").arg(label, message));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Java)
|
|
|
|
|
|
seasonFail({});
|
2026-08-27 19:35:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
|
|
|
|
|
|
invalidateJavaCatalog();
|
|
|
|
|
|
emit downloadChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("java.status.downloadCancelled").arg(label));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Java)
|
|
|
|
|
|
seasonFail({});
|
2026-08-27 19:35:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
|
|
|
|
|
|
emit downloadChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("version.progress.downloading").arg(id));
|
2026-08-24 21:09:52 +03:00
|
|
|
|
});
|
|
|
|
|
|
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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("version.status.downloaded").arg(id));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
// У ванильной сезонной сборки этот установщик и есть весь шаг Loader.
|
|
|
|
|
|
// Внутри установки модлоадера он тоже отчитывается — тогда ход
|
|
|
|
|
|
// передаст ModLoaderInstaller, а не он.
|
|
|
|
|
|
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
|
|
|
|
|
|
seasonAdvance();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
});
|
|
|
|
|
|
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
|
|
|
|
|
|
const QString &message) {
|
|
|
|
|
|
// Часть файлов всё же появилась — список установленного мог измениться.
|
|
|
|
|
|
invalidateCatalog();
|
|
|
|
|
|
emit installedVersionsChanged();
|
|
|
|
|
|
emit downloadChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("version.error.downloadFailed").arg(id, message));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
|
|
|
|
|
|
seasonFail({});
|
2026-08-24 21:09:52 +03:00
|
|
|
|
});
|
2026-08-27 09:21:51 +03:00
|
|
|
|
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);
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("build.status.active")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(index >= 0 ? m_builds[index].name : QString()));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
|
|
|
|
|
|
// Раскатка пака могла быть и той, что доигралась после перезапуска —
|
|
|
|
|
|
// тогда цепочки установки уже нет, а записать ревизию всё равно надо.
|
|
|
|
|
|
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();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
});
|
|
|
|
|
|
connect(m_switcher, &BuildSwitcher::failed, this,
|
|
|
|
|
|
[this](int, const QString &message, bool gameDirIntact) {
|
|
|
|
|
|
invalidateCatalog();
|
|
|
|
|
|
emit installedVersionsChanged();
|
|
|
|
|
|
emit switchChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-08-31 00:59:14 +03:00
|
|
|
|
// Раскатка пака и смена сборки приходят одним сигналом, а
|
|
|
|
|
|
// сказать пользователю надо разное: после сорванного
|
|
|
|
|
|
// переключения .minecraft лежит разобранной, а сорванная
|
|
|
|
|
|
// раскатка означает лишь наполовину обновлённую сборку.
|
|
|
|
|
|
if (m_season.stage == SeasonStage::Applying) {
|
|
|
|
|
|
emit launchError(gameDirIntact
|
2026-09-03 09:18:48 +03:00
|
|
|
|
? Loc::text("build.error.filesNotInstalled").arg(message)
|
|
|
|
|
|
: Loc::text("build.error.filesIncomplete")
|
2026-08-31 00:59:14 +03:00
|
|
|
|
.arg(message));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
emit launchError(gameDirIntact
|
2026-09-03 09:18:48 +03:00
|
|
|
|
? Loc::text("build.error.switchFailed").arg(message)
|
|
|
|
|
|
: Loc::text("build.error.switchFailedDirty")
|
2026-08-31 00:59:14 +03:00
|
|
|
|
.arg(message));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_season.stage == SeasonStage::Switching
|
|
|
|
|
|
|| m_season.stage == SeasonStage::Applying) {
|
|
|
|
|
|
seasonFail({});
|
|
|
|
|
|
}
|
2026-08-27 09:21:51 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("loader.progress.installingShort").arg(label));
|
2026-08-27 09:21:51 +03:00
|
|
|
|
});
|
|
|
|
|
|
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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("loader.status.done")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(producedVersionId, gameVersion));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Loader)
|
|
|
|
|
|
seasonAdvance();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
});
|
|
|
|
|
|
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
|
|
|
|
|
|
[this](const QString &label, const QString &message) {
|
|
|
|
|
|
m_loaderInstallBuildId = -1;
|
|
|
|
|
|
invalidateCatalog();
|
|
|
|
|
|
emit installedVersionsChanged();
|
|
|
|
|
|
emit busyChanged();
|
|
|
|
|
|
emit downloadChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.status.pair").arg(label, message));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Loader)
|
|
|
|
|
|
seasonFail({});
|
2026-08-27 09:21:51 +03:00
|
|
|
|
});
|
|
|
|
|
|
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
|
|
|
|
|
|
m_loaderInstallBuildId = -1;
|
|
|
|
|
|
invalidateCatalog();
|
|
|
|
|
|
emit installedVersionsChanged();
|
|
|
|
|
|
emit busyChanged();
|
|
|
|
|
|
emit downloadChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("loader.status.installCancelled").arg(label));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Loader)
|
|
|
|
|
|
seasonFail({});
|
2026-08-27 09:21:51 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
|
|
|
|
|
|
invalidateCatalog();
|
|
|
|
|
|
emit installedVersionsChanged();
|
|
|
|
|
|
emit downloadChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("version.status.downloadCancelled").arg(id));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("seasonal.progress.downloadingBuild").arg(label));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
});
|
|
|
|
|
|
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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("build.status.named").arg(label, message));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Downloading)
|
|
|
|
|
|
seasonFail({});
|
|
|
|
|
|
});
|
|
|
|
|
|
connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) {
|
|
|
|
|
|
emit downloadChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("seasonal.status.downloadCancelled").arg(label));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (m_season.stage == SeasonStage::Downloading)
|
|
|
|
|
|
seasonFail({});
|
2026-08-24 21:09:52 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
|
|
|
|
|
if (!m_storageIssues.isEmpty()) {
|
|
|
|
|
|
QTimer::singleShot(0, this, [this] {
|
|
|
|
|
|
emit launchError(m_storageIssues.join(u'\n'));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-09 00:43:06 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
LauncherBackend::~LauncherBackend() = default;
|
|
|
|
|
|
|
2026-06-09 00:43:06 +03:00
|
|
|
|
QStringList LauncherBackend::profileNames() const
|
|
|
|
|
|
{
|
|
|
|
|
|
QStringList names;
|
|
|
|
|
|
for (const auto &p : m_profiles)
|
|
|
|
|
|
names << p.name;
|
|
|
|
|
|
return names;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
QStringList LauncherBackend::customBuildNames() const
|
2026-06-09 00:43:06 +03:00
|
|
|
|
{
|
|
|
|
|
|
QStringList names;
|
2026-08-27 09:21:51 +03:00
|
|
|
|
for (const auto &b : m_builds)
|
|
|
|
|
|
names << b.name;
|
2026-06-09 00:43:06 +03:00
|
|
|
|
return names;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
QStringList LauncherBackend::installedVersions() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return VersionLoader::installedVersions(gameDir());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool LauncherBackend::gameRunning() const
|
2026-06-09 00:43:06 +03:00
|
|
|
|
{
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return m_launcher->isRunning();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::addProfile(const QString &name,
|
|
|
|
|
|
const QString &login,
|
|
|
|
|
|
const QString &password,
|
|
|
|
|
|
const QString &authType)
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile profile;
|
|
|
|
|
|
profile.id = m_nextProfileId++;
|
|
|
|
|
|
profile.name = name;
|
|
|
|
|
|
profile.login = login;
|
|
|
|
|
|
profile.password = password;
|
2026-09-01 22:25:23 +03:00
|
|
|
|
profile.authType = normalizedAuthType(authType);
|
2026-08-22 18:19:13 +03:00
|
|
|
|
profile.clientToken = AuthService::generateClientToken();
|
|
|
|
|
|
m_profiles.append(profile);
|
2026-06-09 00:43:06 +03:00
|
|
|
|
saveProfiles();
|
|
|
|
|
|
emit profilesChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
void LauncherBackend::addCustomBuild(const QString &name,
|
|
|
|
|
|
const QString &serverUrl,
|
|
|
|
|
|
const QString &minecraftVersion)
|
2026-06-09 00:43:06 +03:00
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
CustomBuild build;
|
|
|
|
|
|
build.id = m_nextBuildId++;
|
|
|
|
|
|
build.name = name;
|
|
|
|
|
|
build.serverUrl = serverUrl;
|
|
|
|
|
|
build.minecraftVersion = minecraftVersion;
|
|
|
|
|
|
m_builds.append(build);
|
|
|
|
|
|
saveCustomBuilds();
|
|
|
|
|
|
emit customBuildsChanged();
|
|
|
|
|
|
maybeInstall(launchVersionIdOf(m_builds.last()));
|
2026-06-09 00:43:06 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
void LauncherBackend::updateProfile(int index,
|
|
|
|
|
|
const QString &name,
|
|
|
|
|
|
const QString &login,
|
|
|
|
|
|
const QString &password,
|
|
|
|
|
|
const QString &authType)
|
2026-06-16 12:16:59 +03:00
|
|
|
|
{
|
|
|
|
|
|
if (index < 0 || index >= m_profiles.size())
|
|
|
|
|
|
return;
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
|
|
|
|
|
Profile &profile = m_profiles[index];
|
2026-09-01 22:25:23 +03:00
|
|
|
|
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) {
|
2026-08-22 18:19:13 +03:00
|
|
|
|
profile.accessToken.clear();
|
2026-09-01 22:25:23 +03:00
|
|
|
|
profile.refreshToken.clear();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
profile.uuid.clear();
|
|
|
|
|
|
profile.resolvedName.clear();
|
2026-09-01 22:25:23 +03:00
|
|
|
|
profile.xuid.clear();
|
|
|
|
|
|
profile.tokenExpiry.clear();
|
|
|
|
|
|
profile.userType.clear();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
}
|
|
|
|
|
|
profile.name = name;
|
|
|
|
|
|
profile.login = login;
|
|
|
|
|
|
profile.password = password;
|
2026-09-01 22:25:23 +03:00
|
|
|
|
profile.authType = type;
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (profile.clientToken.isEmpty())
|
|
|
|
|
|
profile.clientToken = AuthService::generateClientToken();
|
|
|
|
|
|
|
2026-06-16 12:16:59 +03:00
|
|
|
|
saveProfiles();
|
|
|
|
|
|
emit profilesChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
|
2026-06-16 12:16:59 +03:00
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (index < 0 || index >= m_builds.size())
|
2026-06-16 12:16:59 +03:00
|
|
|
|
return;
|
2026-08-27 09:21:51 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-08-31 00:59:14 +03:00
|
|
|
|
take("seasonalId", build.seasonalId);
|
|
|
|
|
|
if (fields.contains(QStringLiteral("seasonalRevision")))
|
|
|
|
|
|
build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
|
|
|
|
|
|
if (build.minecraftVersion != previousMinecraft
|
|
|
|
|
|
&& !fields.contains(QStringLiteral("loaderVersion"))) {
|
|
|
|
|
|
build.loaderVersion.clear();
|
|
|
|
|
|
build.resolvedVersionId.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
saveCustomBuilds();
|
|
|
|
|
|
emit customBuildsChanged();
|
|
|
|
|
|
emit activeBuildChanged(); // имя активной сборки могло измениться
|
|
|
|
|
|
// maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу
|
|
|
|
|
|
// правки, и загрузка стартовала бы почти на каждое действие. Для этого есть
|
|
|
|
|
|
// installCustomBuild().
|
2026-06-16 12:16:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QVariantMap LauncherBackend::profileAt(int index) const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (index < 0 || index >= m_profiles.size())
|
|
|
|
|
|
return {};
|
|
|
|
|
|
const auto &p = m_profiles[index];
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return {{"name", p.name},
|
|
|
|
|
|
{"login", p.login},
|
|
|
|
|
|
{"password", p.password},
|
2026-09-01 22:25:23 +03:00
|
|
|
|
{"authType", p.authType},
|
|
|
|
|
|
{"resolvedName", p.resolvedName},
|
|
|
|
|
|
{"hasMicrosoftSession", !p.refreshToken.isEmpty()}};
|
2026-06-16 12:16:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
QVariantMap LauncherBackend::customBuildAt(int index) const
|
2026-06-16 12:16:59 +03:00
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (index < 0 || index >= m_builds.size())
|
2026-06-16 12:16:59 +03:00
|
|
|
|
return {};
|
2026-08-27 09:21:51 +03:00
|
|
|
|
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},
|
2026-08-31 00:59:14 +03:00
|
|
|
|
{"seasonalId", b.seasonalId},
|
|
|
|
|
|
{"seasonalRevision", b.seasonalRevision},
|
2026-08-27 09:21:51 +03:00
|
|
|
|
{"launchVersionId", launchVersionIdOf(b)}};
|
2026-06-16 12:16:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 00:22:27 +03:00
|
|
|
|
void LauncherBackend::removeProfile(int index)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (index < 0 || index >= m_profiles.size())
|
|
|
|
|
|
return;
|
|
|
|
|
|
m_profiles.removeAt(index);
|
|
|
|
|
|
saveProfiles();
|
|
|
|
|
|
emit profilesChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 16:44:42 +03:00
|
|
|
|
QVariantMap LauncherBackend::customBuildRemovalInfo(int index) const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (index < 0 || index >= m_builds.size())
|
|
|
|
|
|
return {};
|
|
|
|
|
|
const CustomBuild &build = m_builds[index];
|
|
|
|
|
|
const bool active = m_settings.value(QStringLiteral("activeBuildId")).toInt() == build.id
|
|
|
|
|
|
|| m_builds.size() == 1;
|
|
|
|
|
|
return {{QStringLiteral("name"), build.name},
|
|
|
|
|
|
{QStringLiteral("hasArchive"), m_switcher->hasArchive(build.id)},
|
|
|
|
|
|
{QStringLiteral("active"), active},
|
|
|
|
|
|
{QStringLiteral("lastOne"), m_builds.size() == 1}};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
void LauncherBackend::removeCustomBuild(int index)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (index < 0 || index >= m_builds.size())
|
|
|
|
|
|
return;
|
2026-08-27 16:44:42 +03:00
|
|
|
|
|
|
|
|
|
|
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|
|
|
|
|
|
|| m_loaderInstaller->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.busyOperation"));
|
2026-08-27 16:44:42 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_launcher->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.closeGameFirst"));
|
2026-08-27 16:44:42 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CustomBuild removed = m_builds.takeAt(index);
|
|
|
|
|
|
const bool wasActive = m_settings.value(QStringLiteral("activeBuildId")).toInt() == removed.id
|
|
|
|
|
|
|| m_builds.isEmpty();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
saveCustomBuilds();
|
2026-08-27 16:44:42 +03:00
|
|
|
|
|
|
|
|
|
|
// Раньше удалялась только запись, а архив сборки оставался лежать в
|
|
|
|
|
|
// galeonLauncher/builds вместе со ссылкой на него в index.json.
|
|
|
|
|
|
if (!m_switcher->forgetBuild(removed.id)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("build.warning.archiveNotRemoved")
|
2026-08-27 16:44:42 +03:00
|
|
|
|
.arg(removed.name,
|
|
|
|
|
|
QDir::toNativeSeparators(LauncherPaths::buildStorageDir())));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
emit customBuildsChanged();
|
|
|
|
|
|
emit activeBuildChanged();
|
2026-08-27 16:44:42 +03:00
|
|
|
|
|
|
|
|
|
|
if (!wasActive)
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
// Удалили активную сборку: в .minecraft лежит её содержимое, а активной
|
|
|
|
|
|
// становится другая. Оставить как есть нельзя — при следующем переключении
|
|
|
|
|
|
// чужое содержимое уехало бы в архив новой сборки поверх её собственного.
|
|
|
|
|
|
m_settings.remove(QStringLiteral("activeBuildId"));
|
|
|
|
|
|
saveSettings();
|
|
|
|
|
|
|
|
|
|
|
|
if (m_builds.isEmpty()) {
|
|
|
|
|
|
emit activeBuildChanged();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CustomBuild &next = m_builds.first();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("build.progress.activating").arg(next.name));
|
2026-08-27 16:44:42 +03:00
|
|
|
|
// from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе
|
|
|
|
|
|
// с ней. Дальше обычная очистка и распаковка новой активной сборки.
|
|
|
|
|
|
m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir());
|
|
|
|
|
|
emit switchChanged();
|
|
|
|
|
|
emit busyChanged();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-15 00:22:27 +03:00
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
const int current = activeBuildIndex();
|
|
|
|
|
|
if (index < 0 || index >= m_builds.size() || index == current)
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
if (m_switcher->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.switchInProgress"));
|
2026-08-27 09:21:51 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_launcher->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("build.error.closeGameFilesMoving"));
|
2026-08-27 09:21:51 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.busyOperation"));
|
2026-06-15 00:22:27 +03:00
|
|
|
|
return;
|
2026-08-27 09:21:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в
|
|
|
|
|
|
// её архив, и только потом на его место разворачивается выбранная.
|
|
|
|
|
|
const CustomBuild &target = m_builds[index];
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("build.progress.switchingTo").arg(target.name));
|
2026-08-27 09:21:51 +03:00
|
|
|
|
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;
|
2026-06-15 00:22:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
void LauncherBackend::installCustomBuild(int index)
|
2026-08-22 18:19:13 +03:00
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
// Установка сборки — это установка её версии игры и, если выбран,
|
|
|
|
|
|
// модлоадера: они ставятся одной цепочкой.
|
|
|
|
|
|
installLoaderForBuild(index);
|
2026-08-22 18:19:13 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const
|
2026-08-22 18:19:13 +03:00
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
// resolvedVersionId появляется после установки модлоадера. Пользователь мог
|
|
|
|
|
|
// назвать сборку как угодно — тогда версией считаем само имя: так работали
|
|
|
|
|
|
// записи, созданные до появления каталога версий.
|
|
|
|
|
|
if (!build.resolvedVersionId.isEmpty())
|
|
|
|
|
|
return build.resolvedVersionId;
|
|
|
|
|
|
return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion;
|
|
|
|
|
|
}
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
QStringList LauncherBackend::checkInstallation(int buildIndex) const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (buildIndex < 0 || buildIndex >= m_builds.size())
|
2026-09-03 09:18:48 +03:00
|
|
|
|
return {Loc::text("common.status.noBuildSelected")};
|
2026-08-27 09:21:51 +03:00
|
|
|
|
|
|
|
|
|
|
const CustomBuild &entry = m_builds[buildIndex];
|
|
|
|
|
|
const QString id = launchVersionIdOf(entry);
|
2026-08-23 03:30:31 +03:00
|
|
|
|
if (id.trimmed().isEmpty())
|
2026-09-03 09:18:48 +03:00
|
|
|
|
return {Loc::text("build.error.noMinecraftVersion")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))};
|
2026-08-23 03:30:31 +03:00
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
QString error;
|
|
|
|
|
|
const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error);
|
2026-08-23 03:30:31 +03:00
|
|
|
|
if (!version.isValid()) {
|
|
|
|
|
|
return {error.isEmpty()
|
2026-09-03 09:18:48 +03:00
|
|
|
|
? Loc::text("build.error.versionNotInstalled")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.arg(id,
|
|
|
|
|
|
entry.name,
|
|
|
|
|
|
QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions")))
|
|
|
|
|
|
: error};
|
|
|
|
|
|
}
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
|
|
|
|
|
LaunchOptions options;
|
|
|
|
|
|
options.gameDir = gameDir();
|
|
|
|
|
|
options.versionId = id;
|
|
|
|
|
|
return GameLauncher::missingFiles(options, version);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
// ── Каталог версий ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::invalidateCatalog()
|
|
|
|
|
|
{
|
|
|
|
|
|
m_catalogDirty = true;
|
|
|
|
|
|
emit versionCatalogChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool LauncherBackend::catalogLoading() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_manifest->isRefreshing();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::refreshVersionCatalog(bool force)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Список установленного мог поменяться и без нас (папку добавили руками).
|
|
|
|
|
|
invalidateCatalog();
|
|
|
|
|
|
m_manifest->ensureLoaded([this](bool ok, const QString &warning) {
|
|
|
|
|
|
if (!ok && !warning.isEmpty())
|
|
|
|
|
|
emit launchError(warning);
|
|
|
|
|
|
}, force);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool LauncherBackend::isVersionInstalled(const QString &versionId) const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (versionId.trimmed().isEmpty())
|
|
|
|
|
|
return false;
|
|
|
|
|
|
return VersionLoader::installedVersions(gameDir()).contains(versionId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 21:05:37 +03:00
|
|
|
|
QVariantMap LauncherBackend::versionRemovalInfo(const QString &versionId) const
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString id = versionId.trimmed();
|
|
|
|
|
|
if (id.isEmpty())
|
|
|
|
|
|
return {};
|
|
|
|
|
|
|
|
|
|
|
|
const qint64 bytes = VersionLoader::installedSize(gameDir(), id);
|
|
|
|
|
|
// Сборки не правим при удалении — ссылка на неустановленную версию это
|
|
|
|
|
|
// рабочее состояние. Но перечислить, кого заденет, надо.
|
|
|
|
|
|
QStringList builds;
|
|
|
|
|
|
for (const CustomBuild &build : m_builds) {
|
|
|
|
|
|
if (build.minecraftVersion == id || build.resolvedVersionId == id
|
|
|
|
|
|
|| launchVersionIdOf(build) == id) {
|
|
|
|
|
|
builds << build.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {{QStringLiteral("id"), id},
|
|
|
|
|
|
{QStringLiteral("installed"), isVersionInstalled(id)},
|
|
|
|
|
|
{QStringLiteral("sizeMb"), static_cast<int>((bytes + 1024 * 1024 - 1) / (1024 * 1024))},
|
|
|
|
|
|
{QStringLiteral("dependents"), VersionLoader::dependentsOf(gameDir(), id)},
|
|
|
|
|
|
{QStringLiteral("builds"), builds}};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::removeVersion(const QString &versionId)
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString id = versionId.trimmed();
|
|
|
|
|
|
if (id.isEmpty())
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
if (m_installer->versionId() == id || m_installer->isQueued(id)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("version.error.currentlyDownloading"));
|
2026-08-27 21:05:37 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
|
|
|
|
|
|
|| m_loaderInstaller->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.busyOperation"));
|
2026-08-27 21:05:37 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Какой версией сейчас играют, GameLauncher наружу не отдаёт, поэтому на
|
|
|
|
|
|
// время игры удаление запрещено целиком.
|
|
|
|
|
|
if (m_launcher->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.closeGameFirst"));
|
2026-08-27 21:05:37 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString error;
|
|
|
|
|
|
if (!VersionLoader::remove(gameDir(), id, &error)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("version.error.removeFailed").arg(error));
|
2026-08-27 21:05:37 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
invalidateCatalog();
|
|
|
|
|
|
emit installedVersionsChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("version.status.removed").arg(id));
|
2026-08-27 21:05:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
QVariantList LauncherBackend::versionCatalog() const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m_catalogDirty) {
|
|
|
|
|
|
rebuildCatalog();
|
|
|
|
|
|
m_catalogDirty = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return m_catalog;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::rebuildCatalog() const
|
|
|
|
|
|
{
|
|
|
|
|
|
m_catalog.clear();
|
|
|
|
|
|
|
2026-08-27 16:44:42 +03:00
|
|
|
|
// Плоский список в порядке манифеста — новые версии сверху. Раскладку по
|
|
|
|
|
|
// категориям и отбор делает окно выбора версии: ему нужен один список,
|
|
|
|
|
|
// который можно фильтровать, а не готовая структура с разделителями.
|
2026-08-24 21:09:52 +03:00
|
|
|
|
const QList<RemoteVersionEntry> remote = m_manifest->versions();
|
2026-08-27 16:44:42 +03:00
|
|
|
|
const QStringList installed = VersionLoader::installedVersions(gameDir());
|
|
|
|
|
|
const QSet<QString> installedSet(installed.cbegin(), installed.cend());
|
|
|
|
|
|
|
|
|
|
|
|
const auto category = [](const QString &type) {
|
|
|
|
|
|
if (type == QLatin1String("release") || type == QLatin1String("snapshot")
|
|
|
|
|
|
|| type == QLatin1String("old_beta") || type == QLatin1String("old_alpha"))
|
|
|
|
|
|
return type;
|
|
|
|
|
|
return QStringLiteral("other");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const auto makeVersionRow = [&category](const QString &id, const QString &type, bool isInstalled) {
|
|
|
|
|
|
return QVariantMap{{QStringLiteral("id"), id},
|
2026-08-24 21:09:52 +03:00
|
|
|
|
{QStringLiteral("type"), type},
|
2026-08-27 16:44:42 +03:00
|
|
|
|
{QStringLiteral("category"), category(type)},
|
2026-08-24 21:09:52 +03:00
|
|
|
|
{QStringLiteral("label"), id},
|
2026-08-27 16:44:42 +03:00
|
|
|
|
{QStringLiteral("installed"), isInstalled},
|
2026-08-24 21:09:52 +03:00
|
|
|
|
{QStringLiteral("search"), (id + u' ' + type).toLower()}};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-27 16:44:42 +03:00
|
|
|
|
m_catalog.reserve(remote.size() + installed.size());
|
|
|
|
|
|
QSet<QString> known;
|
|
|
|
|
|
known.reserve(remote.size());
|
|
|
|
|
|
for (const RemoteVersionEntry &entry : remote) {
|
|
|
|
|
|
known.insert(entry.id);
|
|
|
|
|
|
m_catalog.append(makeVersionRow(entry.id, entry.type, installedSet.contains(entry.id)));
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 16:44:42 +03:00
|
|
|
|
// Сборки модлоадеров манифест не знает — они попадают в «Прочие».
|
|
|
|
|
|
QStringList unknown;
|
|
|
|
|
|
for (const QString &id : installed) {
|
|
|
|
|
|
if (!known.contains(id))
|
|
|
|
|
|
unknown << id;
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
2026-08-27 16:44:42 +03:00
|
|
|
|
std::sort(unknown.begin(), unknown.end());
|
|
|
|
|
|
for (const QString &id : std::as_const(unknown))
|
|
|
|
|
|
m_catalog.append(makeVersionRow(id, QString(), true));
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Загрузка версий ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::maybeInstall(const QString &versionId)
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString id = versionId.trimmed();
|
|
|
|
|
|
if (id.isEmpty() || isVersionInstalled(id))
|
|
|
|
|
|
return;
|
|
|
|
|
|
if (m_installer->versionId() == id || m_installer->isQueued(id))
|
|
|
|
|
|
return;
|
|
|
|
|
|
// Манифест здесь не спрашиваем: установщик сам скажет, что версии в нём нет,
|
|
|
|
|
|
// и это правильный ответ на вручную вписанный id сборки.
|
|
|
|
|
|
m_installer->install(gameDir(), id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::installVersion(const QString &versionId)
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString id = versionId.trimmed();
|
|
|
|
|
|
if (id.isEmpty())
|
|
|
|
|
|
return;
|
|
|
|
|
|
if (m_installer->versionId() == id || m_installer->isQueued(id))
|
|
|
|
|
|
return;
|
|
|
|
|
|
m_installer->install(gameDir(), id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::cancelDownload()
|
|
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
// Установщик лоадера сам остановит вложенную установку версии.
|
|
|
|
|
|
if (m_loaderInstaller->isRunning())
|
|
|
|
|
|
m_loaderInstaller->cancel();
|
2026-08-27 19:35:11 +03:00
|
|
|
|
else if (m_installer->isRunning())
|
2026-08-27 09:21:51 +03:00
|
|
|
|
m_installer->cancel();
|
2026-08-31 00:59:14 +03:00
|
|
|
|
else if (m_javaInstaller->isRunning())
|
2026-08-27 19:35:11 +03:00
|
|
|
|
m_javaInstaller->cancel();
|
2026-08-31 00:59:14 +03:00
|
|
|
|
else
|
|
|
|
|
|
m_packDownloader->cancel();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 19:35:11 +03:00
|
|
|
|
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
|
|
|
|
|
|
// показывает его стадии, включая вложенную загрузку файлов версии. Установка
|
|
|
|
|
|
// Java начинается только когда остальные не заняты, поэтому за панель они не спорят.
|
2026-08-24 21:09:52 +03:00
|
|
|
|
bool LauncherBackend::downloading() const
|
|
|
|
|
|
{
|
2026-08-27 19:35:11 +03:00
|
|
|
|
return m_loaderInstaller->isRunning() || m_installer->isRunning()
|
2026-08-31 00:59:14 +03:00
|
|
|
|
|| m_javaInstaller->isRunning() || m_packDownloader->isRunning();
|
2026-08-27 19:35:11 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
|
|
|
|
|
|
bool LauncherBackend::javaDownloadShown() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_javaInstaller->isRunning() && !m_installer->isRunning()
|
|
|
|
|
|
&& !m_loaderInstaller->isRunning();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 00:59:14 +03:00
|
|
|
|
// Пак качается отдельным шагом цепочки, когда остальные установщики уже
|
|
|
|
|
|
// отработали, — за панель они не спорят.
|
|
|
|
|
|
bool LauncherBackend::packDownloadShown() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_packDownloader->isRunning() && !m_installer->isRunning()
|
|
|
|
|
|
&& !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
double LauncherBackend::downloadProgress() const
|
|
|
|
|
|
{
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (packDownloadShown())
|
|
|
|
|
|
return m_packDownloader->fraction();
|
2026-08-27 19:35:11 +03:00
|
|
|
|
if (javaDownloadShown())
|
|
|
|
|
|
return m_javaInstaller->fraction();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (!m_loaderInstaller->isRunning())
|
|
|
|
|
|
return m_installer->fraction();
|
|
|
|
|
|
// Внутри установки лоадера файлы качает VersionInstaller — его доля точнее.
|
|
|
|
|
|
return m_installer->isRunning() ? m_installer->fraction() : m_loaderInstaller->fraction();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString LauncherBackend::downloadVersion() const
|
|
|
|
|
|
{
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (packDownloadShown())
|
|
|
|
|
|
return m_packDownloader->label();
|
2026-08-27 19:35:11 +03:00
|
|
|
|
if (javaDownloadShown())
|
|
|
|
|
|
return m_javaInstaller->label();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (m_loaderInstaller->isRunning())
|
|
|
|
|
|
return m_loaderInstaller->label();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
return m_installer->versionId();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString LauncherBackend::downloadStatus() const
|
|
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
QString stage = m_installer->stage();
|
|
|
|
|
|
QString file = m_installer->currentFile();
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (packDownloadShown()) {
|
|
|
|
|
|
stage = m_packDownloader->stage();
|
|
|
|
|
|
file = m_packDownloader->currentFile();
|
|
|
|
|
|
} else if (javaDownloadShown()) {
|
2026-08-27 19:35:11 +03:00
|
|
|
|
stage = m_javaInstaller->stage();
|
|
|
|
|
|
file = m_javaInstaller->currentFile();
|
|
|
|
|
|
} else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
|
2026-08-27 09:21:51 +03:00
|
|
|
|
stage = m_loaderInstaller->stage();
|
|
|
|
|
|
file = m_loaderInstaller->currentFile();
|
|
|
|
|
|
}
|
2026-08-24 21:09:52 +03:00
|
|
|
|
if (stage.isEmpty())
|
|
|
|
|
|
return file;
|
|
|
|
|
|
if (file.isEmpty())
|
|
|
|
|
|
return stage;
|
|
|
|
|
|
return QStringLiteral("%1 — %2").arg(stage, file);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
qint64 LauncherBackend::downloadBytesDone() const
|
|
|
|
|
|
{
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (packDownloadShown())
|
|
|
|
|
|
return m_packDownloader->bytesDone();
|
2026-08-27 19:35:11 +03:00
|
|
|
|
if (javaDownloadShown())
|
|
|
|
|
|
return m_javaInstaller->bytesDone();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
|
|
|
|
|
return m_loaderInstaller->bytesDone();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
return m_installer->bytesDone();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
qint64 LauncherBackend::downloadBytesTotal() const
|
|
|
|
|
|
{
|
2026-08-31 00:59:14 +03:00
|
|
|
|
if (packDownloadShown())
|
|
|
|
|
|
return m_packDownloader->bytesTotal();
|
2026-08-27 19:35:11 +03:00
|
|
|
|
if (javaDownloadShown())
|
|
|
|
|
|
return m_javaInstaller->bytesTotal();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
|
|
|
|
|
return m_loaderInstaller->bytesTotal();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
return m_installer->bytesTotal();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
// ── Модлоадеры ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-03 09:18:48 +03:00
|
|
|
|
label += Loc::text("loader.label.recommended");
|
2026-08-27 09:21:51 +03:00
|
|
|
|
else if (!entry.stable)
|
2026-09-03 09:18:48 +03:00
|
|
|
|
label += Loc::text("loader.label.unstable");
|
2026-08-27 09:21:51 +03:00
|
|
|
|
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()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.busyDownload"));
|
2026-08-27 09:21:51 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CustomBuild &build = m_builds[index];
|
|
|
|
|
|
if (build.minecraftVersion.trimmed().isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("build.error.pickMinecraftVersion").arg(build.name));
|
2026-08-27 09:21:51 +03:00
|
|
|
|
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()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("build.error.pickLoaderVersion")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(loaderTitle(*loader), build.name));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
m_loaderInstallBuildId = build.id;
|
|
|
|
|
|
m_loaderInstaller->install(gameDir(),
|
|
|
|
|
|
*loader,
|
|
|
|
|
|
build.minecraftVersion,
|
|
|
|
|
|
build.loaderVersion,
|
2026-08-27 19:35:11 +03:00
|
|
|
|
managedJavaPath().isEmpty()
|
|
|
|
|
|
? m_settings.value(QStringLiteral("javaPath")).toString()
|
|
|
|
|
|
: managedJavaPath());
|
2026-08-27 09:21:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::launchGame(int profileIndex, int buildIndex)
|
2026-06-09 00:43:06 +03:00
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (m_loaderInstaller->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("build.error.installBusy")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(m_loaderInstaller->label()));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-24 21:09:52 +03:00
|
|
|
|
if (m_installer->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("version.error.downloadBusy")
|
2026-08-24 21:09:52 +03:00
|
|
|
|
.arg(m_installer->versionId()));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-27 19:35:11 +03:00
|
|
|
|
// Игра ищет java при запуске: стартовать посреди её установки — значит
|
|
|
|
|
|
// найти половину распакованного дерева.
|
|
|
|
|
|
if (m_javaInstaller->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("java.error.installBusy")
|
2026-08-27 19:35:11 +03:00
|
|
|
|
.arg(m_javaInstaller->label()));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (m_busy) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.error.alreadyLaunching"));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_launcher->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.gameRunning"));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-09 00:43:06 +03:00
|
|
|
|
if (profileIndex < 0 || profileIndex >= m_profiles.size()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.error.noProfile"));
|
2026-06-09 00:43:06 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (buildIndex < 0 || buildIndex >= m_builds.size()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.error.noBuild"));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
// Дальше проверяется только папка игры: свои файлы лаунчер уже разложил
|
|
|
|
|
|
// в galeonLauncher при запуске.
|
2026-08-22 18:19:13 +03:00
|
|
|
|
const QString directory = gameDir();
|
|
|
|
|
|
if (!QDir(directory).exists()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.error.gameDirMissing")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.arg(QDir::toNativeSeparators(directory)));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
const CustomBuild &entry = m_builds[buildIndex];
|
|
|
|
|
|
const QString id = launchVersionIdOf(entry);
|
2026-08-23 03:30:31 +03:00
|
|
|
|
if (id.trimmed().isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.error.buildNoVersion")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(entry.name,
|
|
|
|
|
|
QDir::toNativeSeparators(LauncherPaths::customBuildsFile())));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
const QString versionsDir = QDir::toNativeSeparators(directory + QStringLiteral("/versions"));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
const QStringList installed = VersionLoader::installedVersions(directory);
|
|
|
|
|
|
if (!installed.contains(id)) {
|
2026-08-22 23:39:46 +00:00
|
|
|
|
emit launchError(installed.isEmpty()
|
2026-09-03 09:18:48 +03:00
|
|
|
|
? Loc::text("launch.error.noVersionsInstalled").arg(versionsDir)
|
|
|
|
|
|
: Loc::text("launch.error.versionNotInstalled")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.arg(id,
|
|
|
|
|
|
entry.name,
|
|
|
|
|
|
versionsDir,
|
|
|
|
|
|
installed.join(QStringLiteral(", "))));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setBusy(true);
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("launch.progress.readingVersion").arg(id));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
|
|
|
|
|
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()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
failLaunch(error.isEmpty() ? Loc::text("common.error.versionReadFailed").arg(id) : error);
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LaunchOptions probe;
|
|
|
|
|
|
probe.gameDir = directory;
|
|
|
|
|
|
probe.versionId = id;
|
|
|
|
|
|
const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData);
|
|
|
|
|
|
if (!missing.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
failLaunch(Loc::text("launch.error.gameFilesMissing")
|
2026-08-22 18:19:13 +03:00
|
|
|
|
.arg(missing.size())
|
|
|
|
|
|
.arg(missing.join(u'\n')));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
m_pendingProfile = profileIndex;
|
2026-08-27 09:21:51 +03:00
|
|
|
|
// Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из
|
|
|
|
|
|
// модального окна, и индекс уехал бы на чужую запись.
|
|
|
|
|
|
m_pendingBuild = entry.id;
|
2026-08-22 18:19:13 +03:00
|
|
|
|
m_pendingInjectorPath.clear();
|
|
|
|
|
|
beginAuthentication();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 22:25:23 +03:00
|
|
|
|
void LauncherBackend::startMicrosoftLogin(int profileIndex)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!microsoftAvailable()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit microsoftLoginFailed(Loc::text("auth.msa.error.noWebEngine"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать
|
|
|
|
|
|
// это состояние от закрытого окна.
|
|
|
|
|
|
if (profileIndex >= m_profiles.size()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit microsoftLoginFailed(Loc::text("profile.error.notFound"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
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) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit microsoftLoginFailed(Loc::text("auth.msa.status.alreadyCancelled"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
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 {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit microsoftLoginFailed(Loc::text("profile.error.removedDuringLogin"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
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;
|
2026-09-03 09:18:48 +03:00
|
|
|
|
failLaunch(Loc::text("auth.msa.error.profileNeedsLogin")
|
2026-09-01 22:25:23 +03:00
|
|
|
|
.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;
|
2026-09-03 09:18:48 +03:00
|
|
|
|
failLaunch(Loc::text("auth.msa.error.sessionExpired")
|
2026-09-01 22:25:23 +03:00
|
|
|
|
.arg(result.error));
|
|
|
|
|
|
emit microsoftReloginRequired(index);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
continueLaunch(result);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
void LauncherBackend::beginAuthentication()
|
|
|
|
|
|
{
|
|
|
|
|
|
const Profile &profile = m_profiles[m_pendingProfile];
|
|
|
|
|
|
|
2026-09-01 22:25:23 +03:00
|
|
|
|
if (profile.authType == kMicrosoft) {
|
|
|
|
|
|
beginMicrosoftAuthentication();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (profile.authType != kElyBy) {
|
|
|
|
|
|
const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
|
|
|
|
|
|
const AuthResult result = AuthService::offline(nickname);
|
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
|
failLaunch(result.error);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
continueLaunch(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ely.by работает только вместе с authlib-injector — без него игра пойдёт
|
|
|
|
|
|
// в сессионный сервер Mojang и токен окажется недействительным.
|
2026-08-23 03:30:31 +03:00
|
|
|
|
m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(),
|
|
|
|
|
|
[this](const QString &path, const QString &error) {
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (m_pendingProfile < 0)
|
2026-08-22 23:39:46 +00:00
|
|
|
|
return; // запуск отменили, пока качался инжектор
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (!error.isEmpty()) {
|
|
|
|
|
|
failLaunch(error);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
m_pendingInjectorPath = path;
|
|
|
|
|
|
|
|
|
|
|
|
const Profile &pending = m_profiles[m_pendingProfile];
|
|
|
|
|
|
m_auth->loginElyBy(pending.login,
|
|
|
|
|
|
pending.password,
|
|
|
|
|
|
pending.clientToken,
|
|
|
|
|
|
pending.accessToken,
|
|
|
|
|
|
[this](const AuthResult &result) {
|
|
|
|
|
|
if (m_pendingProfile < 0)
|
|
|
|
|
|
return;
|
|
|
|
|
|
if (result.twoFactorRequired) {
|
|
|
|
|
|
setBusy(false);
|
|
|
|
|
|
emit twoFactorRequired(m_profiles[m_pendingProfile].name);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
|
failLaunch(result.error);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
continueLaunch(result);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::submitTwoFactorCode(const QString &code)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.status.alreadyCancelled"));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (code.trimmed().isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("auth.ely.error.enterCode"));
|
2026-06-09 00:43:06 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
setBusy(true);
|
|
|
|
|
|
const Profile &profile = m_profiles[m_pendingProfile];
|
|
|
|
|
|
m_auth->loginElyByWithTotp(profile.login,
|
|
|
|
|
|
profile.password,
|
|
|
|
|
|
code.trimmed(),
|
|
|
|
|
|
profile.clientToken,
|
|
|
|
|
|
[this](const AuthResult &result) {
|
|
|
|
|
|
if (m_pendingProfile < 0)
|
|
|
|
|
|
return;
|
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
|
failLaunch(result.error);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
continueLaunch(result);
|
|
|
|
|
|
});
|
2026-06-09 00:43:06 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
void LauncherBackend::cancelPendingLaunch()
|
|
|
|
|
|
{
|
|
|
|
|
|
m_pendingProfile = -1;
|
2026-08-27 09:21:51 +03:00
|
|
|
|
m_pendingBuild = -1;
|
2026-08-22 18:19:13 +03:00
|
|
|
|
m_pendingInjectorPath.clear();
|
|
|
|
|
|
setBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::continueLaunch(const AuthResult &auth)
|
|
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
failLaunch(Loc::text("launch.status.cancelled"));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
const int buildIndex = indexOfBuildId(m_pendingBuild);
|
|
|
|
|
|
if (buildIndex < 0) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
failLaunch(Loc::text("build.error.removedDuringAuth"));
|
2026-08-27 09:21:51 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
Profile &profile = m_profiles[m_pendingProfile];
|
2026-08-27 09:21:51 +03:00
|
|
|
|
const CustomBuild &build = m_builds[buildIndex];
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
|
|
|
|
|
// Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем,
|
|
|
|
|
|
// чтобы в следующий раз обойтись без пароля.
|
2026-09-01 22:25:23 +03:00
|
|
|
|
if (profile.authType == kMicrosoft && auth.ok) {
|
|
|
|
|
|
applyMicrosoftResult(profile, auth);
|
|
|
|
|
|
saveProfiles();
|
|
|
|
|
|
} else if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
|
2026-08-22 18:19:13 +03:00
|
|
|
|
profile.accessToken = auth.accessToken;
|
|
|
|
|
|
profile.clientToken = auth.clientToken;
|
|
|
|
|
|
if (!auth.uuid.isEmpty())
|
|
|
|
|
|
profile.uuid = auth.uuid;
|
|
|
|
|
|
if (!auth.playerName.isEmpty())
|
|
|
|
|
|
profile.resolvedName = auth.playerName;
|
|
|
|
|
|
profile.userType = auth.userType;
|
|
|
|
|
|
saveProfiles();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LaunchOptions options;
|
|
|
|
|
|
options.gameDir = gameDir();
|
|
|
|
|
|
options.versionId = m_pendingVersionData.id;
|
|
|
|
|
|
options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName;
|
|
|
|
|
|
if (options.playerName.isEmpty())
|
|
|
|
|
|
options.playerName = profile.login.isEmpty() ? profile.name : profile.login;
|
|
|
|
|
|
options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid;
|
|
|
|
|
|
options.accessToken = auth.accessToken;
|
|
|
|
|
|
options.clientToken = auth.clientToken;
|
|
|
|
|
|
options.userType = auth.userType;
|
2026-09-01 22:25:23 +03:00
|
|
|
|
options.xuid = auth.xuid;
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
2026-08-27 19:35:11 +03:00
|
|
|
|
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
|
|
|
|
|
|
// окне выбора версии Java.
|
|
|
|
|
|
options.javaPath = managedJavaPath();
|
|
|
|
|
|
if (options.javaPath.isEmpty()) {
|
|
|
|
|
|
if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty())
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("java.warning.runtimeMissing"));
|
2026-08-27 19:35:11 +03:00
|
|
|
|
options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString();
|
|
|
|
|
|
}
|
2026-08-22 18:19:13 +03:00
|
|
|
|
options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512);
|
|
|
|
|
|
options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096);
|
|
|
|
|
|
options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0);
|
|
|
|
|
|
options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0);
|
|
|
|
|
|
options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
options.serverAddress = build.serverUrl.trimmed();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
|
|
|
|
|
|
options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
|
|
|
|
|
|
|
|
|
|
|
|
const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed();
|
|
|
|
|
|
if (!extra.isEmpty())
|
|
|
|
|
|
options.extraJvmArgs = extra.split(QRegularExpression(QStringLiteral("\\s+")),
|
|
|
|
|
|
Qt::SkipEmptyParts);
|
|
|
|
|
|
|
|
|
|
|
|
if (profile.authType == kElyBy)
|
|
|
|
|
|
options.authlibInjectorPath = m_pendingInjectorPath;
|
|
|
|
|
|
|
|
|
|
|
|
QString error;
|
|
|
|
|
|
const bool started = m_launcher->launch(options, m_pendingVersionData, &error);
|
|
|
|
|
|
|
|
|
|
|
|
const QString profileName = profile.name;
|
2026-08-27 09:21:51 +03:00
|
|
|
|
const QString buildName = build.name;
|
|
|
|
|
|
const QString serverUrl = build.serverUrl;
|
2026-08-22 18:19:13 +03:00
|
|
|
|
cancelPendingLaunch();
|
|
|
|
|
|
|
|
|
|
|
|
if (!started) {
|
|
|
|
|
|
emit launchError(error);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
emit gameRunningChanged();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
emit launched(profileName, buildName, serverUrl);
|
2026-08-22 18:19:13 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::stopGame()
|
|
|
|
|
|
{
|
|
|
|
|
|
m_launcher->terminate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::failLaunch(const QString &message)
|
|
|
|
|
|
{
|
|
|
|
|
|
cancelPendingLaunch();
|
|
|
|
|
|
emit launchError(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
bool LauncherBackend::switching() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_switcher->isRunning();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
double LauncherBackend::switchProgress() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_switcher->fraction();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString LauncherBackend::switchStage() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_switcher->stage();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString LauncherBackend::switchStatus() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_switcher->status();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
bool LauncherBackend::busy() const
|
|
|
|
|
|
{
|
2026-08-27 09:21:51 +03:00
|
|
|
|
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
|
2026-08-31 00:59:14 +03:00
|
|
|
|
|| m_javaInstaller->isRunning() || m_switcher->isRunning()
|
|
|
|
|
|
|| m_packDownloader->isRunning();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 22:25:23 +03:00
|
|
|
|
bool LauncherBackend::microsoftAvailable() const
|
|
|
|
|
|
{
|
|
|
|
|
|
#ifdef LAUNCHER_HAS_WEBENGINE
|
|
|
|
|
|
return true;
|
|
|
|
|
|
#else
|
|
|
|
|
|
return false;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
void LauncherBackend::setBusy(bool busy)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m_busy == busy)
|
|
|
|
|
|
return;
|
|
|
|
|
|
m_busy = busy;
|
|
|
|
|
|
emit busyChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString LauncherBackend::gameDir() const
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed();
|
2026-08-23 03:30:31 +03:00
|
|
|
|
return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir()
|
|
|
|
|
|
: QDir::fromNativeSeparators(configured);
|
2026-08-22 18:19:13 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QVariantMap LauncherBackend::settings() const
|
|
|
|
|
|
{
|
|
|
|
|
|
QVariantMap result = m_settings;
|
|
|
|
|
|
result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir());
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::updateSettings(const QVariantMap &values)
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString previousGameDir = gameDir();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
const QString previousLanguage = m_settings.value(QStringLiteral("language")).toString();
|
|
|
|
|
|
|
2026-08-22 18:19:13 +03:00
|
|
|
|
for (auto it = values.cbegin(); it != values.cend(); ++it)
|
|
|
|
|
|
m_settings.insert(it.key(), it.value());
|
|
|
|
|
|
saveSettings();
|
|
|
|
|
|
emit settingsChanged();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
|
|
|
|
|
|
// Язык переключается после записи: порядок «сохранили → переключили»
|
|
|
|
|
|
// гарантирует, что выбор переживёт падение сразу после переключения.
|
|
|
|
|
|
// Сам Localization в settings.json не пишет — файл ведёт только бэкенд.
|
|
|
|
|
|
const QString language = m_settings.value(QStringLiteral("language")).toString();
|
|
|
|
|
|
if (language != previousLanguage)
|
|
|
|
|
|
Localization::instance().setLanguage(language);
|
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
|
if (gameDir() != previousGameDir) {
|
|
|
|
|
|
invalidateCatalog();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
emit installedVersionsChanged();
|
2026-08-24 21:09:52 +03:00
|
|
|
|
}
|
2026-08-22 18:19:13 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QStringList LauncherBackend::detectedJava() const
|
|
|
|
|
|
{
|
|
|
|
|
|
QStringList result;
|
|
|
|
|
|
const QStringList candidates = JavaLocator::findAll(gameDir());
|
|
|
|
|
|
for (const QString &candidate : candidates) {
|
|
|
|
|
|
const int major = JavaLocator::majorVersion(candidate);
|
|
|
|
|
|
result << (major > 0 ? QStringLiteral("Java %1 — %2")
|
|
|
|
|
|
.arg(major)
|
|
|
|
|
|
.arg(QDir::toNativeSeparators(candidate))
|
|
|
|
|
|
: QDir::toNativeSeparators(candidate));
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 19:35:11 +03:00
|
|
|
|
// ── Сборки Java ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::invalidateJavaCatalog()
|
|
|
|
|
|
{
|
|
|
|
|
|
m_javaCatalogDirty = true;
|
|
|
|
|
|
emit javaCatalogChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool LauncherBackend::javaCatalogLoading() const
|
|
|
|
|
|
{
|
|
|
|
|
|
return m_javaMeta->isRefreshing();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::refreshJavaCatalog(bool force)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Список установленного мог поменяться и без нас (папку удалили руками).
|
|
|
|
|
|
invalidateJavaCatalog();
|
|
|
|
|
|
m_javaMeta->ensureLoaded([this](bool ok, const QString &warning) {
|
|
|
|
|
|
if (!ok && !warning.isEmpty())
|
|
|
|
|
|
emit launchError(warning);
|
|
|
|
|
|
}, force);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QVariantList LauncherBackend::javaCatalog() const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m_javaCatalogDirty) {
|
|
|
|
|
|
rebuildJavaCatalog();
|
|
|
|
|
|
m_javaCatalogDirty = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return m_javaCatalog;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::rebuildJavaCatalog() const
|
|
|
|
|
|
{
|
|
|
|
|
|
m_javaCatalog.clear();
|
|
|
|
|
|
|
|
|
|
|
|
const QList<InstalledJavaRuntime> installed = JavaRuntimeStore::installed();
|
|
|
|
|
|
QHash<QString, qint64> installedSizes;
|
|
|
|
|
|
for (const InstalledJavaRuntime &runtime : installed)
|
|
|
|
|
|
installedSizes.insert(runtime.id, runtime.size);
|
|
|
|
|
|
|
|
|
|
|
|
const auto megabytes = [](qint64 bytes) {
|
|
|
|
|
|
return bytes > 0 ? int((bytes + 512 * 1024) / (1024 * 1024)) : 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const auto makeRow = [&megabytes](const QString &id,
|
|
|
|
|
|
const QString &kind,
|
|
|
|
|
|
int major,
|
|
|
|
|
|
const QString &version,
|
|
|
|
|
|
const QString &detail,
|
|
|
|
|
|
qint64 size,
|
|
|
|
|
|
bool isInstalled,
|
|
|
|
|
|
bool lts,
|
|
|
|
|
|
bool downloadable) {
|
|
|
|
|
|
return QVariantMap{
|
|
|
|
|
|
{QStringLiteral("id"), id},
|
|
|
|
|
|
{QStringLiteral("kind"), kind},
|
|
|
|
|
|
{QStringLiteral("major"), major},
|
|
|
|
|
|
{QStringLiteral("version"), version},
|
|
|
|
|
|
{QStringLiteral("label"), QStringLiteral("Java %1").arg(version)},
|
|
|
|
|
|
{QStringLiteral("detail"), detail},
|
|
|
|
|
|
{QStringLiteral("coverage"), JavaRequirement::coverage(major)},
|
|
|
|
|
|
{QStringLiteral("sizeMb"), megabytes(size)},
|
|
|
|
|
|
{QStringLiteral("installed"), isInstalled},
|
|
|
|
|
|
{QStringLiteral("lts"), lts},
|
|
|
|
|
|
{QStringLiteral("downloadable"), downloadable},
|
|
|
|
|
|
{QStringLiteral("search"),
|
|
|
|
|
|
QStringLiteral("%1 %2 %3 %4").arg(version, QString::number(major), kind, detail).toLower()}};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Скачанное — сверху: этим уже можно пользоваться прямо сейчас.
|
|
|
|
|
|
QSet<QString> listed;
|
|
|
|
|
|
for (const InstalledJavaRuntime &runtime : installed) {
|
|
|
|
|
|
listed.insert(runtime.id);
|
|
|
|
|
|
const auto remote = m_javaMeta->find(runtime.id);
|
|
|
|
|
|
const QString detail = remote ? (remote->kind == JavaRuntimeKind::Mojang
|
2026-09-03 09:18:48 +03:00
|
|
|
|
? Loc::text("java.label.mojang").arg(remote->component)
|
|
|
|
|
|
: Loc::text("java.label.temurin").arg(remote->architecture))
|
|
|
|
|
|
: Loc::text("java.label.downloadedByLauncher");
|
2026-08-27 19:35:11 +03:00
|
|
|
|
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
|
2026-09-03 09:18:48 +03:00
|
|
|
|
? Loc::text("java.label.mojang").arg(entry.component)
|
|
|
|
|
|
: Loc::text("java.label.temurin").arg(entry.architecture);
|
2026-08-27 19:35:11 +03:00
|
|
|
|
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()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("java.error.installBusy")
|
2026-08-27 19:35:11 +03:00
|
|
|
|
.arg(m_javaInstaller->label()));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.busyOperation"));
|
2026-08-27 19:35:11 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const auto entry = m_javaMeta->find(runtimeId);
|
|
|
|
|
|
if (!entry) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("java.error.buildNotInCatalog").arg(runtimeId));
|
2026-08-27 19:35:11 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
m_javaInstaller->install(*entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::removeJavaRuntime(const QString &runtimeId)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (m_javaInstaller->isRunning() && m_javaInstaller->runtimeId() == runtimeId) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("java.error.currentlyDownloading"));
|
2026-08-27 19:35:11 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString error;
|
|
|
|
|
|
if (!JavaRuntimeStore::remove(runtimeId, &error)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("java.error.removeFailed").arg(error));
|
2026-08-27 19:35:11 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Удалили выбранную — настройка указывала бы в пустоту.
|
|
|
|
|
|
if (m_settings.value(QStringLiteral("javaRuntime")).toString() == runtimeId) {
|
|
|
|
|
|
m_settings.insert(QStringLiteral("javaRuntime"), QString());
|
|
|
|
|
|
saveSettings();
|
|
|
|
|
|
emit settingsChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
invalidateJavaCatalog();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("java.status.removed"));
|
2026-08-27 19:35:11 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QVariantMap LauncherBackend::javaRuntimeInfo(const QString &runtimeId) const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (runtimeId.trimmed().isEmpty())
|
|
|
|
|
|
return {};
|
|
|
|
|
|
|
|
|
|
|
|
const auto runtime = JavaRuntimeStore::find(runtimeId);
|
|
|
|
|
|
if (runtime) {
|
|
|
|
|
|
return QVariantMap{{QStringLiteral("id"), runtime->id},
|
|
|
|
|
|
{QStringLiteral("kind"), runtime->kind},
|
|
|
|
|
|
{QStringLiteral("major"), runtime->major},
|
|
|
|
|
|
{QStringLiteral("version"), runtime->version},
|
|
|
|
|
|
{QStringLiteral("label"), QStringLiteral("Java %1").arg(runtime->version)},
|
|
|
|
|
|
{QStringLiteral("javaPath"), QDir::toNativeSeparators(runtime->javaPath)},
|
|
|
|
|
|
{QStringLiteral("installed"), true}};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Выбрана, но ещё не скачана: подпись всё равно нужна.
|
|
|
|
|
|
const auto entry = m_javaMeta->find(runtimeId);
|
|
|
|
|
|
if (!entry)
|
|
|
|
|
|
return QVariantMap{{QStringLiteral("id"), runtimeId},
|
|
|
|
|
|
{QStringLiteral("label"), runtimeId},
|
|
|
|
|
|
{QStringLiteral("installed"), false}};
|
|
|
|
|
|
|
|
|
|
|
|
return QVariantMap{{QStringLiteral("id"), entry->id},
|
|
|
|
|
|
{QStringLiteral("kind"), javaKindKey(entry->kind)},
|
|
|
|
|
|
{QStringLiteral("major"), entry->major},
|
|
|
|
|
|
{QStringLiteral("version"), entry->version},
|
|
|
|
|
|
{QStringLiteral("label"), QStringLiteral("Java %1").arg(entry->version)},
|
|
|
|
|
|
{QStringLiteral("javaPath"), QString()},
|
|
|
|
|
|
{QStringLiteral("installed"), false}};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int LauncherBackend::requiredJavaMajor(int buildIndex) const
|
|
|
|
|
|
{
|
|
|
|
|
|
if (buildIndex < 0 || buildIndex >= m_builds.size())
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
return JavaRequirement::minimumFor(m_builds.at(buildIndex).minecraftVersion);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString LauncherBackend::managedJavaPath() const
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString id = m_settings.value(QStringLiteral("javaRuntime")).toString();
|
|
|
|
|
|
if (id.trimmed().isEmpty())
|
|
|
|
|
|
return {};
|
|
|
|
|
|
const auto runtime = JavaRuntimeStore::find(id);
|
|
|
|
|
|
return runtime ? runtime->javaPath : QString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 00:59:14 +03:00
|
|
|
|
// ── Сезонные сборки ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-03 09:18:48 +03:00
|
|
|
|
status = Loc::text("seasonal.status.notInstalled");
|
2026-08-31 00:59:14 +03:00
|
|
|
|
else if (updateAvailable)
|
2026-09-03 09:18:48 +03:00
|
|
|
|
status = Loc::text("seasonal.status.updateAvailable");
|
2026-08-31 00:59:14 +03:00
|
|
|
|
else
|
2026-09-03 09:18:48 +03:00
|
|
|
|
status = Loc::text("seasonal.status.installed");
|
2026-08-31 00:59:14 +03:00
|
|
|
|
|
|
|
|
|
|
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},
|
2026-09-03 09:18:48 +03:00
|
|
|
|
{QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : Loc::text("loader.label.vanilla")},
|
2026-08-31 00:59:14 +03:00
|
|
|
|
{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) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("seasonal.error.installInProgress"));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m_launcher->isRunning()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("build.error.closeGameFilesMoving"));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (busy()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("common.error.busyOperation"));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const auto entry = m_seasonalMeta->find(seasonalId);
|
|
|
|
|
|
if (!entry) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("seasonal.error.notInCatalog").arg(seasonalId));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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);
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("seasonal.progress.installing").arg(entry->name));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
// Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его
|
|
|
|
|
|
// вызова seasonalInstalling() ещё вернул бы false.
|
|
|
|
|
|
seasonAdvance();
|
|
|
|
|
|
emit seasonalInstallingChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::seasonAdvance()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (indexOfBuildId(m_season.buildId) < 0) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
seasonFail(Loc::text("seasonal.error.entryGone"));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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;
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("build.progress.switchingTo").arg(m_builds[index].name));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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;
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("seasonal.progress.installingJava").arg(major));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
|
|
|
|
|
|
// Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать.
|
|
|
|
|
|
m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) {
|
|
|
|
|
|
if (m_season.stage != SeasonStage::Java)
|
|
|
|
|
|
return;
|
|
|
|
|
|
if (!ok) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
seasonFail(warning.isEmpty() ? Loc::text("seasonal.error.javaCatalogFailed") : warning);
|
2026-08-31 00:59:14 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const auto entry = m_javaMeta->bestFor(major);
|
|
|
|
|
|
if (!entry) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
seasonFail(Loc::text("seasonal.error.noJavaBuild").arg(major));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
seasonFail(Loc::text("seasonal.error.filesNotDownloaded"));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchProgress(Loc::text("seasonal.status.buildInstalled").arg(name));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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()
|
2026-09-03 09:18:48 +03:00
|
|
|
|
? Loc::text("seasonal.error.installAborted").arg(name)
|
|
|
|
|
|
: Loc::text("seasonal.error.installAbortedWhy").arg(name, message));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 уже разобрано: отступать некуда,
|
|
|
|
|
|
// операцию нужно довести до конца.
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("switch.error.cannotCancel"));
|
2026-08-31 00:59:14 +03:00
|
|
|
|
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 ¬e,
|
|
|
|
|
|
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}});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 21:03:37 +03:00
|
|
|
|
void LauncherBackend::openMinecraftFolder()
|
|
|
|
|
|
{
|
2026-08-22 18:19:13 +03:00
|
|
|
|
const QString modsDir = gameDir() + QStringLiteral("/mods");
|
2026-06-19 21:03:37 +03:00
|
|
|
|
QDir().mkpath(modsDir);
|
|
|
|
|
|
|
|
|
|
|
|
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir)))
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.error.openModsFailed"));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::openGameFolder()
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString directory = gameDir();
|
|
|
|
|
|
QDir().mkpath(directory);
|
|
|
|
|
|
|
|
|
|
|
|
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory)))
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("launch.error.openGameDirFailed"));
|
2026-06-19 21:03:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
// Повреждённый файл не удаляем: отводим в .bak, чтобы данные можно было спасти
|
|
|
|
|
|
// руками, а ensureDataFiles() создал на его месте пустой.
|
|
|
|
|
|
bool LauncherBackend::moveAsideBroken(const QString &path, const QString &reason)
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString backup = path + QStringLiteral(".bak");
|
|
|
|
|
|
QFile::remove(backup);
|
|
|
|
|
|
if (!QFile::rename(path, backup)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
m_storageIssues << Loc::text("storage.error.corruptNotSetAside")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.arg(QDir::toNativeSeparators(path), reason);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 09:18:48 +03:00
|
|
|
|
m_storageIssues << Loc::text("storage.warning.corruptRecreated")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.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)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
m_storageIssues << Loc::text("common.error.readFailed")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
moveAsideBroken(path, Loc::text("storage.error.expectedArray"));
|
2026-08-23 03:30:31 +03:00
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
return document.array();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QJsonObject LauncherBackend::readObjectFile(const QString &path)
|
|
|
|
|
|
{
|
|
|
|
|
|
QFile file(path);
|
|
|
|
|
|
if (!file.exists())
|
|
|
|
|
|
return {};
|
|
|
|
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
m_storageIssues << Loc::text("common.error.readFailed")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.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();
|
2026-09-03 09:18:48 +03:00
|
|
|
|
moveAsideBroken(path, Loc::text("storage.error.expectedObject"));
|
2026-08-23 03:30:31 +03:00
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
return document.object();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Перед каждой записью убеждаемся, что папка лаунчера на месте: её могли удалить
|
|
|
|
|
|
// уже после запуска, и тогда данные молча пропадали бы.
|
|
|
|
|
|
bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &document)
|
|
|
|
|
|
{
|
|
|
|
|
|
QString storageError;
|
|
|
|
|
|
if (!LauncherPaths::ensureRootExists(&storageError)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("storage.error.launcherDirFailed").arg(storageError));
|
2026-08-23 03:30:31 +03:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const QByteArray payload = document.toJson();
|
|
|
|
|
|
QFile file(path);
|
|
|
|
|
|
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
emit launchError(Loc::text("storage.error.saveFailedWhy")
|
2026-08-23 03:30:31 +03:00
|
|
|
|
.arg(QDir::toNativeSeparators(path), file.errorString()));
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 00:43:06 +03:00
|
|
|
|
void LauncherBackend::loadData()
|
|
|
|
|
|
{
|
2026-08-23 03:30:31 +03:00
|
|
|
|
auto loadFile = [this](const QString &path, auto handler) {
|
|
|
|
|
|
for (const auto &val : readArrayFile(path))
|
2026-06-09 00:43:06 +03:00
|
|
|
|
handler(val.toObject());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) {
|
2026-08-22 18:19:13 +03:00
|
|
|
|
Profile profile;
|
|
|
|
|
|
profile.id = o["id"].toInt();
|
|
|
|
|
|
profile.name = o["name"].toString();
|
|
|
|
|
|
profile.login = o["login"].toString();
|
|
|
|
|
|
profile.password = o["password"].toString();
|
|
|
|
|
|
profile.authType = o.value("authType").toString(kOffline);
|
|
|
|
|
|
profile.clientToken = o.value("clientToken").toString();
|
|
|
|
|
|
profile.accessToken = o.value("accessToken").toString();
|
|
|
|
|
|
profile.uuid = o.value("uuid").toString();
|
|
|
|
|
|
profile.resolvedName = o.value("resolvedName").toString();
|
|
|
|
|
|
profile.userType = o.value("userType").toString();
|
2026-09-01 22:25:23 +03:00
|
|
|
|
profile.refreshToken = o.value("refreshToken").toString();
|
|
|
|
|
|
profile.xuid = o.value("xuid").toString();
|
|
|
|
|
|
profile.tokenExpiry = o.value("tokenExpiry").toString();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (profile.clientToken.isEmpty())
|
|
|
|
|
|
profile.clientToken = AuthService::generateClientToken();
|
|
|
|
|
|
m_profiles.append(profile);
|
|
|
|
|
|
if (profile.id >= m_nextProfileId)
|
|
|
|
|
|
m_nextProfileId = profile.id + 1;
|
2026-06-09 00:43:06 +03:00
|
|
|
|
});
|
2026-08-22 23:39:46 +00:00
|
|
|
|
std::sort(m_profiles.begin(), m_profiles.end(),
|
|
|
|
|
|
[](const Profile &a, const Profile &b) { return a.id < b.id; });
|
2026-06-09 00:43:06 +03:00
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
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();
|
2026-08-31 00:59:14 +03:00
|
|
|
|
build.seasonalId = o.value("seasonalId").toString();
|
|
|
|
|
|
build.seasonalRevision = o.value("seasonalRevision").toInt();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
|
|
|
|
|
|
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
|
|
|
|
|
|
// пустую строку.
|
|
|
|
|
|
if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) {
|
|
|
|
|
|
++skippedBuilds;
|
2026-08-23 03:30:31 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-27 09:21:51 +03:00
|
|
|
|
m_builds.append(build);
|
|
|
|
|
|
if (build.id >= m_nextBuildId)
|
|
|
|
|
|
m_nextBuildId = build.id + 1;
|
2026-06-09 00:43:06 +03:00
|
|
|
|
});
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (skippedBuilds > 0) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
m_storageIssues << Loc::text("storage.warning.buildsSkipped")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))
|
|
|
|
|
|
.arg(skippedBuilds);
|
2026-08-23 03:30:31 +03:00
|
|
|
|
}
|
2026-08-27 09:21:51 +03:00
|
|
|
|
std::sort(m_builds.begin(), m_builds.end(),
|
|
|
|
|
|
[](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Первый запуск после переименования «версий» в «сборки»: перекладываем
|
|
|
|
|
|
// <root>/versions.json в customBuilds.json. Старый файл не удаляем, а
|
|
|
|
|
|
// переименовываем — он остаётся резервной копией.
|
|
|
|
|
|
bool LauncherBackend::migrateLegacyBuildsFile()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (QFile::exists(LauncherPaths::customBuildsFile()))
|
|
|
|
|
|
return false;
|
|
|
|
|
|
if (!QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
|
|
// readArrayFile() сам отводит битый файл в .bak — тогда мигрировать нечего.
|
|
|
|
|
|
const QJsonArray legacy = readArrayFile(LauncherPaths::legacyCustomBuildsFile());
|
|
|
|
|
|
if (legacy.isEmpty() && !QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
|
|
QJsonArray migrated;
|
|
|
|
|
|
for (const auto &value : legacy) {
|
|
|
|
|
|
const QJsonObject o = value.toObject();
|
|
|
|
|
|
migrated.append(QJsonObject{{"id", o.value("id").toInt()},
|
|
|
|
|
|
{"name", o.value("name").toString()},
|
|
|
|
|
|
{"serverUrl", o.value("serverUrl").toString()},
|
|
|
|
|
|
{"minecraftVersion", o.value("versionId").toString()},
|
|
|
|
|
|
{"loader", QString()},
|
|
|
|
|
|
{"loaderVersion", QString()},
|
|
|
|
|
|
{"resolvedVersionId", QString()}});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Не удалось записать — молча читаем в этой сессии из старого файла.
|
|
|
|
|
|
// Без данных пользователь не остаётся ни при каком исходе.
|
|
|
|
|
|
if (!writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(migrated)))
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
|
|
const QString retired = LauncherPaths::legacyCustomBuildsFile() + QStringLiteral(".migrated");
|
|
|
|
|
|
QFile::remove(retired);
|
|
|
|
|
|
if (!QFile::rename(LauncherPaths::legacyCustomBuildsFile(), retired)) {
|
2026-09-03 09:18:48 +03:00
|
|
|
|
m_storageIssues << Loc::text("storage.warning.migrationLeftover")
|
2026-08-27 09:21:51 +03:00
|
|
|
|
.arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()),
|
|
|
|
|
|
QDir::toNativeSeparators(
|
|
|
|
|
|
LauncherPaths::legacyCustomBuildsFile()));
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
2026-06-09 00:43:06 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
|
|
|
|
|
|
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
|
|
|
|
|
|
void LauncherBackend::ensureDataFiles()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!QFile::exists(LauncherPaths::settingsFile()))
|
|
|
|
|
|
saveSettings();
|
|
|
|
|
|
if (!QFile::exists(LauncherPaths::profilesFile()))
|
|
|
|
|
|
saveProfiles();
|
2026-08-27 09:21:51 +03:00
|
|
|
|
if (!QFile::exists(LauncherPaths::customBuildsFile()))
|
|
|
|
|
|
saveCustomBuilds();
|
2026-08-23 03:30:31 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 00:43:06 +03:00
|
|
|
|
void LauncherBackend::saveProfiles()
|
|
|
|
|
|
{
|
|
|
|
|
|
QJsonArray arr;
|
|
|
|
|
|
for (const auto &p : m_profiles)
|
2026-08-22 18:19:13 +03:00
|
|
|
|
arr.append(QJsonObject{{"id", p.id},
|
|
|
|
|
|
{"name", p.name},
|
|
|
|
|
|
{"login", p.login},
|
|
|
|
|
|
{"password", p.password},
|
|
|
|
|
|
{"authType", p.authType},
|
|
|
|
|
|
{"clientToken", p.clientToken},
|
|
|
|
|
|
{"accessToken", p.accessToken},
|
|
|
|
|
|
{"uuid", p.uuid},
|
|
|
|
|
|
{"resolvedName", p.resolvedName},
|
2026-09-01 22:25:23 +03:00
|
|
|
|
{"userType", p.userType},
|
|
|
|
|
|
{"refreshToken", p.refreshToken},
|
|
|
|
|
|
{"xuid", p.xuid},
|
|
|
|
|
|
{"tokenExpiry", p.tokenExpiry}});
|
2026-06-09 00:43:06 +03:00
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
|
2026-06-09 00:43:06 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
|
void LauncherBackend::saveCustomBuilds()
|
2026-06-09 00:43:06 +03:00
|
|
|
|
{
|
|
|
|
|
|
QJsonArray arr;
|
2026-08-27 09:21:51 +03:00
|
|
|
|
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},
|
2026-08-31 00:59:14 +03:00
|
|
|
|
{"resolvedVersionId", b.resolvedVersionId},
|
|
|
|
|
|
{"seasonalId", b.seasonalId},
|
|
|
|
|
|
{"seasonalRevision", b.seasonalRevision}});
|
2026-08-27 09:21:51 +03:00
|
|
|
|
|
|
|
|
|
|
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
|
2026-06-09 00:43:06 +03:00
|
|
|
|
}
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::loadSettings()
|
|
|
|
|
|
{
|
|
|
|
|
|
m_settings = {{QStringLiteral("gameDir"), QString()},
|
|
|
|
|
|
{QStringLiteral("javaPath"), QString()},
|
2026-08-27 19:35:11 +03:00
|
|
|
|
// id сборки Java из <root>/java. Пусто — java ищется сама.
|
|
|
|
|
|
{QStringLiteral("javaRuntime"), QString()},
|
2026-08-22 18:19:13 +03:00
|
|
|
|
{QStringLiteral("minMemoryMb"), 512},
|
|
|
|
|
|
{QStringLiteral("maxMemoryMb"), 4096},
|
|
|
|
|
|
{QStringLiteral("jvmArgs"), QString()},
|
|
|
|
|
|
{QStringLiteral("windowWidth"), 0},
|
|
|
|
|
|
{QStringLiteral("windowHeight"), 0},
|
|
|
|
|
|
{QStringLiteral("fullscreen"), false},
|
2026-09-03 09:18:48 +03:00
|
|
|
|
// Язык интерфейса: "system" — по системной локали.
|
|
|
|
|
|
{QStringLiteral("language"), QStringLiteral("system")},
|
2026-08-22 18:19:13 +03:00
|
|
|
|
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
|
2026-08-27 09:21:51 +03:00
|
|
|
|
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
|
2026-08-31 00:59:14 +03:00
|
|
|
|
{QStringLiteral("activeBuildId"), 0},
|
|
|
|
|
|
// Сервер готовых сборок. В интерфейс не вынесен: менять его
|
|
|
|
|
|
// приходится только при отладке против локального сервера.
|
|
|
|
|
|
{QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}};
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
2026-08-23 03:30:31 +03:00
|
|
|
|
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
|
2026-08-22 18:19:13 +03:00
|
|
|
|
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
|
|
|
|
|
|
m_settings.insert(it.key(), it.value().toVariant());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void LauncherBackend::saveSettings()
|
|
|
|
|
|
{
|
2026-08-23 03:30:31 +03:00
|
|
|
|
writeJsonFile(LauncherPaths::settingsFile(),
|
|
|
|
|
|
QJsonDocument(QJsonObject::fromVariantMap(m_settings)));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
}
|