verison deletion feature

This commit is contained in:
2026-08-27 21:05:37 +03:00
parent dddc0261d4
commit af41017446
5 changed files with 365 additions and 8 deletions
+211 -8
View File
@@ -108,6 +108,183 @@ Dialog {
picker.close() 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 { header: Item {
implicitHeight: 52 implicitHeight: 52
Text { Text {
@@ -291,7 +468,7 @@ Dialog {
Text { Text {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 10 anchors.leftMargin: 10
anchors.right: installedMark.left anchors.right: rightMarks.left
anchors.rightMargin: 6 anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: versionRow.modelData.label text: versionRow.modelData.label
@@ -300,21 +477,47 @@ Dialog {
font.pixelSize: 13 font.pixelSize: 13
} }
Text { Row {
id: installedMark id: rightMarks
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 10 anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: versionRow.modelData.installed spacing: 8
text: "✓"
color: picker.selectedId === versionRow.modelData.id ? "#1e1e1e" : "#91B315" Text {
font.pixelSize: 13 visible: versionRow.modelData.installed
font.bold: true 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 { MouseArea {
id: versionArea id: versionArea
anchors.fill: parent anchors.fill: parent
anchors.rightMargin: 40
hoverEnabled: true hoverEnabled: true
onClicked: { onClicked: {
picker.selectedId = versionRow.modelData.id picker.selectedId = versionRow.modelData.id
+57
View File
@@ -609,6 +609,63 @@ bool LauncherBackend::isVersionInstalled(const QString &versionId) const
return VersionLoader::installedVersions(gameDir()).contains(versionId); 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<int>((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 QVariantList LauncherBackend::versionCatalog() const
{ {
if (m_catalogDirty) { if (m_catalogDirty) {
+3
View File
@@ -135,6 +135,9 @@ public:
Q_INVOKABLE void refreshVersionCatalog(bool force = false); Q_INVOKABLE void refreshVersionCatalog(bool force = false);
Q_INVOKABLE bool isVersionInstalled(const QString &versionId) const; Q_INVOKABLE bool isVersionInstalled(const QString &versionId) const;
Q_INVOKABLE void installVersion(const QString &versionId); 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(); Q_INVOKABLE void cancelDownload();
// Версии модлоадеров для выбранной версии игры. Возвращает список карт // Версии модлоадеров для выбранной версии игры. Возвращает список карт
+84
View File
@@ -1,6 +1,7 @@
#include "minecraftversion.h" #include "minecraftversion.h"
#include <QDir> #include <QDir>
#include <QDirIterator>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QHash> #include <QHash>
@@ -27,6 +28,27 @@ QString versionJarPath(const QString &gameDir, const QString &id)
return gameDir + "/versions/" + id + "/" + id + ".jar"; 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'\\');
}
// Верхний объект <id>.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 // 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) QString mavenToPath(const QString &name)
{ {
@@ -311,6 +333,68 @@ QStringList VersionLoader::installedVersions(const QString &gameDir)
return result; 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;
}
// Удаляем только внутри <gameDir>/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, MinecraftVersion VersionLoader::load(const QString &gameDir,
const QString &versionId, const QString &versionId,
const QSet<QString> &features, const QSet<QString> &features,
+10
View File
@@ -47,6 +47,16 @@ namespace VersionLoader {
// Версии, реально установленные в <gameDir>/versions (есть папка и <id>.json). // Версии, реально установленные в <gameDir>/versions (есть папка и <id>.json).
QStringList installedVersions(const QString &gameDir); QStringList installedVersions(const QString &gameDir);
// Установленные профили, у которых inheritsFrom == versionId: без базовой версии
// они не запустятся.
QStringList dependentsOf(const QString &gameDir, const QString &versionId);
// Размер <gameDir>/versions/<id> в байтах; 0 — папки нет.
qint64 installedSize(const QString &gameDir, const QString &versionId);
// Сносит <gameDir>/versions/<id>. Отсутствие папки — успех.
bool remove(const QString &gameDir, const QString &versionId, QString *error);
// Читает и разворачивает версию. При ошибке возвращает невалидный объект и пишет error. // Читает и разворачивает версию. При ошибке возвращает невалидный объект и пишет error.
MinecraftVersion load(const QString &gameDir, MinecraftVersion load(const QString &gameDir,
const QString &versionId, const QString &versionId,