seasonal builds

This commit is contained in:
2026-08-31 00:59:14 +03:00
parent 4689e59b5a
commit c25e071b29
17 changed files with 2312 additions and 18 deletions
+612 -11
View File
@@ -12,7 +12,11 @@
#include "modloaderinstaller.h"
#include "modloaderversionservice.h"
#include "buildswitcher.h"
#include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h"
#include <QDate>
#include <QDateTime>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
@@ -21,6 +25,7 @@
#include <QJsonObject>
#include <QHash>
#include <QRegularExpression>
#include <QSaveFile>
#include <QSet>
#include <QTimer>
#include <QUrl>
@@ -31,6 +36,17 @@ namespace {
const QString kOffline = QStringLiteral("offline");
const QString kElyBy = QStringLiteral("elyby");
// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl
// в settings.json — это нужно, чтобы прогонять установку против локального
// http-сервера, не трогая боевой.
const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com");
// Дата сезона в таблице: год нужен, время — нет.
QString formatSeasonDate(const QDate &date)
{
return date.isValid() ? date.toString(QStringLiteral("dd.MM.yyyy")) : QStringLiteral("");
}
int settingInt(const QVariantMap &settings, const QString &key, int fallback)
{
const QVariant value = settings.value(key);
@@ -52,6 +68,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
, m_switcher(new BuildSwitcher(this))
, m_javaMeta(new JavaRuntimeService(this))
, m_javaInstaller(new JavaInstaller(this))
, m_seasonalMeta(new SeasonalBuildService(this))
, m_packDownloader(new SeasonalPackDownloader(this))
{
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
// следующем — если её удалили, она появится снова.
@@ -113,6 +131,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit javaRuntimeInstalled(runtimeId);
emit launchProgress(tr("Java установлена: %1")
.arg(QDir::toNativeSeparators(javaPath)));
if (m_season.stage == SeasonStage::Java)
seasonAdvance();
});
connect(m_javaInstaller, &JavaInstaller::failed, this,
[this](const QString &label, const QString &message) {
@@ -120,12 +140,16 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message));
if (m_season.stage == SeasonStage::Java)
seasonFail({});
});
connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
invalidateJavaCatalog();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label));
if (m_season.stage == SeasonStage::Java)
seasonFail({});
});
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
@@ -143,6 +167,11 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Версия «%1» загружена").arg(id));
// У ванильной сезонной сборки этот установщик и есть весь шаг Loader.
// Внутри установки модлоадера он тоже отчитывается — тогда ход
// передаст ModLoaderInstaller, а не он.
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonAdvance();
});
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
const QString &message) {
@@ -152,6 +181,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonFail({});
});
connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
emit switchChanged();
@@ -170,6 +201,17 @@ LauncherBackend::LauncherBackend(QObject *parent)
const int index = indexOfBuildId(toBuildId);
emit launchProgress(tr("Активна сборка «%1»")
.arg(index >= 0 ? m_builds[index].name : QString()));
// Раскатка пака могла быть и той, что доигралась после перезапуска —
// тогда цепочки установки уже нет, а записать ревизию всё равно надо.
const QJsonObject note = m_switcher->lastPackNote();
if (!note.isEmpty())
recordSeasonalPack(toBuildId, note, m_switcher->lastPackEntries());
if (m_season.stage == SeasonStage::Applying)
seasonFinish();
else if (m_season.stage == SeasonStage::Switching)
seasonAdvance();
});
connect(m_switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) {
@@ -177,11 +219,27 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit installedVersionsChanged();
emit switchChanged();
emit busyChanged();
emit launchError(gameDirIntact
? tr("Сборка не переключена: %1").arg(message)
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
"осталось разобранным — повторите переключение.")
.arg(message));
// Раскатка пака и смена сборки приходят одним сигналом, а
// сказать пользователю надо разное: после сорванного
// переключения .minecraft лежит разобранной, а сорванная
// раскатка означает лишь наполовину обновлённую сборку.
if (m_season.stage == SeasonStage::Applying) {
emit launchError(gameDirIntact
? tr("Файлы сборки не установлены: %1").arg(message)
: tr("Файлы сборки установлены не полностью: %1\n"
"Повторите установку сборки.")
.arg(message));
} else {
emit launchError(gameDirIntact
? tr("Сборка не переключена: %1").arg(message)
: tr("Сборка не переключена: %1\nСодержимое .minecraft "
"осталось разобранным — повторите переключение.")
.arg(message));
}
if (m_season.stage == SeasonStage::Switching
|| m_season.stage == SeasonStage::Applying) {
seasonFail({});
}
});
connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
@@ -216,6 +274,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged();
emit launchProgress(tr("Готово: %1 для Minecraft %2")
.arg(producedVersionId, gameVersion));
if (m_season.stage == SeasonStage::Loader)
seasonAdvance();
});
connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &label, const QString &message) {
@@ -225,6 +285,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit busyChanged();
emit downloadChanged();
emit launchError(tr("%1: %2").arg(label, message));
if (m_season.stage == SeasonStage::Loader)
seasonFail({});
});
connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
m_loaderInstallBuildId = -1;
@@ -233,6 +295,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit busyChanged();
emit downloadChanged();
emit launchProgress(tr("Установка %1 отменена").arg(label));
if (m_season.stage == SeasonStage::Loader)
seasonFail({});
});
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
@@ -241,6 +305,49 @@ LauncherBackend::LauncherBackend(QObject *parent)
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
seasonFail({});
});
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
connect(m_seasonalMeta, &SeasonalBuildService::buildsChanged,
this, &LauncherBackend::invalidateSeasonalCatalog);
connect(m_seasonalMeta, &SeasonalBuildService::refreshingChanged,
this, &LauncherBackend::seasonalCatalogLoadingChanged);
// Статус строки («установлена», «доступно обновление») считается по
// локальным записям — их изменение обновляет и таблицу.
connect(this, &LauncherBackend::customBuildsChanged,
this, &LauncherBackend::invalidateSeasonalCatalog);
connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка файлов сборки «%1»…").arg(label));
});
connect(m_packDownloader, &SeasonalPackDownloader::progressChanged,
this, &LauncherBackend::downloadChanged);
connect(m_packDownloader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
emit downloadChanged();
emit busyChanged();
if (m_season.stage != SeasonStage::Downloading)
return;
m_season.packPath = path;
seasonAdvance();
});
connect(m_packDownloader, &SeasonalPackDownloader::failed, this,
[this](const QString &label, const QString &message) {
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Сборка «%1»: %2").arg(label, message));
if (m_season.stage == SeasonStage::Downloading)
seasonFail({});
});
connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка сборки «%1» отменена").arg(label));
if (m_season.stage == SeasonStage::Downloading)
seasonFail({});
});
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
@@ -362,6 +469,9 @@ void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
take("loader", build.loader);
take("loaderVersion", build.loaderVersion);
take("resolvedVersionId", build.resolvedVersionId);
take("seasonalId", build.seasonalId);
if (fields.contains(QStringLiteral("seasonalRevision")))
build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt();
if (build.minecraftVersion != previousMinecraft
&& !fields.contains(QStringLiteral("loaderVersion"))) {
@@ -399,6 +509,8 @@ QVariantMap LauncherBackend::customBuildAt(int index) const
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision},
{"launchVersionId", launchVersionIdOf(b)}};
}
@@ -752,8 +864,10 @@ void LauncherBackend::cancelDownload()
m_loaderInstaller->cancel();
else if (m_installer->isRunning())
m_installer->cancel();
else
else if (m_javaInstaller->isRunning())
m_javaInstaller->cancel();
else
m_packDownloader->cancel();
}
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
@@ -762,7 +876,7 @@ void LauncherBackend::cancelDownload()
bool LauncherBackend::downloading() const
{
return m_loaderInstaller->isRunning() || m_installer->isRunning()
|| m_javaInstaller->isRunning();
|| m_javaInstaller->isRunning() || m_packDownloader->isRunning();
}
// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
@@ -772,8 +886,18 @@ bool LauncherBackend::javaDownloadShown() const
&& !m_loaderInstaller->isRunning();
}
// Пак качается отдельным шагом цепочки, когда остальные установщики уже
// отработали, — за панель они не спорят.
bool LauncherBackend::packDownloadShown() const
{
return m_packDownloader->isRunning() && !m_installer->isRunning()
&& !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning();
}
double LauncherBackend::downloadProgress() const
{
if (packDownloadShown())
return m_packDownloader->fraction();
if (javaDownloadShown())
return m_javaInstaller->fraction();
if (!m_loaderInstaller->isRunning())
@@ -784,6 +908,8 @@ double LauncherBackend::downloadProgress() const
QString LauncherBackend::downloadVersion() const
{
if (packDownloadShown())
return m_packDownloader->label();
if (javaDownloadShown())
return m_javaInstaller->label();
if (m_loaderInstaller->isRunning())
@@ -795,7 +921,10 @@ QString LauncherBackend::downloadStatus() const
{
QString stage = m_installer->stage();
QString file = m_installer->currentFile();
if (javaDownloadShown()) {
if (packDownloadShown()) {
stage = m_packDownloader->stage();
file = m_packDownloader->currentFile();
} else if (javaDownloadShown()) {
stage = m_javaInstaller->stage();
file = m_javaInstaller->currentFile();
} else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
@@ -811,6 +940,8 @@ QString LauncherBackend::downloadStatus() const
qint64 LauncherBackend::downloadBytesDone() const
{
if (packDownloadShown())
return m_packDownloader->bytesDone();
if (javaDownloadShown())
return m_javaInstaller->bytesDone();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
@@ -820,6 +951,8 @@ qint64 LauncherBackend::downloadBytesDone() const
qint64 LauncherBackend::downloadBytesTotal() const
{
if (packDownloadShown())
return m_packDownloader->bytesTotal();
if (javaDownloadShown())
return m_javaInstaller->bytesTotal();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
@@ -1217,7 +1350,8 @@ QString LauncherBackend::switchStatus() const
bool LauncherBackend::busy() const
{
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
|| m_javaInstaller->isRunning() || m_switcher->isRunning();
|| m_javaInstaller->isRunning() || m_switcher->isRunning()
|| m_packDownloader->isRunning();
}
void LauncherBackend::setBusy(bool busy)
@@ -1473,6 +1607,466 @@ QString LauncherBackend::managedJavaPath() const
return runtime ? runtime->javaPath : QString();
}
// ── Сезонные сборки ────────────────────────────────────────────────────────
QUrl LauncherBackend::seasonalBaseUrl() const
{
const QString configured =
m_settings.value(QStringLiteral("seasonalBaseUrl")).toString().trimmed();
QUrl url(configured.isEmpty() ? kSeasonalBaseUrl : configured);
// Хвостовой слэш превратил бы адрес каталога в «…//index.json».
while (url.path().endsWith(u'/'))
url.setPath(url.path().chopped(1));
return url;
}
void LauncherBackend::invalidateSeasonalCatalog()
{
m_seasonalCatalogDirty = true;
emit seasonalCatalogChanged();
}
int LauncherBackend::indexOfSeasonalBuild(const QString &seasonalId) const
{
if (seasonalId.isEmpty())
return -1;
for (int i = 0; i < m_builds.size(); ++i) {
if (m_builds[i].seasonalId == seasonalId)
return i;
}
return -1;
}
void LauncherBackend::rebuildSeasonalCatalog() const
{
m_seasonalCatalog.clear();
const QList<SeasonalBuildEntry> builds = m_seasonalMeta->builds();
m_seasonalCatalog.reserve(builds.size());
for (const SeasonalBuildEntry &entry : builds) {
int installedRevision = 0;
bool installed = false;
for (const CustomBuild &build : m_builds) {
if (build.seasonalId != entry.id)
continue;
installed = true;
installedRevision = build.seasonalRevision;
break;
}
const bool updateAvailable = installed && installedRevision < entry.revision;
QString status;
if (!installed)
status = tr("Не установлена");
else if (updateAvailable)
status = tr("Есть обновление");
else
status = tr("Установлена");
const auto loader = loaderFromKey(entry.loader);
m_seasonalCatalog.append(QVariantMap{
{QStringLiteral("id"), entry.id},
{QStringLiteral("name"), entry.name},
{QStringLiteral("minecraftVersion"), entry.minecraftVersion},
{QStringLiteral("loader"), entry.loader},
{QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : tr("Ванильная")},
{QStringLiteral("loaderVersion"), entry.loaderVersion},
{QStringLiteral("modCount"), entry.modCount},
{QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)},
{QStringLiteral("seasonEnd"), formatSeasonDate(entry.seasonEnd)},
{QStringLiteral("sizeBytes"), entry.archiveSize},
{QStringLiteral("revision"), entry.revision},
{QStringLiteral("description"), entry.description},
{QStringLiteral("serverUrl"), entry.serverUrl},
{QStringLiteral("installed"), installed},
{QStringLiteral("installedRevision"), installedRevision},
{QStringLiteral("updateAvailable"), updateAvailable},
{QStringLiteral("status"), status}});
}
m_seasonalCatalogDirty = false;
}
QVariantList LauncherBackend::seasonalCatalog() const
{
if (m_seasonalCatalogDirty)
rebuildSeasonalCatalog();
return m_seasonalCatalog;
}
bool LauncherBackend::seasonalCatalogLoading() const
{
return m_seasonalMeta->isRefreshing();
}
QString LauncherBackend::seasonalCatalogError() const
{
return m_seasonalMeta->lastError();
}
bool LauncherBackend::seasonalInstalling() const
{
return m_season.stage != SeasonStage::Idle;
}
void LauncherBackend::refreshSeasonalCatalog(bool force)
{
// Адрес мог смениться в settings.json между запусками окна.
m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
m_seasonalMeta->ensureLoaded(nullptr, force);
}
// ── Установка сезонной сборки ──────────────────────────────────────────────
void LauncherBackend::installSeasonalBuild(const QString &seasonalId)
{
if (m_season.stage != SeasonStage::Idle) {
emit launchError(tr("Установка сборки уже идёт"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
return;
}
if (busy()) {
emit launchError(tr("Дождитесь окончания текущей операции"));
return;
}
const auto entry = m_seasonalMeta->find(seasonalId);
if (!entry) {
emit launchError(tr("Сборка «%1» не найдена в списке — обновите его").arg(seasonalId));
return;
}
m_season = {};
m_season.entry = *entry;
// Сборка с таким seasonalId уже заведена — обновляем её описание под новую
// ревизию, а не плодим вторую запись.
int index = indexOfSeasonalBuild(seasonalId);
if (index < 0) {
CustomBuild build;
build.id = m_nextBuildId++;
build.name = entry->name;
build.serverUrl = entry->serverUrl;
build.minecraftVersion = entry->minecraftVersion;
build.loader = entry->loader;
build.loaderVersion = entry->loaderVersion;
build.seasonalId = entry->id;
// Ревизия появится только когда файлы лягут на диск: иначе оборванная
// установка выглядела бы завершённой.
m_builds.append(build);
index = m_builds.size() - 1;
saveCustomBuilds();
emit customBuildsChanged();
// maybeInstall() здесь намеренно нет: версию и модлоадер поставит шаг
// цепочки, а параллельная загрузка только мешала бы ему.
} else {
updateCustomBuild(index,
{{QStringLiteral("name"), entry->name},
{QStringLiteral("serverUrl"), entry->serverUrl},
{QStringLiteral("minecraftVersion"), entry->minecraftVersion},
{QStringLiteral("loader"), entry->loader},
{QStringLiteral("loaderVersion"), entry->loaderVersion}});
}
m_season.buildId = m_builds[index].id;
setBusy(true);
emit launchProgress(tr("Установка сборки «%1»…").arg(entry->name));
// Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его
// вызова seasonalInstalling() ещё вернул бы false.
seasonAdvance();
emit seasonalInstallingChanged();
}
void LauncherBackend::seasonAdvance()
{
if (indexOfBuildId(m_season.buildId) < 0) {
seasonFail(tr("запись сборки исчезла"));
return;
}
// Каждый шаг либо начинает свою асинхронную работу и обрывает цепочку до
// следующего сигнала, либо уступает следующему — отсюда провалы между
// ветками.
switch (m_season.stage) {
case SeasonStage::Idle:
if (seasonBeginSwitch())
return;
[[fallthrough]];
case SeasonStage::Switching:
if (seasonBeginJava())
return;
[[fallthrough]];
case SeasonStage::Java:
if (seasonBeginLoader())
return;
[[fallthrough]];
case SeasonStage::Loader:
if (seasonBeginDownload())
return;
[[fallthrough]];
case SeasonStage::Downloading:
if (seasonBeginApply())
return;
[[fallthrough]];
case SeasonStage::Applying:
seasonFinish();
return;
}
}
bool LauncherBackend::seasonBeginSwitch()
{
const int index = indexOfBuildId(m_season.buildId);
const int current = activeBuildIndex();
if (current == index)
return false;
// Пак ложится на то, что сейчас разложено в .minecraft, поэтому сборка
// должна стать активной до всего остального. Содержимое прежней уезжает в
// её архив обычным путём.
m_season.stage = SeasonStage::Switching;
emit launchProgress(tr("Смена сборки на «%1»…").arg(m_builds[index].name));
m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
current >= 0 ? m_builds[current].name : QString(),
m_builds[index].id,
m_builds[index].name,
gameDir());
emit switchChanged();
emit busyChanged();
return true;
}
bool LauncherBackend::seasonBeginJava()
{
// Сервер может назвать нужную Java прямо; иначе спрашиваем таблицу.
const int major = m_season.entry.javaMajor > 0
? m_season.entry.javaMajor
: JavaRequirement::minimumFor(m_season.entry.minecraftVersion);
if (major <= 0)
return false;
// Java уже есть — своя, системная или скачанная раньше.
QString error;
if (!JavaLocator::select(gameDir(), major, QString(), &error).isEmpty())
return false;
m_season.stage = SeasonStage::Java;
emit launchProgress(tr("Для сборки нужна Java %1 — устанавливаем").arg(major));
// Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать.
m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) {
if (m_season.stage != SeasonStage::Java)
return;
if (!ok) {
seasonFail(warning.isEmpty() ? tr("не удалось получить каталог Java") : warning);
return;
}
const auto entry = m_javaMeta->bestFor(major);
if (!entry) {
seasonFail(tr("в каталоге нет сборки Java %1 для этой системы").arg(major));
return;
}
m_javaInstaller->install(*entry);
});
return true;
}
bool LauncherBackend::seasonBeginLoader()
{
const int index = indexOfBuildId(m_season.buildId);
const CustomBuild &build = m_builds[index];
const auto loader = loaderFromKey(build.loader);
// Ванильная сборка: ставить надо только саму версию игры.
if (!loader) {
if (checkInstallation(index).isEmpty())
return false;
m_season.stage = SeasonStage::Loader;
m_installer->install(gameDir(), build.minecraftVersion);
return true;
}
// Профиль лоадера уже собран и укомплектован — второй раз не ставим.
if (!build.resolvedVersionId.isEmpty() && checkInstallation(index).isEmpty())
return false;
m_season.stage = SeasonStage::Loader;
m_loaderInstallBuildId = build.id;
m_loaderInstaller->install(gameDir(),
*loader,
build.minecraftVersion,
build.loaderVersion,
managedJavaPath().isEmpty()
? m_settings.value(QStringLiteral("javaPath")).toString()
: managedJavaPath());
return true;
}
bool LauncherBackend::seasonBeginDownload()
{
const SeasonalBuildEntry &entry = m_season.entry;
// Точка в начале имени: файл служебный, и BuildSwitcher по этому признаку
// не примет его за архив сборки и не сотрёт при следующем переключении.
const QString path = LauncherPaths::buildDir(m_season.buildId)
+ QStringLiteral("/.pack-r") + QString::number(entry.revision)
+ QStringLiteral(".zip");
m_season.stage = SeasonStage::Downloading;
m_packDownloader->download(entry.archiveUrl,
path,
entry.archiveSha256,
entry.archiveSize,
entry.name);
return true;
}
bool LauncherBackend::seasonBeginApply()
{
if (m_season.packPath.isEmpty()) {
seasonFail(tr("файлы сборки не скачаны"));
return true;
}
// Под удаление идёт весь список прошлой ревизии, а не разница с новой:
// распаковка всё равно кладёт каждый файл пака заново, поэтому разница
// сэкономила бы лишь перезапись того, что и так перезаписывается, а вот
// ушедшие из сборки моды так убираются наверняка. Миров и скриншотов в
// списке нет — их пак не приносил.
const QStringList remove = seasonalPackEntries(m_season.buildId);
const QJsonObject note{{QStringLiteral("seasonalId"), m_season.entry.id},
{QStringLiteral("revision"), m_season.entry.revision},
{QStringLiteral("sha256"), m_season.entry.archiveSha256}};
const int index = indexOfBuildId(m_season.buildId);
m_season.stage = SeasonStage::Applying;
m_switcher->applyPack(m_season.buildId,
m_builds[index].name,
m_season.packPath,
remove,
note,
gameDir());
emit switchChanged();
emit busyChanged();
return true;
}
void LauncherBackend::seasonFinish()
{
const int index = indexOfBuildId(m_season.buildId);
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
const QString seasonalId = m_season.entry.id;
m_season = {};
setBusy(false);
invalidateSeasonalCatalog();
emit seasonalInstallingChanged();
emit launchProgress(tr("Сборка «%1» установлена").arg(name));
emit seasonalInstallFinished(seasonalId, name);
}
void LauncherBackend::seasonFail(const QString &message)
{
const int index = indexOfBuildId(m_season.buildId);
const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
// Недокачанный или нераскатанный пак не должен занимать место: цепочку
// всегда можно начать заново с чистого листа.
if (!m_season.packPath.isEmpty())
QFile::remove(m_season.packPath);
m_season = {};
setBusy(false);
emit seasonalInstallingChanged();
// Пустое message означает, что о причине уже сообщил тот, кто её нашёл, —
// второй раз ту же ошибку не показываем.
emit launchError(message.isEmpty()
? tr("Установка сборки «%1» прервана").arg(name)
: tr("Установка сборки «%1» прервана: %2").arg(name, message));
}
void LauncherBackend::cancelSeasonalInstall()
{
switch (m_season.stage) {
case SeasonStage::Idle:
return;
case SeasonStage::Java:
m_javaInstaller->cancel();
return;
case SeasonStage::Loader:
if (m_loaderInstaller->isRunning())
m_loaderInstaller->cancel();
else
m_installer->cancel();
return;
case SeasonStage::Downloading:
m_packDownloader->cancel();
return;
case SeasonStage::Switching:
case SeasonStage::Applying:
// На этих шагах содержимое .minecraft уже разобрано: отступать некуда,
// операцию нужно довести до конца.
emit launchError(tr("Сейчас переносятся файлы — отменить нельзя"));
return;
}
}
// ── Состояние установленной сезонной сборки ────────────────────────────────
QStringList LauncherBackend::seasonalPackEntries(int buildId) const
{
QFile file(LauncherPaths::seasonalStateFile(buildId));
if (!file.open(QIODevice::ReadOnly))
return {};
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QStringList entries;
const QJsonArray array = root.value(QStringLiteral("entries")).toArray();
entries.reserve(array.size());
for (const QJsonValue &value : array)
entries << value.toString();
return entries;
}
void LauncherBackend::recordSeasonalPack(int buildId,
const QJsonObject &note,
const QStringList &entries)
{
const QString seasonalId = note.value(QStringLiteral("seasonalId")).toString();
const int revision = note.value(QStringLiteral("revision")).toInt();
if (seasonalId.isEmpty() || revision <= 0)
return;
const QJsonObject state{{QStringLiteral("seasonalId"), seasonalId},
{QStringLiteral("revision"), revision},
{QStringLiteral("sha256"),
note.value(QStringLiteral("sha256")).toString()},
{QStringLiteral("installedAt"),
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)},
{QStringLiteral("entries"), QJsonArray::fromStringList(entries)}};
// Не через writeJsonFile: файл лежит в папке сборки, а не в корне лаунчера,
// и его отсутствие не мешает игре — ошибку достаточно показать.
QDir().mkpath(LauncherPaths::buildDir(buildId));
QSaveFile file(LauncherPaths::seasonalStateFile(buildId));
if (file.open(QIODevice::WriteOnly)) {
const QByteArray payload = QJsonDocument(state).toJson(QJsonDocument::Indented);
if (file.write(payload) == payload.size())
file.commit();
else
file.cancelWriting();
}
const int index = indexOfBuildId(buildId);
if (index < 0)
return;
// Ревизия проставляется только здесь: до этого момента файлы сборки на
// диске не лежали.
updateCustomBuild(index,
{{QStringLiteral("seasonalId"), seasonalId},
{QStringLiteral("seasonalRevision"), revision}});
}
void LauncherBackend::openMinecraftFolder()
{
const QString modsDir = gameDir() + QStringLiteral("/mods");
@@ -1624,6 +2218,8 @@ void LauncherBackend::loadData()
build.loader = o.value("loader").toString();
build.loaderVersion = o.value("loaderVersion").toString();
build.resolvedVersionId = o.value("resolvedVersionId").toString();
build.seasonalId = o.value("seasonalId").toString();
build.seasonalRevision = o.value("seasonalRevision").toInt();
// Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
// пустую строку.
@@ -1727,7 +2323,9 @@ void LauncherBackend::saveCustomBuilds()
{"minecraftVersion", b.minecraftVersion},
{"loader", b.loader},
{"loaderVersion", b.loaderVersion},
{"resolvedVersionId", b.resolvedVersionId}});
{"resolvedVersionId", b.resolvedVersionId},
{"seasonalId", b.seasonalId},
{"seasonalRevision", b.seasonalRevision}});
writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
}
@@ -1746,7 +2344,10 @@ void LauncherBackend::loadSettings()
{QStringLiteral("fullscreen"), false},
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0}};
{QStringLiteral("activeBuildId"), 0},
// Сервер готовых сборок. В интерфейс не вынесен: менять его
// приходится только при отладке против локального сервера.
{QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}};
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)