pollymc/launcher/ui/dialogs/BlockedModsDialog.cpp
flow 6a18079953
refactor: generalize mod models and APIs to resources
Firstly, this abstract away behavior in the mod download models that can
also be applied to other types of resources into a superclass, allowing
other resource types to be implemented without so much code duplication.

For that, this also generalizes the APIs used (currently, ModrinthAPI
and FlameAPI) to be able to make requests to other types of resources.

It also does a general cleanup of both of those. In particular, this
makes use of std::optional instead of invalid values for errors and,
well, optional values :p

This is a squash of some commits that were becoming too interlaced
together to be cleanly separated.

Signed-off-by: flow <flowlnlnln@gmail.com>
2023-01-13 16:23:00 -03:00

370 lines
12 KiB
C++

// SPDX-FileCopyrightText: 2022 Sefa Eyeoglu <contact@scrumplex.net>
// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com>
// SPDX-FileCopyrightText: 2022 kumquat-ir <66188216+kumquat-ir@users.noreply.github.com>
//
// SPDX-License-Identifier: GPL-3.0-only
/*
* Prism Launcher - Minecraft Launcher
* Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
* Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com>
* Copyright (C) 2022 kumquat-ir <66188216+kumquat-ir@users.noreply.github.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "BlockedModsDialog.h"
#include "ui_BlockedModsDialog.h"
#include "Application.h"
#include "modplatform/helpers/HashUtils.h"
#include <QDebug>
#include <QDesktopServices>
#include <QDialogButtonBox>
#include <QDragEnterEvent>
#include <QFileDialog>
#include <QFileInfo>
#include <QPushButton>
#include <QStandardPaths>
BlockedModsDialog::BlockedModsDialog(QWidget* parent, const QString& title, const QString& text, QList<BlockedMod>& mods)
: QDialog(parent), ui(new Ui::BlockedModsDialog), m_mods(mods)
{
m_hashing_task = shared_qobject_ptr<ConcurrentTask>(new ConcurrentTask(this, "MakeHashesTask", 10));
connect(m_hashing_task.get(), &Task::finished, this, &BlockedModsDialog::hashTaskFinished);
ui->setupUi(this);
m_openMissingButton = ui->buttonBox->addButton(tr("Open Missing"), QDialogButtonBox::ActionRole);
connect(m_openMissingButton, &QPushButton::clicked, this, [this]() { openAll(true); });
auto downloadFolderButton = ui->buttonBox->addButton(tr("Add Download Folder"), QDialogButtonBox::ActionRole);
connect(downloadFolderButton, &QPushButton::clicked, this, &BlockedModsDialog::addDownloadFolder);
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &BlockedModsDialog::directoryChanged);
qDebug() << "[Blocked Mods Dialog] Mods List: " << mods;
setupWatch();
scanPaths();
this->setWindowTitle(title);
ui->labelDescription->setText(text);
// force all URL handling as external
connect(ui->textBrowserWatched, &QTextBrowser::anchorClicked, this, [](const QUrl url) { QDesktopServices::openUrl(url); });
setAcceptDrops(true);
update();
}
BlockedModsDialog::~BlockedModsDialog()
{
delete ui;
}
void BlockedModsDialog::dragEnterEvent(QDragEnterEvent* e)
{
if (e->mimeData()->hasUrls()) {
e->acceptProposedAction();
}
}
void BlockedModsDialog::dropEvent(QDropEvent* e)
{
for (QUrl& url : e->mimeData()->urls()) {
if (url.scheme().isEmpty()) { // ensure isLocalFile() works correctly
url.setScheme("file");
}
if (!url.isLocalFile()) { // can't drop external files here.
continue;
}
QString filePath = url.toLocalFile();
qDebug() << "[Blocked Mods Dialog] Dropped file:" << filePath;
addHashTask(filePath);
// watch for changes
QFileInfo file = QFileInfo(filePath);
QString path = file.dir().absolutePath();
qDebug() << "[Blocked Mods Dialog] Adding watch path:" << path;
m_watcher.addPath(path);
}
scanPaths();
update();
}
void BlockedModsDialog::done(int r)
{
QDialog::done(r);
disconnect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &BlockedModsDialog::directoryChanged);
}
void BlockedModsDialog::openAll(bool missingOnly)
{
for (auto& mod : m_mods) {
if (!missingOnly || !mod.matched) {
QDesktopServices::openUrl(mod.websiteUrl);
}
}
}
void BlockedModsDialog::addDownloadFolder()
{
QString dir =
QFileDialog::getExistingDirectory(this, tr("Select directory where you downloaded the mods"),
QStandardPaths::writableLocation(QStandardPaths::DownloadLocation), QFileDialog::ShowDirsOnly);
qDebug() << "[Blocked Mods Dialog] Adding watch path:" << dir;
m_watcher.addPath(dir);
scanPath(dir, true);
update();
}
/// @brief update UI with current status of the blocked mod detection
void BlockedModsDialog::update()
{
QString text;
QString span;
for (auto& mod : m_mods) {
if (mod.matched) {
// &#x2714; -> html for HEAVY CHECK MARK : ✔
span = QString(tr("<span style=\"color:green\"> &#x2714; Found at %1 </span>")).arg(mod.localPath);
} else {
// &#x2718; -> html for HEAVY BALLOT X : ✘
span = QString(tr("<span style=\"color:red\"> &#x2718; Not Found </span>"));
}
text += QString(tr("%1: <a href='%2'>%2</a> <p>Hash: %3 %4</p> <br/>")).arg(mod.name, mod.websiteUrl, mod.hash, span);
}
ui->textBrowserModsListing->setText(text);
QString watching;
for (auto& dir : m_watcher.directories()) {
watching += QString("<a href=\"%1\">%1</a><br/>").arg(dir);
}
ui->textBrowserWatched->setText(watching);
if (allModsMatched()) {
ui->labelModsFound->setText("<span style=\"color:green\">✔</span>" + tr("All mods found"));
m_openMissingButton->setDisabled(true);
} else {
ui->labelModsFound->setText(tr("Please download the missing mods."));
m_openMissingButton->setDisabled(false);
}
}
/// @brief Signal fired when a watched direcotry has changed
/// @param path the path to the changed directory
void BlockedModsDialog::directoryChanged(QString path)
{
qDebug() << "[Blocked Mods Dialog] Directory changed: " << path;
validateMatchedMods();
scanPath(path, true);
}
/// @brief add the user downloads folder and the global mods folder to the filesystem watcher
void BlockedModsDialog::setupWatch()
{
const QString downloadsFolder = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
const QString modsFolder = APPLICATION->settings()->get("CentralModsDir").toString();
m_watcher.addPath(downloadsFolder);
m_watcher.addPath(modsFolder);
}
/// @brief scan all watched folder
void BlockedModsDialog::scanPaths()
{
for (auto& dir : m_watcher.directories()) {
scanPath(dir, false);
}
runHashTask();
}
/// @brief Scan the directory at path, skip paths that do not contain a file name
/// of a blocked mod we are looking for
/// @param path the directory to scan
void BlockedModsDialog::scanPath(QString path, bool start_task)
{
QDir scan_dir(path);
QDirIterator scan_it(path, QDir::Filter::Files | QDir::Filter::Hidden, QDirIterator::NoIteratorFlags);
while (scan_it.hasNext()) {
QString file = scan_it.next();
if (!checkValidPath(file)) {
continue;
}
addHashTask(file);
}
if (start_task) {
runHashTask();
}
}
/// @brief add a hashing task for the file located at path, add the path to the pending set if the hasing task is already running
/// @param path the path to the local file being hashed
void BlockedModsDialog::addHashTask(QString path)
{
qDebug() << "[Blocked Mods Dialog] adding a Hash task for" << path << "to the pending set.";
m_pending_hash_paths.insert(path);
}
/// @brief add a hashing task for the file located at path and connect it to check that hash against
/// our blocked mods list
/// @param path the path to the local file being hashed
void BlockedModsDialog::buildHashTask(QString path)
{
auto hash_task = Hashing::createBlockedModHasher(path, ModPlatform::ResourceProvider::FLAME, "sha1");
qDebug() << "[Blocked Mods Dialog] Creating Hash task for path: " << path;
connect(hash_task.get(), &Task::succeeded, this, [this, hash_task, path] { checkMatchHash(hash_task->getResult(), path); });
connect(hash_task.get(), &Task::failed, this, [path] { qDebug() << "Failed to hash path: " << path; });
m_hashing_task->addTask(hash_task);
}
/// @brief check if the computed hash for the provided path matches a blocked
/// mod we are looking for
/// @param hash the computed hash for the provided path
/// @param path the path to the local file being compared
void BlockedModsDialog::checkMatchHash(QString hash, QString path)
{
bool match = false;
qDebug() << "[Blocked Mods Dialog] Checking for match on hash: " << hash << "| From path:" << path;
for (auto& mod : m_mods) {
if (mod.matched) {
continue;
}
if (mod.hash.compare(hash, Qt::CaseInsensitive) == 0) {
mod.matched = true;
mod.localPath = path;
match = true;
qDebug() << "[Blocked Mods Dialog] Hash match found:" << mod.name << hash << "| From path:" << path;
break;
}
}
if (match) {
update();
}
}
/// @brief Check if the name of the file at path matches the name of a blocked mod we are searching for
/// @param path the path to check
/// @return boolean: did the path match the name of a blocked mod?
bool BlockedModsDialog::checkValidPath(QString path)
{
const QFileInfo file = QFileInfo(path);
const QString filename = file.fileName();
QString laxFilename(filename);
laxFilename.replace('+', ' ');
auto compare = [](QString fsfilename, QString metadataFilename) {
return metadataFilename.compare(fsfilename, Qt::CaseInsensitive) == 0;
};
for (auto& mod : m_mods) {
if (compare(filename, mod.name)) {
qDebug() << "[Blocked Mods Dialog] Name match found:" << mod.name << "| From path:" << path;
return true;
}
if (compare(laxFilename, mod.name)) {
qDebug() << "[Blocked Mods Dialog] Lax name match found:" << mod.name << "| From path:" << path;
return true;
}
}
return false;
}
bool BlockedModsDialog::allModsMatched()
{
return std::all_of(m_mods.begin(), m_mods.end(), [](auto const& mod) { return mod.matched; });
}
/// @brief ensure matched file paths still exist
void BlockedModsDialog::validateMatchedMods()
{
bool changed = false;
for (auto& mod : m_mods) {
if (mod.matched) {
QFileInfo file = QFileInfo(mod.localPath);
if (!file.exists() || !file.isFile()) {
qDebug() << "[Blocked Mods Dialog] File" << mod.localPath << "for mod" << mod.name
<< "has vanshed! marking as not matched.";
mod.localPath = "";
mod.matched = false;
changed = true;
}
}
}
if (changed) {
update();
}
}
/// @brief run hash task or mark a pending run if it is already runing
void BlockedModsDialog::runHashTask()
{
if (!m_hashing_task->isRunning()) {
m_rehash_pending = false;
if (!m_pending_hash_paths.isEmpty()) {
qDebug() << "[Blocked Mods Dialog] there are pending hash tasks, building and running tasks";
auto path = m_pending_hash_paths.begin();
while (path != m_pending_hash_paths.end()) {
buildHashTask(*path);
path = m_pending_hash_paths.erase(path);
}
m_hashing_task->start();
}
} else {
qDebug() << "[Blocked Mods Dialog] queueing another run of the hashing task";
qDebug() << "[Blocked Mods Dialog] pending hash tasks:" << m_pending_hash_paths;
m_rehash_pending = true;
}
}
void BlockedModsDialog::hashTaskFinished()
{
qDebug() << "[Blocked Mods Dialog] All hash tasks finished";
if (m_rehash_pending) {
qDebug() << "[Blocked Mods Dialog] task finished with a rehash pending, rerunning";
runHashTask();
}
}
/// qDebug print support for the BlockedMod struct
QDebug operator<<(QDebug debug, const BlockedMod& m)
{
QDebugStateSaver saver(debug);
debug.nospace() << "{ name: " << m.name << ", websiteUrl: " << m.websiteUrl << ", hash: " << m.hash << ", matched: " << m.matched
<< ", localPath: " << m.localPath << "}";
return debug;
}