seasonal builds
This commit is contained in:
+612
-11
@@ -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 ¬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}});
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user