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
+188 -6
View File
@@ -8,6 +8,7 @@
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include <QStorageInfo>
@@ -18,6 +19,7 @@ namespace {
const QString kIndexFile = QStringLiteral("index.json");
const QString kStateSwitching = QStringLiteral("switching");
const QString kStateApplying = QStringLiteral("applying");
const QString kStateIdle = QStringLiteral("idle");
// Запас на архив: точный размер заранее неизвестен, но упасть на середине
@@ -89,6 +91,8 @@ BuildSwitcher::BuildSwitcher(QObject *parent)
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
connect(m_worker, &BuildArchiveWorker::packEntries, this,
[this](const QStringList &entries) { m_lastPackEntries = entries; });
m_thread->start();
sweepTempArchives();
@@ -110,7 +114,7 @@ double BuildSwitcher::fraction() const
QString BuildSwitcher::buildDir(int buildId) const
{
return LauncherPaths::buildStorageDir() + u'/' + QString::number(buildId);
return LauncherPaths::buildDir(buildId);
}
// На сборку — ровно один архив: имя с датой меняется от переключения к
@@ -118,8 +122,14 @@ QString BuildSwitcher::buildDir(int buildId) const
QString BuildSwitcher::archivePathOf(int buildId) const
{
const QDir dir(buildDir(buildId));
// Рядом лежат служебные .tmp-*.zip и .pack-*.zip. На Unix они скрытые и в
// выборку не попадают, на Windows — попадают, поэтому отсекаем по имени.
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
return zips.isEmpty() ? QString() : dir.absoluteFilePath(zips.first());
for (const QString &name : zips) {
if (!name.startsWith(u'.'))
return dir.absoluteFilePath(name);
}
return {};
}
void BuildSwitcher::setStage(const QString &stage)
@@ -217,6 +227,15 @@ void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDi
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
}
void BuildSwitcher::requestApplyPack(const QString &zipPath,
const QString &gameDir,
const QStringList &remove)
{
QMetaObject::invokeMethod(m_worker, "applyPack", Qt::QueuedConnection,
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir),
Q_ARG(QStringList, remove), Q_ARG(QStringList, sharedTopLevel()));
}
void BuildSwitcher::onWorkerProgress(int done, int total, const QString &currentPath)
{
m_done = done;
@@ -254,6 +273,17 @@ void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
}
finish();
return;
case Applying:
if (!ok) {
// Архив проверяется целиком до первой записи, поэтому нулевой
// счётчик означает, что в папке игры ничего не тронуто. Иначе
// часть файлов уже заменена: отметка в index.json остаётся, и
// следующий запуск доиграет раскатку из сохранённого пака.
fail(error, /*gameDirIntact=*/m_done == 0);
return;
}
finishApplying();
return;
}
}
@@ -278,7 +308,9 @@ void BuildSwitcher::commitArchive()
const QDir dir(buildDir(m_fromId));
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
for (const QString &name : zips) {
if (dir.absoluteFilePath(name) != target)
// Служебные файлы под удаление не попадают: в .pack-*.zip может лежать
// пак недоигранной раскатки, без которого её нечем довести до конца.
if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target)
QFile::remove(dir.absoluteFilePath(name));
}
@@ -323,6 +355,98 @@ void BuildSwitcher::beginRestoring()
requestRestore(archive, m_gameDir);
}
// ── Раскатка пака сезонной сборки ──────────────────────────────────────────
void BuildSwitcher::applyPack(int buildId,
const QString &buildName,
const QString &packZipPath,
const QStringList &removeRelative,
const QJsonObject &note,
const QString &gameDir)
{
if (m_state != Idle) {
emit failed(buildId, tr("Операция со сборками уже выполняется"), true);
return;
}
QString rootError;
if (!LauncherPaths::ensureRootExists(&rootError)) {
emit failed(buildId, rootError, true);
return;
}
if (!QFile::exists(packZipPath)) {
emit failed(buildId,
tr("Архив сборки не найден: %1")
.arg(QDir::toNativeSeparators(packZipPath)),
true);
return;
}
m_fromId = -1;
m_toId = buildId;
m_toName = buildName;
m_gameDir = gameDir;
m_packPath = packZipPath;
m_packRemove = removeRelative;
m_packNote = note;
m_lastPackEntries.clear();
m_lastPackNote = {};
m_done = 0;
m_total = 0;
m_worker->clearCancel();
// Отметка ставится до первого разрушающего действия — как и при смене
// сборки. Пак лежит рядом и переживёт перезапуск вместе с ней.
QJsonObject index = readIndex();
index.insert(QStringLiteral("state"), kStateApplying);
index.insert(QStringLiteral("to"), buildId);
index.insert(QStringLiteral("pack"), QFileInfo(packZipPath).fileName());
index.insert(QStringLiteral("packName"), buildName);
index.insert(QStringLiteral("packRemove"), QJsonArray::fromStringList(removeRelative));
index.insert(QStringLiteral("packNote"), note);
writeIndex(index);
m_state = Applying;
beginApplying();
}
void BuildSwitcher::beginApplying()
{
setStage(tr("Установка сборки «%1»").arg(m_toName));
requestApplyPack(m_packPath, m_gameDir, m_packRemove);
}
void BuildSwitcher::finishApplying()
{
// Пак сделал своё дело: держать сотни мегабайт рядом с архивом сборки не
// за чем, а его наличие означало бы недоигранную раскатку.
QFile::remove(m_packPath);
QJsonObject index = readIndex();
index.insert(QStringLiteral("state"), kStateIdle);
index.insert(QStringLiteral("active"), m_toId);
index.remove(QStringLiteral("from"));
index.remove(QStringLiteral("to"));
index.remove(QStringLiteral("pack"));
index.remove(QStringLiteral("packName"));
index.remove(QStringLiteral("packRemove"));
index.remove(QStringLiteral("packNote"));
writeIndex(index);
m_lastPackNote = m_packNote;
m_packPath.clear();
m_packRemove.clear();
m_packNote = {};
const int toId = m_toId;
m_state = Idle;
m_stage.clear();
m_status.clear();
m_done = m_total = 0;
emit progressChanged();
emit finished(toId);
}
void BuildSwitcher::finish()
{
QJsonObject index = readIndex();
@@ -379,7 +503,14 @@ bool BuildSwitcher::forgetBuild(int buildId)
QString BuildSwitcher::interruptedSwitchWarning() const
{
const QJsonObject index = readIndex();
if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
const QString state = index.value(QStringLiteral("state")).toString();
if (state == kStateApplying) {
const QString name = index.value(QStringLiteral("packName")).toString();
return tr("Установка сборки%1 не завершилась — докатываем её файлы.")
.arg(name.isEmpty() ? QString() : tr(" «%1»").arg(name));
}
if (state != kStateSwitching)
return {};
const int toId = index.value(QStringLiteral("to")).toInt(-1);
@@ -395,7 +526,13 @@ QString BuildSwitcher::interruptedSwitchWarning() const
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
{
const QJsonObject index = readIndex();
if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
const QString state = index.value(QStringLiteral("state")).toString();
if (state == kStateApplying) {
resumeApplying(index, gameDir);
return;
}
if (state != kStateSwitching)
return;
const int toId = index.value(QStringLiteral("to")).toInt(-1);
@@ -419,6 +556,50 @@ void BuildSwitcher::resumeInterrupted(const QString &gameDir)
beginClearing();
}
void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &gameDir)
{
const int toId = index.value(QStringLiteral("to")).toInt(-1);
if (toId < 0 || m_state != Idle)
return;
const QString packName = index.value(QStringLiteral("pack")).toString();
const QString packPath = buildDir(toId) + u'/' + packName;
if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) {
// Пак не сохранился — доигрывать нечем. Отметку снимаем, иначе
// предупреждение о незавершённой установке будет всплывать вечно;
// сборка при этом остаётся наполовину обновлённой, и лаунчер покажет
// её как требующую обновления.
QJsonObject cleaned = index;
cleaned.insert(QStringLiteral("state"), kStateIdle);
cleaned.remove(QStringLiteral("pack"));
cleaned.remove(QStringLiteral("packName"));
cleaned.remove(QStringLiteral("packRemove"));
cleaned.remove(QStringLiteral("packNote"));
cleaned.remove(QStringLiteral("to"));
writeIndex(cleaned);
return;
}
QStringList remove;
const QJsonArray removeArray = index.value(QStringLiteral("packRemove")).toArray();
for (const QJsonValue &value : removeArray)
remove << value.toString();
m_fromId = -1;
m_toId = toId;
m_toName = index.value(QStringLiteral("packName")).toString();
m_gameDir = gameDir;
m_packPath = packPath;
m_packRemove = remove;
m_packNote = index.value(QStringLiteral("packNote")).toObject();
m_lastPackEntries.clear();
m_lastPackNote = {};
m_done = m_total = 0;
m_worker->clearCancel();
m_state = Applying;
beginApplying();
}
// ── Реестр архивов ─────────────────────────────────────────────────────────
QJsonObject BuildSwitcher::readIndex() const
@@ -445,7 +626,8 @@ void BuildSwitcher::writeIndex(const QJsonObject &index) const
}
// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может
// занимать гигабайты.
// занимать гигабайты. Пакеты .pack-*.zip не трогаем: по ним доигрывается
// прерванная раскатка сезонной сборки, а лишний тут удаляет resumeApplying.
void BuildSwitcher::sweepTempArchives() const
{
QDir root(LauncherPaths::buildStorageDir());