Files
minecraft-launcher/buildswitcher.cpp
T

439 lines
16 KiB
C++
Raw Normal View History

#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);
}
// ── Незавершённое переключение ─────────────────────────────────────────────
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));
}
}