// SPDX-FileCopyrightText: 2022 Sefa Eyeoglu // 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 * 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 . */ #include "BlockedModsDialog.h" #include "ui_BlockedModsDialog.h" #include "Application.h" #include "modplatform/helpers/HashUtils.h" #include #include #include #include #include #include #include #include #include #include #include BlockedModsDialog::BlockedModsDialog(QWidget* parent, const QString& title, const QString& text, QList& mods) : QDialog(parent), ui(new Ui::BlockedModsDialog), m_mods(mods) { m_hashing_task = shared_qobject_ptr( new ConcurrentTask(this, "MakeHashesTask", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt())); 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) { // ✔ -> html for HEAVY CHECK MARK : ✔ span = QString(tr(" ✔ Found at %1 ")).arg(mod.localPath); } else { // ✘ -> html for HEAVY BALLOT X : ✘ span = QString(tr(" ✘ Not Found ")); } text += QString(tr("%1: %2

Hash: %3 %4


")).arg(mod.name, mod.websiteUrl, mod.hash, span); } ui->textBrowserModsListing->setText(text); QString watching; for (auto& dir : m_watcher.directories()) { watching += QString("%1
").arg(dir); } ui->textBrowserWatched->setText(watching); if (allModsMatched()) { ui->labelModsFound->setText("" + 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 directory 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 = APPLICATION->settings()->get("DownloadsDir").toString(); const QString modsFolder = APPLICATION->settings()->get("CentralModsDir").toString(); const bool downloadsFolderWatchRecursive = APPLICATION->settings()->get("DownloadsDirWatchRecursive").toBool(); watchPath(downloadsFolder, downloadsFolderWatchRecursive); watchPath(modsFolder, true); } void BlockedModsDialog::watchPath(QString path, bool watch_recursive) { auto to_watch = QFileInfo(path); auto to_watch_path = to_watch.canonicalFilePath(); if (m_watcher.directories().contains(to_watch_path)) return; // don't watch the same path twice (no loops!) qDebug() << "[Blocked Mods Dialog] Adding Watch Path:" << path; m_watcher.addPath(to_watch_path); if (!to_watch.isDir() || !watch_recursive) return; QDirIterator it(to_watch_path, QDir::Filter::Dirs | QDir::Filter::NoDotAndDotDot, QDirIterator::NoIteratorFlags); while (it.hasNext()) { QString watch_dir = QDir(it.next()).canonicalPath(); // resolve symlinks and relative paths watchPath(watch_dir, watch_recursive); } } /// @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 hashing 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(); auto compare = [](QString fsFilename, QString metadataFilename) { return metadataFilename.compare(fsFilename, Qt::CaseInsensitive) == 0; }; // super lax compare (but not fuzzy) // convert to lowercase // convert all speratores to whitespace // simplify sequence of internal whitespace to a single space // efectivly compare two strings ignoring all separators and case auto laxCompare = [](QString fsfilename, QString metadataFilename) { // allowed character seperators QList allowedSeperators = { '-', '+', '.', '_' }; // copy in lowercase auto fsName = fsfilename.toLower(); auto metaName = metadataFilename.toLower(); // replace all potential allowed seperatores with whitespace for (auto sep : allowedSeperators) { fsName = fsName.replace(sep, ' '); metaName = metaName.replace(sep, ' '); } // remove extraneous whitespace fsName = fsName.simplified(); metaName = metaName.simplified(); return fsName.compare(metaName) == 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 (laxCompare(filename, 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 running 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; }