Files
minecraft-launcher/buildswitcher.cpp
T

644 lines
24 KiB
C++
Raw Normal View History

#include "buildswitcher.h"
#include "localization.h"
#include "buildarchiveworker.h"
#include "launcherpaths.h"
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
2026-08-31 00:59:14 +03:00
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include <QStorageInfo>
#include <QThread>
#include <QUuid>
namespace {
const QString kIndexFile = QStringLiteral("index.json");
const QString kStateSwitching = QStringLiteral("switching");
2026-08-31 00:59:14 +03:00
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);
2026-08-31 00:59:14 +03:00
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
{
2026-08-31 00:59:14 +03:00
return LauncherPaths::buildDir(buildId);
}
// На сборку — ровно один архив: имя с датой меняется от переключения к
// переключению, поэтому ищем по расширению, а не по имени.
QString BuildSwitcher::archivePathOf(int buildId) const
{
const QDir dir(buildDir(buildId));
2026-08-31 00:59:14 +03:00
// Рядом лежат служебные .tmp-*.zip и .pack-*.zip. На Unix они скрытые и в
// выборку не попадают, на Windows — попадают, поэтому отсекаем по имени.
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
2026-08-31 00:59:14 +03:00
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));
}
2026-08-31 00:59:14 +03:00
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;
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;
2026-08-31 00:59:14 +03:00
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) {
2026-08-31 00:59:14 +03:00
// Служебные файлы под удаление не попадают: в .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);
}
2026-08-31 00:59:14 +03:00
// ── Раскатка пака сезонной сборки ──────────────────────────────────────────
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, Loc::text("switch.error.busy"), true);
2026-08-31 00:59:14 +03:00
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")
2026-08-31 00:59:14 +03:00
.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));
2026-08-31 00:59:14 +03:00
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);
}
2026-08-27 16:44:42 +03:00
// ── Удаление сборки ────────────────────────────────────────────────────────
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();
2026-08-31 00:59:14 +03:00
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));
2026-08-31 00:59:14 +03:00
}
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();
2026-08-31 00:59:14 +03:00
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();
}
2026-08-31 00:59:14 +03:00
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: он никому не нужен и может
2026-08-31 00:59:14 +03:00
// занимать гигабайты. Пакеты .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));
}
}