new builds menu and modloaders implementation
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
#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 ¤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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user