374 lines
15 KiB
C++
374 lines
15 KiB
C++
#include "buildarchiveworker.h"
|
|
#include "localization.h"
|
|
|
|
#include <QDir>
|
|
#include <QDirIterator>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
|
|
#include <algorithm>
|
|
|
|
#include <QtCore/private/qzipreader_p.h>
|
|
#include <QtCore/private/qzipwriter_p.h>
|
|
|
|
namespace {
|
|
|
|
// Имена внутри архива приходят из файла и превращаются в пути на диске —
|
|
// «../..» увёл бы распаковку за пределы .minecraft. Правило то же, что у
|
|
// установщика версий.
|
|
bool isSafeRelativePath(const QString &path)
|
|
{
|
|
if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
|
|
return false;
|
|
const QStringList parts = path.split(u'/');
|
|
for (const QString &part : parts) {
|
|
if (part == QLatin1String(".") || part == QLatin1String(".."))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Последняя проверка перед записью: куда бы ни указывало имя из архива после
|
|
// всех преобразований, файл обязан оказаться внутри папки игры. Правило имён
|
|
// выше это уже гарантирует, но проверка по готовому пути не зависит от того,
|
|
// как именно QZipReader разбирает записи.
|
|
bool isInside(const QDir &root, const QString &absolutePath)
|
|
{
|
|
const QString base = QDir::cleanPath(root.absolutePath()) + u'/';
|
|
return QDir::cleanPath(absolutePath).startsWith(base);
|
|
}
|
|
|
|
// Верхний сегмент относительного пути: по нему проверяется, не лезет ли пак в
|
|
// общие каталоги лаунчера.
|
|
QString topSegment(const QString &path)
|
|
{
|
|
const int slash = path.indexOf(u'/');
|
|
return slash < 0 ? path : path.left(slash);
|
|
}
|
|
|
|
// После удаления файлов остаются пустые папки: пустой mods/ вместо удалённого
|
|
// набора модов выглядит как незавершённая установка.
|
|
void pruneEmptyDirs(const QDir &root, QStringList dirs)
|
|
{
|
|
// Сначала самые глубокие: удаление вложенной может опустошить родителя.
|
|
std::sort(dirs.begin(), dirs.end(),
|
|
[](const QString &a, const QString &b) { return a.size() > b.size(); });
|
|
for (const QString &relative : std::as_const(dirs)) {
|
|
if (relative.isEmpty() || relative == QLatin1String("."))
|
|
continue;
|
|
QDir dir(root.absoluteFilePath(relative));
|
|
if (dir.exists() && dir.isEmpty())
|
|
root.rmdir(relative);
|
|
}
|
|
}
|
|
|
|
// Файлы, которые попадут в архив, — относительными путями от gameDir.
|
|
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
|
|
{
|
|
QStringList result;
|
|
const QDir root(gameDir);
|
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
|
| QDir::Hidden | QDir::System);
|
|
for (const QFileInfo &entry : top) {
|
|
if (excludeTop.contains(entry.fileName()))
|
|
continue;
|
|
if (entry.isSymLink())
|
|
continue; // ссылку в архив не положить, а идти по ней опасно
|
|
if (entry.isFile()) {
|
|
result << entry.fileName();
|
|
continue;
|
|
}
|
|
if (!entry.isDir())
|
|
continue;
|
|
|
|
QDirIterator it(entry.absoluteFilePath(),
|
|
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
|
QDirIterator::Subdirectories);
|
|
while (it.hasNext())
|
|
result << root.relativeFilePath(it.next());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
BuildArchiveWorker::BuildArchiveWorker(QObject *parent)
|
|
: QObject(parent)
|
|
{
|
|
}
|
|
|
|
void BuildArchiveWorker::archive(const QString &gameDir,
|
|
const QString &tempZipPath,
|
|
const QStringList &excludeTop)
|
|
{
|
|
const QDir root(gameDir);
|
|
if (!root.exists()) {
|
|
emit finished(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(gameDir)));
|
|
return;
|
|
}
|
|
|
|
const QStringList files = collectFiles(gameDir, excludeTop);
|
|
emit progress(0, files.size(), {});
|
|
|
|
{
|
|
QZipWriter writer(tempZipPath);
|
|
if (writer.status() != QZipWriter::NoError) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.archiveCreateFailed")
|
|
.arg(QDir::toNativeSeparators(tempZipPath)));
|
|
return;
|
|
}
|
|
// Миры и конфиги сжимаются хорошо, уже сжатые jar-моды — нет: пусть
|
|
// решает эвристика, а не мы.
|
|
writer.setCompressionPolicy(QZipWriter::AutoCompress);
|
|
|
|
int done = 0;
|
|
for (const QString &relative : files) {
|
|
if (canceled()) {
|
|
writer.close();
|
|
QFile::remove(tempZipPath);
|
|
emit finished(false, Loc::text("switch.status.archivingCancelled"));
|
|
return;
|
|
}
|
|
|
|
QFile file(root.absoluteFilePath(relative));
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
// Один нечитаемый файл не повод потерять всю сборку, но и молча
|
|
// выкидывать его нельзя — иначе пользователь узнает о пропаже
|
|
// уже после очистки .minecraft.
|
|
writer.close();
|
|
QFile::remove(tempZipPath);
|
|
emit finished(false,
|
|
Loc::text("common.error.readFailed")
|
|
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
|
return;
|
|
}
|
|
writer.addFile(relative, &file);
|
|
file.close();
|
|
|
|
if (writer.status() != QZipWriter::NoError) {
|
|
writer.close();
|
|
QFile::remove(tempZipPath);
|
|
emit finished(false,
|
|
Loc::text("switch.error.archiveWriteFailed")
|
|
.arg(QDir::toNativeSeparators(relative)));
|
|
return;
|
|
}
|
|
emit progress(++done, files.size(), relative);
|
|
}
|
|
writer.close();
|
|
}
|
|
|
|
emit finished(true, {});
|
|
}
|
|
|
|
void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop)
|
|
{
|
|
QDir root(gameDir);
|
|
if (!root.exists()) {
|
|
emit finished(true, {}); // чистить нечего
|
|
return;
|
|
}
|
|
|
|
QFileInfoList victims;
|
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
|
| QDir::Hidden | QDir::System);
|
|
for (const QFileInfo &entry : top) {
|
|
if (!keepTop.contains(entry.fileName()))
|
|
victims << entry;
|
|
}
|
|
|
|
emit progress(0, victims.size(), {});
|
|
int done = 0;
|
|
for (const QFileInfo &entry : victims) {
|
|
// Отмена посреди очистки оставила бы полупустую папку, которую нечем
|
|
// склеить обратно, — на этом шаге её не слушаем.
|
|
const bool ok = entry.isDir() && !entry.isSymLink()
|
|
? QDir(entry.absoluteFilePath()).removeRecursively()
|
|
: QFile::remove(entry.absoluteFilePath());
|
|
if (!ok) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.removeFailed")
|
|
.arg(QDir::toNativeSeparators(entry.absoluteFilePath())));
|
|
return;
|
|
}
|
|
emit progress(++done, victims.size(), entry.fileName());
|
|
}
|
|
|
|
emit finished(true, {});
|
|
}
|
|
|
|
void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
|
|
{
|
|
QZipReader reader(zipPath);
|
|
if (!reader.isReadable()) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
|
return;
|
|
}
|
|
|
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
|
emit progress(0, entries.size(), {});
|
|
|
|
const QDir root(gameDir);
|
|
if (!QDir().mkpath(gameDir)) {
|
|
emit finished(false,
|
|
Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
|
return;
|
|
}
|
|
|
|
int done = 0;
|
|
for (const QZipReader::FileInfo &entry : entries) {
|
|
if (canceled()) {
|
|
emit finished(false, Loc::text("switch.status.extractCancelled"));
|
|
return;
|
|
}
|
|
if (!entry.isFile) {
|
|
emit progress(++done, entries.size(), entry.filePath);
|
|
continue;
|
|
}
|
|
if (!isSafeRelativePath(entry.filePath)) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.archiveBadPath").arg(entry.filePath));
|
|
return;
|
|
}
|
|
|
|
const QString target = root.absoluteFilePath(entry.filePath);
|
|
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
|
emit finished(false,
|
|
Loc::text("common.error.createFailed")
|
|
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
|
return;
|
|
}
|
|
|
|
QFile file(target);
|
|
const QByteArray data = reader.fileData(entry.filePath);
|
|
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.writeFailed")
|
|
.arg(QDir::toNativeSeparators(entry.filePath), file.errorString()));
|
|
return;
|
|
}
|
|
file.close();
|
|
emit progress(++done, entries.size(), entry.filePath);
|
|
}
|
|
|
|
emit finished(true, {});
|
|
}
|
|
|
|
void BuildArchiveWorker::applyPack(const QString &zipPath,
|
|
const QString &gameDir,
|
|
const QStringList &removeRelative,
|
|
const QStringList &forbiddenTop)
|
|
{
|
|
QZipReader reader(zipPath);
|
|
if (!reader.isReadable()) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
|
return;
|
|
}
|
|
|
|
// Весь архив проверяется до первой записи на диск: наполовину раскатанный
|
|
// пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии.
|
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
|
QStringList applied;
|
|
applied.reserve(entries.size());
|
|
for (const QZipReader::FileInfo &entry : entries) {
|
|
if (!entry.isFile)
|
|
continue;
|
|
if (!isSafeRelativePath(entry.filePath)) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.packBadPath").arg(entry.filePath));
|
|
return;
|
|
}
|
|
if (forbiddenTop.contains(topSegment(entry.filePath))) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.packTouchesLauncher")
|
|
.arg(entry.filePath));
|
|
return;
|
|
}
|
|
applied << entry.filePath;
|
|
}
|
|
|
|
const QDir root(gameDir);
|
|
if (!QDir().mkpath(gameDir)) {
|
|
emit finished(false, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
|
return;
|
|
}
|
|
|
|
const int total = removeRelative.size() + applied.size();
|
|
emit progress(0, total, {});
|
|
int done = 0;
|
|
|
|
// ── Убираем то, чего в новой ревизии больше нет ──────────────────────
|
|
QStringList touchedDirs;
|
|
for (const QString &relative : removeRelative) {
|
|
if (canceled()) {
|
|
emit finished(false, Loc::text("switch.status.installCancelled"));
|
|
return;
|
|
}
|
|
// Список пришёл из нашего же файла состояния, но он лежит на диске
|
|
// рядом с игрой и мог быть отредактирован — правила те же, что у архива.
|
|
if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) {
|
|
emit progress(++done, total, relative);
|
|
continue;
|
|
}
|
|
// Файл мог быть удалён игроком или прийти заново в новой ревизии —
|
|
// и то и другое нормально.
|
|
const QString target = root.absoluteFilePath(relative);
|
|
if (!isInside(root, target)) {
|
|
emit progress(++done, total, relative);
|
|
continue;
|
|
}
|
|
if (QFileInfo::exists(target) && !QFile::remove(target)) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.removeFailed").arg(QDir::toNativeSeparators(target)));
|
|
return;
|
|
}
|
|
const QString parent = QFileInfo(relative).path();
|
|
if (!touchedDirs.contains(parent))
|
|
touchedDirs << parent;
|
|
emit progress(++done, total, relative);
|
|
}
|
|
pruneEmptyDirs(root, touchedDirs);
|
|
|
|
// ── Раскладываем новую ревизию поверх ────────────────────────────────
|
|
for (const QString &relative : std::as_const(applied)) {
|
|
if (canceled()) {
|
|
emit finished(false, Loc::text("switch.status.installCancelled"));
|
|
return;
|
|
}
|
|
|
|
const QString target = root.absoluteFilePath(relative);
|
|
if (!isInside(root, target)) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.packEscapesGameDir")
|
|
.arg(relative));
|
|
return;
|
|
}
|
|
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
|
emit finished(false,
|
|
Loc::text("common.error.createFailed")
|
|
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
|
return;
|
|
}
|
|
|
|
QFile file(target);
|
|
const QByteArray data = reader.fileData(relative);
|
|
// QIODevice::Truncate обязателен: поверх более длинного старого файла
|
|
// иначе остался бы его хвост.
|
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
|
|| file.write(data) != data.size()) {
|
|
emit finished(false,
|
|
Loc::text("switch.error.writeFailed")
|
|
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
|
return;
|
|
}
|
|
file.close();
|
|
emit progress(++done, total, relative);
|
|
}
|
|
|
|
emit packEntries(applied);
|
|
emit finished(true, {});
|
|
}
|