Files
minecraft-launcher/buildswitcher.cpp
T
2026-08-27 16:44:42 +03:00

461 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "buildswitcher.h"
#include "buildarchiveworker.h"
#include "launcherpaths.h"
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QSaveFile>
#include <QStorageInfo>
#include <QThread>
#include <QUuid>
namespace {
const QString kIndexFile = QStringLiteral("index.json");
const QString kStateSwitching = QStringLiteral("switching");
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);
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::buildStorageDir() + u'/' + QString::number(buildId);
}
// На сборку — ровно один архив: имя с датой меняется от переключения к
// переключению, поэтому ищем по расширению, а не по имени.
QString BuildSwitcher::archivePathOf(int buildId) const
{
const QDir dir(buildDir(buildId));
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
return zips.isEmpty() ? QString() : dir.absoluteFilePath(zips.first());
}
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, tr("Смена сборки уже выполняется"), 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,
tr("Не удалось создать %1")
.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,
tr("Недостаточно места для архива сборки «%1»: нужно около %2 МБ")
.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(tr("Сохранение сборки «%1»").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::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;
}
}
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(tr("Не удалось заменить архив %1").arg(QDir::toNativeSeparators(target)), true);
return;
}
if (!QFile::rename(m_tempZip, target)) {
QFile::remove(m_tempZip);
fail(tr("Не удалось сохранить архив %1").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) {
if (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(tr("Подготовка сборки «%1»").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(tr("Восстановление сборки «%1»").arg(m_toName));
requestRestore(archive, m_gameDir);
}
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();
if (index.value(QStringLiteral("state")).toString() != 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 tr("Прошлая смена сборки%1 не завершилась — восстанавливаем содержимое .minecraft.")
.arg(name.isEmpty() ? QString() : tr(" на «%1»").arg(name));
}
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
{
const QJsonObject index = readIndex();
if (index.value(QStringLiteral("state")).toString() != 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();
}
// ── Реестр архивов ─────────────────────────────────────────────────────────
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: он никому не нужен и может
// занимать гигабайты.
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));
}
}