#include "buildswitcher.h" #include "buildarchiveworker.h" #include "launcherpaths.h" #include #include #include #include #include #include #include #include #include #include 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 ¤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; } } 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)); } }