verison deletion feature
This commit is contained in:
+211
-8
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
// Версии модлоадеров для выбранной версии игры. Возвращает список карт
|
// Версии модлоадеров для выбранной версии игры. Возвращает список карт
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user