644 lines
24 KiB
C++
644 lines
24 KiB
C++
#include "buildswitcher.h"
|
||
#include "localization.h"
|
||
|
||
#include "buildarchiveworker.h"
|
||
#include "launcherpaths.h"
|
||
|
||
#include <QDateTime>
|
||
#include <QDir>
|
||
#include <QDirIterator>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
#include <QJsonArray>
|
||
#include <QJsonDocument>
|
||
#include <QSaveFile>
|
||
#include <QStorageInfo>
|
||
#include <QThread>
|
||
#include <QUuid>
|
||
|
||
namespace {
|
||
|
||
const QString kIndexFile = QStringLiteral("index.json");
|
||
const QString kStateSwitching = QStringLiteral("switching");
|
||
const QString kStateApplying = QStringLiteral("applying");
|
||
const QString kStateIdle = QStringLiteral("idle");
|
||
|
||
// Запас на архив: точный размер заранее неизвестен, но упасть на середине
|
||
// записи из-за полного диска хуже, чем отказаться заранее.
|
||
constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024;
|
||
|
||
// Имя файла из названия сборки: оно попадает в файловую систему, поэтому всё
|
||
// небезопасное заменяется подчёркиванием.
|
||
QString slug(const QString &name)
|
||
{
|
||
QString result;
|
||
result.reserve(name.size());
|
||
for (const QChar c : name) {
|
||
if (c.isLetterOrNumber() || c == u'-' || c == u'_')
|
||
result.append(c);
|
||
else if (c.isSpace() || c == u'.')
|
||
result.append(u'_');
|
||
}
|
||
while (result.startsWith(u'_'))
|
||
result.remove(0, 1);
|
||
return result.isEmpty() ? QStringLiteral("build") : result.left(48);
|
||
}
|
||
|
||
qint64 directorySize(const QString &path, const QStringList &excludeTop)
|
||
{
|
||
qint64 total = 0;
|
||
const QDir root(path);
|
||
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||
| QDir::Hidden | QDir::System);
|
||
for (const QFileInfo &entry : top) {
|
||
if (excludeTop.contains(entry.fileName()) || entry.isSymLink())
|
||
continue;
|
||
if (entry.isFile()) {
|
||
total += entry.size();
|
||
continue;
|
||
}
|
||
QDirIterator it(entry.absoluteFilePath(),
|
||
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||
QDirIterator::Subdirectories);
|
||
while (it.hasNext()) {
|
||
it.next();
|
||
total += it.fileInfo().size();
|
||
}
|
||
}
|
||
return total;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
QStringList BuildSwitcher::sharedTopLevel()
|
||
{
|
||
// Общий кэш, одинаковый для всех сборок и всегда восстановимый загрузкой.
|
||
// versions/ здесь не случайно: без libraries/ описания версий бесполезны, а
|
||
// две сборки на одной версии игры иначе таскали бы по копии клиентского jar.
|
||
// Чем сборка запускается, помнит resolvedVersionId, а не набор папок.
|
||
return {QStringLiteral("assets"),
|
||
QStringLiteral("libraries"),
|
||
QStringLiteral("versions"),
|
||
QStringLiteral("launcher_profiles.json"),
|
||
QStringLiteral("runtime")};
|
||
}
|
||
|
||
BuildSwitcher::BuildSwitcher(QObject *parent)
|
||
: QObject(parent)
|
||
, m_thread(new QThread(this))
|
||
, m_worker(new BuildArchiveWorker)
|
||
{
|
||
m_worker->moveToThread(m_thread);
|
||
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();
|
||
}
|
||
|
||
BuildSwitcher::~BuildSwitcher()
|
||
{
|
||
m_worker->requestCancel();
|
||
m_thread->quit();
|
||
m_thread->wait(5000);
|
||
}
|
||
|
||
double BuildSwitcher::fraction() const
|
||
{
|
||
if (m_state == Idle || m_total <= 0)
|
||
return -1.0;
|
||
return double(m_done) / double(m_total);
|
||
}
|
||
|
||
QString BuildSwitcher::buildDir(int buildId) const
|
||
{
|
||
return LauncherPaths::buildDir(buildId);
|
||
}
|
||
|
||
// На сборку — ровно один архив: имя с датой меняется от переключения к
|
||
// переключению, поэтому ищем по расширению, а не по имени.
|
||
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);
|
||
for (const QString &name : zips) {
|
||
if (!name.startsWith(u'.'))
|
||
return dir.absoluteFilePath(name);
|
||
}
|
||
return {};
|
||
}
|
||
|
||
void BuildSwitcher::setStage(const QString &stage)
|
||
{
|
||
m_stage = stage;
|
||
emit progressChanged();
|
||
}
|
||
|
||
void BuildSwitcher::switchTo(int fromBuildId,
|
||
const QString &fromName,
|
||
int toBuildId,
|
||
const QString &toName,
|
||
const QString &gameDir)
|
||
{
|
||
if (m_state != Idle) {
|
||
emit failed(toBuildId, Loc::text("common.error.switchInProgress"), true);
|
||
return;
|
||
}
|
||
|
||
QString rootError;
|
||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||
emit failed(toBuildId, rootError, true);
|
||
return;
|
||
}
|
||
|
||
m_fromId = fromBuildId;
|
||
m_toId = toBuildId;
|
||
m_fromName = fromName;
|
||
m_toName = toName;
|
||
m_gameDir = gameDir;
|
||
m_done = 0;
|
||
m_total = 0;
|
||
m_worker->clearCancel();
|
||
|
||
// Первая сборка за всё время: архивировать нечего, сразу разворачиваем цель.
|
||
if (fromBuildId < 0 || !QDir(gameDir).exists()) {
|
||
m_state = Clearing;
|
||
beginClearing();
|
||
return;
|
||
}
|
||
|
||
if (!QDir().mkpath(buildDir(fromBuildId))) {
|
||
emit failed(toBuildId,
|
||
Loc::text("common.error.createFailed")
|
||
.arg(QDir::toNativeSeparators(buildDir(fromBuildId))),
|
||
true);
|
||
return;
|
||
}
|
||
|
||
const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes;
|
||
const QStorageInfo storage(LauncherPaths::buildStorageDir());
|
||
if (storage.isValid() && storage.bytesAvailable() < needed) {
|
||
emit failed(toBuildId,
|
||
Loc::text("switch.error.notEnoughSpace")
|
||
.arg(fromName)
|
||
.arg(needed / (1024 * 1024)),
|
||
true);
|
||
return;
|
||
}
|
||
|
||
m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-")
|
||
+ QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip");
|
||
|
||
m_state = Archiving;
|
||
setStage(Loc::text("switch.progress.saving").arg(fromName));
|
||
requestArchive(gameDir, m_tempZip, sharedTopLevel());
|
||
}
|
||
|
||
void BuildSwitcher::cancel()
|
||
{
|
||
// Отмена имеет смысл только на архивации: после очистки папки отступать
|
||
// уже некуда, там нужно доводить дело до конца.
|
||
if (m_state == Archiving)
|
||
m_worker->requestCancel();
|
||
}
|
||
|
||
void BuildSwitcher::requestArchive(const QString &gameDir,
|
||
const QString &tempZip,
|
||
const QStringList &exclude)
|
||
{
|
||
QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection,
|
||
Q_ARG(QString, gameDir), Q_ARG(QString, tempZip),
|
||
Q_ARG(QStringList, exclude));
|
||
}
|
||
|
||
void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep)
|
||
{
|
||
QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection,
|
||
Q_ARG(QString, gameDir), Q_ARG(QStringList, keep));
|
||
}
|
||
|
||
void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir)
|
||
{
|
||
QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection,
|
||
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 ¤tPath)
|
||
{
|
||
m_done = done;
|
||
m_total = total;
|
||
m_status = currentPath;
|
||
emit progressChanged();
|
||
}
|
||
|
||
void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
|
||
{
|
||
switch (m_state) {
|
||
case Idle:
|
||
return;
|
||
case Archiving:
|
||
if (!ok) {
|
||
QFile::remove(m_tempZip);
|
||
fail(error, /*gameDirIntact=*/true);
|
||
return;
|
||
}
|
||
commitArchive();
|
||
return;
|
||
case Clearing:
|
||
if (!ok) {
|
||
// Папка осталась разобранной: активной сборкой считается прежняя,
|
||
// отметка о незавершённом переключении в index.json остаётся.
|
||
fail(error, /*gameDirIntact=*/false);
|
||
return;
|
||
}
|
||
beginRestoring();
|
||
return;
|
||
case Restoring:
|
||
if (!ok) {
|
||
fail(error, /*gameDirIntact=*/false);
|
||
return;
|
||
}
|
||
finish();
|
||
return;
|
||
case Applying:
|
||
if (!ok) {
|
||
// Архив проверяется целиком до первой записи, поэтому нулевой
|
||
// счётчик означает, что в папке игры ничего не тронуто. Иначе
|
||
// часть файлов уже заменена: отметка в index.json остаётся, и
|
||
// следующий запуск доиграет раскатку из сохранённого пака.
|
||
fail(error, /*gameDirIntact=*/m_done == 0);
|
||
return;
|
||
}
|
||
finishApplying();
|
||
return;
|
||
}
|
||
}
|
||
|
||
void BuildSwitcher::commitArchive()
|
||
{
|
||
const QString target = buildDir(m_fromId) + u'/'
|
||
+ QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))
|
||
+ u'_' + slug(m_fromName) + QStringLiteral(".zip");
|
||
|
||
// На сборку — один архив: новый встаёт на место, все прежние уходят.
|
||
if (QFile::exists(target) && !QFile::remove(target)) {
|
||
QFile::remove(m_tempZip);
|
||
fail(Loc::text("switch.error.archiveReplaceFailed").arg(QDir::toNativeSeparators(target)), true);
|
||
return;
|
||
}
|
||
if (!QFile::rename(m_tempZip, target)) {
|
||
QFile::remove(m_tempZip);
|
||
fail(Loc::text("switch.error.archiveSaveFailed").arg(QDir::toNativeSeparators(target)), true);
|
||
return;
|
||
}
|
||
|
||
const QDir dir(buildDir(m_fromId));
|
||
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
|
||
for (const QString &name : zips) {
|
||
// Служебные файлы под удаление не попадают: в .pack-*.zip может лежать
|
||
// пак недоигранной раскатки, без которого её нечем довести до конца.
|
||
if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target)
|
||
QFile::remove(dir.absoluteFilePath(name));
|
||
}
|
||
|
||
// Отметка ставится до первого разрушающего действия: по ней следующий
|
||
// запуск поймёт, что переключение оборвалось на середине.
|
||
QJsonObject index = readIndex();
|
||
index.insert(QStringLiteral("state"), kStateSwitching);
|
||
index.insert(QStringLiteral("from"), m_fromId);
|
||
index.insert(QStringLiteral("to"), m_toId);
|
||
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||
builds.insert(QString::number(m_fromId),
|
||
QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()},
|
||
{QStringLiteral("name"), m_fromName},
|
||
{QStringLiteral("savedAt"),
|
||
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||
index.insert(QStringLiteral("builds"), builds);
|
||
writeIndex(index);
|
||
|
||
m_state = Clearing;
|
||
beginClearing();
|
||
}
|
||
|
||
void BuildSwitcher::beginClearing()
|
||
{
|
||
setStage(Loc::text("switch.progress.preparing").arg(m_toName));
|
||
requestClear(m_gameDir, sharedTopLevel());
|
||
}
|
||
|
||
void BuildSwitcher::beginRestoring()
|
||
{
|
||
const QString archive = archivePathOf(m_toId);
|
||
if (archive.isEmpty()) {
|
||
// Сборка ещё ни разу не запускалась: пустая .minecraft рядом с общими
|
||
// кэшами — это и есть её правильное начальное состояние.
|
||
m_state = Restoring;
|
||
finish();
|
||
return;
|
||
}
|
||
|
||
m_state = Restoring;
|
||
setStage(Loc::text("switch.progress.restoring").arg(m_toName));
|
||
requestRestore(archive, m_gameDir);
|
||
}
|
||
|
||
// ── Раскатка пака сезонной сборки ──────────────────────────────────────────
|
||
|
||
void BuildSwitcher::applyPack(int buildId,
|
||
const QString &buildName,
|
||
const QString &packZipPath,
|
||
const QStringList &removeRelative,
|
||
const QJsonObject ¬e,
|
||
const QString &gameDir)
|
||
{
|
||
if (m_state != Idle) {
|
||
emit failed(buildId, Loc::text("switch.error.busy"), true);
|
||
return;
|
||
}
|
||
|
||
QString rootError;
|
||
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||
emit failed(buildId, rootError, true);
|
||
return;
|
||
}
|
||
if (!QFile::exists(packZipPath)) {
|
||
emit failed(buildId,
|
||
Loc::text("switch.error.archiveMissing")
|
||
.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(Loc::text("switch.progress.installing").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();
|
||
index.insert(QStringLiteral("state"), kStateIdle);
|
||
index.insert(QStringLiteral("active"), m_toId);
|
||
index.remove(QStringLiteral("from"));
|
||
index.remove(QStringLiteral("to"));
|
||
writeIndex(index);
|
||
|
||
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::fail(const QString &message, bool gameDirIntact)
|
||
{
|
||
const int toId = m_toId;
|
||
m_state = Idle;
|
||
m_stage.clear();
|
||
m_status.clear();
|
||
m_done = m_total = 0;
|
||
emit progressChanged();
|
||
emit failed(toId, message, gameDirIntact);
|
||
}
|
||
|
||
// ── Удаление сборки ────────────────────────────────────────────────────────
|
||
|
||
bool BuildSwitcher::forgetBuild(int buildId)
|
||
{
|
||
if (buildId < 0)
|
||
return true;
|
||
|
||
// Запись убираем в любом случае: даже если файл не удалось стереть, ссылаться
|
||
// на архив несуществующей сборки нельзя — иначе он «воскреснет» под новой
|
||
// сборкой с тем же id.
|
||
QJsonObject index = readIndex();
|
||
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||
builds.remove(QString::number(buildId));
|
||
index.insert(QStringLiteral("builds"), builds);
|
||
if (index.value(QStringLiteral("active")).toInt(-1) == buildId)
|
||
index.remove(QStringLiteral("active"));
|
||
writeIndex(index);
|
||
|
||
QDir dir(buildDir(buildId));
|
||
return !dir.exists() || dir.removeRecursively();
|
||
}
|
||
|
||
// ── Незавершённое переключение ─────────────────────────────────────────────
|
||
|
||
QString BuildSwitcher::interruptedSwitchWarning() const
|
||
{
|
||
const QJsonObject index = readIndex();
|
||
const QString state = index.value(QStringLiteral("state")).toString();
|
||
|
||
if (state == kStateApplying) {
|
||
const QString name = index.value(QStringLiteral("packName")).toString();
|
||
return Loc::text("switch.status.applyInterrupted")
|
||
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.nameSuffix").arg(name));
|
||
}
|
||
if (state != kStateSwitching)
|
||
return {};
|
||
|
||
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||
const QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||
const QString name = builds.value(QString::number(toId))
|
||
.toObject()
|
||
.value(QStringLiteral("name"))
|
||
.toString();
|
||
return Loc::text("switch.status.switchInterrupted")
|
||
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.targetSuffix").arg(name));
|
||
}
|
||
|
||
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||
{
|
||
const QJsonObject index = readIndex();
|
||
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);
|
||
if (toId < 0 || m_state != Idle)
|
||
return;
|
||
|
||
// Архив исходной сборки уже записан целиком — доигрываем только вторую
|
||
// половину: очистку и распаковку цели.
|
||
m_fromId = -1;
|
||
m_toId = toId;
|
||
m_toName = index.value(QStringLiteral("builds"))
|
||
.toObject()
|
||
.value(QString::number(toId))
|
||
.toObject()
|
||
.value(QStringLiteral("name"))
|
||
.toString();
|
||
m_gameDir = gameDir;
|
||
m_done = m_total = 0;
|
||
m_worker->clearCancel();
|
||
m_state = Clearing;
|
||
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
|
||
{
|
||
QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||
if (!file.open(QIODevice::ReadOnly))
|
||
return {};
|
||
return QJsonDocument::fromJson(file.readAll()).object();
|
||
}
|
||
|
||
void BuildSwitcher::writeIndex(const QJsonObject &index) const
|
||
{
|
||
if (!LauncherPaths::ensureRootExists())
|
||
return;
|
||
QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||
if (!file.open(QIODevice::WriteOnly))
|
||
return;
|
||
const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented);
|
||
if (file.write(payload) != payload.size()) {
|
||
file.cancelWriting();
|
||
return;
|
||
}
|
||
file.commit();
|
||
}
|
||
|
||
// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может
|
||
// занимать гигабайты. Пакеты .pack-*.zip не трогаем: по ним доигрывается
|
||
// прерванная раскатка сезонной сборки, а лишний тут удаляет resumeApplying.
|
||
void BuildSwitcher::sweepTempArchives() const
|
||
{
|
||
QDir root(LauncherPaths::buildStorageDir());
|
||
const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||
for (const QString &build : builds) {
|
||
QDir dir(root.absoluteFilePath(build));
|
||
const QStringList leftovers =
|
||
dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden);
|
||
for (const QString &name : leftovers)
|
||
QFile::remove(dir.absoluteFilePath(name));
|
||
}
|
||
}
|