From af4101744626e38953aacf789dabba0d67c1d7a3 Mon Sep 17 00:00:00 2001 From: galeon Date: Thu, 27 Aug 2026 21:05:37 +0300 Subject: [PATCH] verison deletion feature --- VersionPickerDialog.qml | 219 ++++++++++++++++++++++++++++++++++++++-- launcherbackend.cpp | 57 +++++++++++ launcherbackend.h | 3 + minecraftversion.cpp | 84 +++++++++++++++ minecraftversion.h | 10 ++ 5 files changed, 365 insertions(+), 8 deletions(-) diff --git a/VersionPickerDialog.qml b/VersionPickerDialog.qml index 43643ce..4fbd825 100644 --- a/VersionPickerDialog.qml +++ b/VersionPickerDialog.qml @@ -108,6 +108,183 @@ Dialog { picker.close() } + function catalogHas(versionId) { + const source = picker.backend.versionCatalog + for (var i = 0; i < source.length; ++i) { + if (source[i].id === versionId) + return true + } + return false + } + + // Спрашиваем до удаления: версию придётся качать заново, а профили + // модлоадеров поверх неё — переустанавливать. Id запоминаем здесь: + // к моменту ответа строка под курсором может быть уже другой. + function askRemove(versionId) { + const info = picker.backend.versionRemovalInfo(versionId) + if (!info || info.installed !== true) + return + removeConfirm.versionId = versionId + removeConfirm.sizeMb = info.sizeMb || 0 + removeConfirm.dependents = info.dependents || [] + removeConfirm.builds = info.builds || [] + removeConfirm.open() + } + + function performRemove(versionId) { + picker.backend.removeVersion(versionId) + // Профиль модлоадера был в каталоге только потому, что установлен: + // после удаления строка исчезает совсем, и выбор надо снять. + if (picker.selectedId === versionId && !picker.catalogHas(versionId)) + picker.selectedId = "" + picker.revealSelected() + } + + Dialog { + id: removeConfirm + + property string versionId: "" + property int sizeMb: 0 + property var dependents: [] + property var builds: [] + + modal: true + padding: 0 + width: 420 + parent: picker.parent + x: (picker.parent.width - width) / 2 + y: (picker.parent.height - height) / 2 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#1e1e1e" + radius: 10 + border.color: "#cc6666" + border.width: 1 + } + + header: Item { + implicitHeight: 52 + Text { + anchors.centerIn: parent + text: qsTr("Удалить версию?") + color: "#ffffff" + font.pixelSize: 17 + font.bold: true + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#333333" + } + } + + contentItem: Column { + spacing: 10 + topPadding: 18 + bottomPadding: 18 + + Text { + x: 20 + width: parent.width - 40 + wrapMode: Text.Wrap + color: "#ffffff" + font.pixelSize: 13 + text: removeConfirm.sizeMb > 0 + ? qsTr("Файлы версии «%1» будут удалены из versions/ — освободится около %2 МБ. Скачать её заново можно в любой момент.") + .arg(removeConfirm.versionId).arg(removeConfirm.sizeMb) + : qsTr("Файлы версии «%1» будут удалены из versions/. Скачать её заново можно в любой момент.") + .arg(removeConfirm.versionId) + } + + Text { + x: 20 + width: parent.width - 40 + visible: removeConfirm.dependents.length > 0 + wrapMode: Text.Wrap + color: "#cc6666" + font.pixelSize: 12 + text: qsTr("На эту версию опираются установленные профили модлоадеров: %1. Без неё они не запустятся — их придётся переустановить.") + .arg(removeConfirm.dependents.join(", ")) + } + + Text { + x: 20 + width: parent.width - 40 + visible: removeConfirm.builds.length > 0 + wrapMode: Text.Wrap + color: "#cc6666" + font.pixelSize: 12 + text: qsTr("Версию используют сборки: %1. Файлы понадобится скачать заново кнопкой «Установить».") + .arg(removeConfirm.builds.join(", ")) + } + + Text { + x: 20 + width: parent.width - 40 + wrapMode: Text.Wrap + color: "#888888" + font.pixelSize: 12 + text: qsTr("Библиотеки и ресурсы в libraries/ и assets/ общие для всех версий и остаются на месте.") + } + } + + footer: Item { + implicitHeight: 60 + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: "#333333" + } + Row { + anchors.centerIn: parent + spacing: 12 + + Button { + text: qsTr("Отмена") + width: 130; height: 36 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#6a8510" : "#91B315" + radius: 6 + } + onClicked: removeConfirm.close() + } + + Button { + text: qsTr("Удалить") + width: 130; height: 36 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#8a3a3a" : "#663333" + radius: 6 + border.color: "#cc6666" + border.width: 1 + } + onClicked: { + const id = removeConfirm.versionId + removeConfirm.close() + picker.performRemove(id) + } + } + } + } + + onClosed: removeConfirm.versionId = "" + } + header: Item { implicitHeight: 52 Text { @@ -291,7 +468,7 @@ Dialog { Text { anchors.left: parent.left anchors.leftMargin: 10 - anchors.right: installedMark.left + anchors.right: rightMarks.left anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter text: versionRow.modelData.label @@ -300,21 +477,47 @@ Dialog { font.pixelSize: 13 } - Text { - id: installedMark + Row { + id: rightMarks anchors.right: parent.right anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter - visible: versionRow.modelData.installed - text: "✓" - color: picker.selectedId === versionRow.modelData.id ? "#1e1e1e" : "#91B315" - font.pixelSize: 13 - font.bold: true + spacing: 8 + + Text { + visible: versionRow.modelData.installed + text: "✓" + color: picker.selectedId === versionRow.modelData.id ? "#1e1e1e" : "#91B315" + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + + // Версия весит от десятков мегабайт, а галочка рядом — + // единственное место, где видно, что именно скачано. + Image { + visible: versionRow.modelData.installed + source: "images/Trash.svg" + width: 14 + height: 14 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + opacity: trashArea.containsMouse ? 1.0 : 0.55 + + MouseArea { + id: trashArea + anchors.fill: parent + anchors.margins: -5 + hoverEnabled: true + onClicked: picker.askRemove(versionRow.modelData.id) + } + } } MouseArea { id: versionArea anchors.fill: parent + anchors.rightMargin: 40 hoverEnabled: true onClicked: { picker.selectedId = versionRow.modelData.id diff --git a/launcherbackend.cpp b/launcherbackend.cpp index c36fdb3..fbb80d8 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -609,6 +609,63 @@ bool LauncherBackend::isVersionInstalled(const QString &versionId) const return VersionLoader::installedVersions(gameDir()).contains(versionId); } +QVariantMap LauncherBackend::versionRemovalInfo(const QString &versionId) const +{ + const QString id = versionId.trimmed(); + if (id.isEmpty()) + return {}; + + const qint64 bytes = VersionLoader::installedSize(gameDir(), id); + // Сборки не правим при удалении — ссылка на неустановленную версию это + // рабочее состояние. Но перечислить, кого заденет, надо. + QStringList builds; + for (const CustomBuild &build : m_builds) { + if (build.minecraftVersion == id || build.resolvedVersionId == id + || launchVersionIdOf(build) == id) { + builds << build.name; + } + } + + return {{QStringLiteral("id"), id}, + {QStringLiteral("installed"), isVersionInstalled(id)}, + {QStringLiteral("sizeMb"), static_cast((bytes + 1024 * 1024 - 1) / (1024 * 1024))}, + {QStringLiteral("dependents"), VersionLoader::dependentsOf(gameDir(), id)}, + {QStringLiteral("builds"), builds}}; +} + +void LauncherBackend::removeVersion(const QString &versionId) +{ + const QString id = versionId.trimmed(); + if (id.isEmpty()) + return; + + if (m_installer->versionId() == id || m_installer->isQueued(id)) { + emit launchError(tr("Эта версия сейчас скачивается")); + return; + } + if (m_switcher->isRunning() || m_busy || m_installer->isRunning() + || m_loaderInstaller->isRunning()) { + emit launchError(tr("Дождитесь окончания текущей операции")); + return; + } + // Какой версией сейчас играют, GameLauncher наружу не отдаёт, поэтому на + // время игры удаление запрещено целиком. + if (m_launcher->isRunning()) { + emit launchError(tr("Сначала закройте игру")); + return; + } + + QString error; + if (!VersionLoader::remove(gameDir(), id, &error)) { + emit launchError(tr("Не удалось удалить версию: %1").arg(error)); + return; + } + + invalidateCatalog(); + emit installedVersionsChanged(); + emit launchProgress(tr("Версия «%1» удалена").arg(id)); +} + QVariantList LauncherBackend::versionCatalog() const { if (m_catalogDirty) { diff --git a/launcherbackend.h b/launcherbackend.h index 7fce715..35d661f 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -135,6 +135,9 @@ public: Q_INVOKABLE void refreshVersionCatalog(bool force = false); Q_INVOKABLE bool isVersionInstalled(const QString &versionId) const; Q_INVOKABLE void installVersion(const QString &versionId); + // Что потеряется при удалении версии — для текста предупреждения. + Q_INVOKABLE QVariantMap versionRemovalInfo(const QString &versionId) const; + Q_INVOKABLE void removeVersion(const QString &versionId); Q_INVOKABLE void cancelDownload(); // Версии модлоадеров для выбранной версии игры. Возвращает список карт diff --git a/minecraftversion.cpp b/minecraftversion.cpp index efc76a3..7f40ddc 100644 --- a/minecraftversion.cpp +++ b/minecraftversion.cpp @@ -1,6 +1,7 @@ #include "minecraftversion.h" #include +#include #include #include #include @@ -27,6 +28,27 @@ QString versionJarPath(const QString &gameDir, const QString &id) return gameDir + "/versions/" + id + "/" + id + ".jar"; } +QString versionsRoot(const QString &gameDir) +{ + return gameDir + QStringLiteral("/versions"); +} + +// Идентификатор версии — это имя папки, поэтому «чинить» его как sanitizeId() +// нельзя: подстановка символов увела бы нас не в ту версию. Только проверяем. +bool isSafeVersionId(const QString &id) +{ + return !id.isEmpty() && !id.startsWith(u'.') && !id.contains(u'/') && !id.contains(u'\\'); +} + +// Верхний объект .json без разворачивания цепочки: нужен только inheritsFrom. +QJsonObject readVersionObject(const QString &gameDir, const QString &id) +{ + QFile file(versionJsonPath(gameDir, id)); + if (!file.open(QIODevice::ReadOnly)) + return {}; + return QJsonDocument::fromJson(file.readAll()).object(); +} + // org.lwjgl:lwjgl:3.3.1:natives-windows -> org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar QString mavenToPath(const QString &name) { @@ -311,6 +333,68 @@ QStringList VersionLoader::installedVersions(const QString &gameDir) return result; } +QStringList VersionLoader::dependentsOf(const QString &gameDir, const QString &versionId) +{ + if (versionId.isEmpty()) + return {}; + + // Одного уровня хватает: профили модлоадеров наследуются от ванильных версий, + // а не друг от друга. + QStringList result; + const QStringList installed = installedVersions(gameDir); + for (const QString &id : installed) { + if (id == versionId) + continue; + const QJsonObject object = readVersionObject(gameDir, id); + if (object.value(QStringLiteral("inheritsFrom")).toString() == versionId) + result << id; + } + return result; +} + +qint64 VersionLoader::installedSize(const QString &gameDir, const QString &versionId) +{ + if (!isSafeVersionId(versionId)) + return 0; + + qint64 total = 0; + QDirIterator it(versionsRoot(gameDir) + u'/' + versionId, + QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + total += it.fileInfo().size(); + } + return total; +} + +bool VersionLoader::remove(const QString &gameDir, const QString &versionId, QString *error) +{ + const QString root = versionsRoot(gameDir); + if (gameDir.isEmpty() || !isSafeVersionId(versionId)) { + if (error) + *error = QObject::tr("недопустимый идентификатор версии"); + return false; + } + + // Удаляем только внутри /versions: проверка id уже не пропустит «..», + // но цена ошибки — чужая папка, а проверка стоит одной строки. + const QString dir = QDir::cleanPath(root + u'/' + versionId); + if (!dir.startsWith(root + u'/')) { + if (error) + *error = QObject::tr("недопустимый идентификатор версии"); + return false; + } + if (!QDir(dir).exists()) + return true; + if (!QDir(dir).removeRecursively()) { + if (error) + *error = QObject::tr("не удалось удалить %1").arg(QDir::toNativeSeparators(dir)); + return false; + } + return true; +} + MinecraftVersion VersionLoader::load(const QString &gameDir, const QString &versionId, const QSet &features, diff --git a/minecraftversion.h b/minecraftversion.h index 5d0e27f..31cccbe 100644 --- a/minecraftversion.h +++ b/minecraftversion.h @@ -47,6 +47,16 @@ namespace VersionLoader { // Версии, реально установленные в /versions (есть папка и .json). QStringList installedVersions(const QString &gameDir); +// Установленные профили, у которых inheritsFrom == versionId: без базовой версии +// они не запустятся. +QStringList dependentsOf(const QString &gameDir, const QString &versionId); + +// Размер /versions/ в байтах; 0 — папки нет. +qint64 installedSize(const QString &gameDir, const QString &versionId); + +// Сносит /versions/. Отсутствие папки — успех. +bool remove(const QString &gameDir, const QString &versionId, QString *error); + // Читает и разворачивает версию. При ошибке возвращает невалидный объект и пишет error. MinecraftVersion load(const QString &gameDir, const QString &versionId,