diff --git a/src/frontend_common/mod_manager.cpp b/src/frontend_common/mod_manager.cpp index 776a6160b2..6ac79d22e2 100644 --- a/src/frontend_common/mod_manager.cpp +++ b/src/frontend_common/mod_manager.cpp @@ -13,11 +13,10 @@ namespace FrontendCommon { // TODO: Handle cases where the folder appears to contain multiple mods. -std::optional GetModFolder(const std::string& root) { - std::filesystem::path path; - bool found; +std::vector GetModFolder(const std::string& root) { + std::vector paths; - auto callback = [&path, &found](const std::filesystem::directory_entry& entry) -> bool { + auto callback = [&paths](const std::filesystem::directory_entry& entry) -> bool { const auto name = entry.path().filename().string(); static constexpr const std::array valid_names = {"exefs", "romfs" @@ -25,8 +24,7 @@ std::optional GetModFolder(const std::string& root) { "cheats", "romfslite"}; if (std::ranges::find(valid_names, name) != valid_names.end()) { - path = entry.path().parent_path(); - found = true; + paths.emplace_back(entry.path().parent_path()); } return true; @@ -34,10 +32,7 @@ std::optional GetModFolder(const std::string& root) { Common::FS::IterateDirEntriesRecursively(root, callback, Common::FS::DirEntryFilter::Directory); - if (found) - return path; - - return std::nullopt; + return paths; } ModInstallResult InstallMod(const std::filesystem::path& path, const u64 program_id, const bool copy) { diff --git a/src/frontend_common/mod_manager.h b/src/frontend_common/mod_manager.h index 8b0ef3883b..57fdbc3533 100644 --- a/src/frontend_common/mod_manager.h +++ b/src/frontend_common/mod_manager.h @@ -15,7 +15,7 @@ enum ModInstallResult { Success, }; -std::optional GetModFolder(const std::string& root); +std::vector GetModFolder(const std::string& root); ModInstallResult InstallMod(const std::filesystem::path &path, const u64 program_id, const bool copy = true); } diff --git a/src/qt_common/util/mod.cpp b/src/qt_common/util/mod.cpp index 0794d548b0..d7fcf0e841 100644 --- a/src/qt_common/util/mod.cpp +++ b/src/qt_common/util/mod.cpp @@ -3,107 +3,120 @@ #include #include +#include "frontend_common/mod_manager.h" #include "mod.h" #include "qt_common/abstract/frontend.h" namespace QtCommon::Mod { -QString GetModFolder(const QString& root, const QString& fallbackName) { +QStringList GetModFolders(const QString& root, const QString& fallbackName) { namespace fs = std::filesystem; const auto std_root = root.toStdString(); - auto std_path = FrontendCommon::GetModFolder(std_root); - - QString default_name; - if (!fallbackName.isEmpty()) - default_name = fallbackName; - else if (std_path) - default_name = QString::fromStdString(std_path->filename()); - else - default_name = root.split(QLatin1Char('/')).last(); - - QString name = QtCommon::Frontend::GetTextInput( - tr("Mod Name"), tr("What should this mod be called?"), default_name); - - // if std_path is empty, frontend_common could not determine mod type and/or name. - // so we have to prompt the user and set up the structure ourselves - if (!std_path) { - // TODO: Carboxyl impl. - const QStringList choices = { - tr("RomFS"), - tr("ExeFS/Patch"), - tr("Cheat"), - }; - - int choice = QtCommon::Frontend::Choice( - tr("Mod Type"), - tr("Could not detect mod type automatically. Please manually " - "specify the type of mod you downloaded.\n\nMost mods are RomFS mods, but patches " - "(.pchtxt) are typically ExeFS mods."), - choices); - - std::string to_make; - - switch (choice) { - case 0: - to_make = "romfs"; - break; - case 1: - to_make = "exefs"; - break; - case 2: - to_make = "cheats"; - break; - default: - return QString(); - } - - // now make a temp directory... - const auto mod_dir = fs::temp_directory_path() / "eden" / "mod" / name.toStdString(); - const auto tmp = mod_dir / to_make; - fs::remove_all(mod_dir); - if (!fs::create_directories(tmp)) { - LOG_ERROR(Frontend, "Failed to create temporary directory {}", tmp.string()); - return QString(); - } - std_path = mod_dir; + auto paths = FrontendCommon::GetModFolder(std_root); - - // ... and copy everything from the root to the temp dir - for (const auto& entry : fs::directory_iterator(root.toStdString())) { - const auto target = tmp / entry.path().filename(); - - fs::copy(entry.path(), target, - fs::copy_options::recursive | fs::copy_options::overwrite_existing); + // multi mod zip + if (paths.size() > 1) { + // We just have to assume it's properly formed here. + // If not, you're out of luck. + QStringList qpaths; + for (const fs::path& path : paths) { + qpaths << QString::fromStdString(path.string()); } - } else { - // Rename the existing mod folder. - const auto new_path = std_path->parent_path() / name.toStdString(); - fs::rename(std_path.value(), new_path); - std_path = new_path; - } - return QString::fromStdString(std_path->string()); -} - -FrontendCommon::ModInstallResult InstallMod(const QString& path, const QString& fallbackName, const u64 program_id, - const bool copy) { - const auto target = GetModFolder(path, fallbackName); - if (target.isEmpty()) { - return FrontendCommon::Cancelled; + return qpaths; } + // either frontend didn't detect any romfs/exefs, or is a single-mod zip + else { + fs::path std_path; + if (!paths.empty()) + std_path = paths[0]; + + QString default_name; + if (!fallbackName.isEmpty()) + default_name = fallbackName; + else if (!paths.empty()) + default_name = QString::fromStdString(std_path.filename()); + else + default_name = root.split(QLatin1Char('/')).last(); + + QString name = QtCommon::Frontend::GetTextInput( + tr("Mod Name"), tr("What should this mod be called?"), default_name); + + // if std_path is empty, frontend_common could not determine mod type and/or name. + // so we have to prompt the user and set up the structure ourselves + if (paths.empty()) { + // TODO: Carboxyl impl. + const QStringList choices = { + tr("RomFS"), + tr("ExeFS/Patch"), + tr("Cheat"), + }; + + int choice = QtCommon::Frontend::Choice( + tr("Mod Type"), + tr("Could not detect mod type automatically. Please manually " + "specify the type of mod you downloaded.\n\nMost mods are RomFS mods, but " + "patches " + "(.pchtxt) are typically ExeFS mods."), + choices); + + std::string to_make; + + switch (choice) { + case 0: + to_make = "romfs"; + break; + case 1: + to_make = "exefs"; + break; + case 2: + to_make = "cheats"; + break; + default: + return {}; + } + + // now make a temp directory... + const auto mod_dir = fs::temp_directory_path() / "eden" / "mod" / name.toStdString(); + const auto tmp = mod_dir / to_make; + fs::remove_all(mod_dir); + if (!fs::create_directories(tmp)) { + LOG_ERROR(Frontend, "Failed to create temporary directory {}", tmp.string()); + return {}; + } + + std_path = mod_dir; + + // ... and copy everything from the root to the temp dir + for (const auto& entry : fs::directory_iterator(root.toStdString())) { + const auto target = tmp / entry.path().filename(); + + fs::copy(entry.path(), target, + fs::copy_options::recursive | fs::copy_options::overwrite_existing); + } + } else { + // Rename the existing mod folder. + const auto new_path = std_path.parent_path() / name.toStdString(); + fs::rename(std_path, new_path); + std_path = new_path; + } - return FrontendCommon::InstallMod(target.toStdString(), program_id, copy); + return {QString::fromStdString(std_path.string())}; + } } -FrontendCommon::ModInstallResult InstallModFromZip(const QString& path, const u64 program_id) { +// TODO(crueter): Make this a common extract_to_tmp func +const QString ExtractMod(const QString& path) { namespace fs = std::filesystem; fs::path tmp{fs::temp_directory_path() / "eden" / "unzip_mod"}; fs::remove_all(tmp); if (!fs::create_directories(tmp)) { - LOG_ERROR(Frontend, "Failed to create temporary directory {}", tmp.string()); - return FrontendCommon::Failed; + QtCommon::Frontend::Critical(tr("Mod Extract Failed"), + tr("Failed to create temporary directory %1") + .arg(QString::fromStdString(tmp.string()))); + return QString(); } QString qCacheDir = QString::fromStdString(tmp.string()); @@ -113,13 +126,12 @@ FrontendCommon::ModInstallResult InstallModFromZip(const QString& path, const u6 // TODO(crueter): use QtCompress QStringList result = JlCompress::extractDir(&zip, qCacheDir); if (result.isEmpty()) { - LOG_ERROR(Frontend, "Zip file {} is empty", path.toStdString()); - return FrontendCommon::Failed; + QtCommon::Frontend::Critical(tr("Mod Extract Failed"), + tr("Zip file %1 is empty").arg(path)); + return QString(); } - const auto fallback = fs::path{path.toStdString()}.stem(); - - return InstallMod(qCacheDir, QString::fromStdString(fallback.string()), program_id, false); + return qCacheDir; } } // namespace QtCommon::Mod diff --git a/src/qt_common/util/mod.h b/src/qt_common/util/mod.h index 6e9457b2e6..ceda81ef92 100644 --- a/src/qt_common/util/mod.h +++ b/src/qt_common/util/mod.h @@ -9,11 +9,8 @@ namespace QtCommon::Mod { -QString GetModFolder(const QString &root, const QString &fallbackName); +QStringList GetModFolders(const QString &root, const QString &fallbackName); -FrontendCommon::ModInstallResult InstallMod(const QString& path, const QString& fallbackName, - const u64 program_id, const bool copy = true); - -FrontendCommon::ModInstallResult InstallModFromZip(const QString &path, const u64 program_id); +const QString ExtractMod(const QString &path); } diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 27911d2ac8..5204f9e8ac 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -238,6 +238,7 @@ add_executable(yuzu configuration/system/new_user_dialog.h configuration/system/new_user_dialog.cpp configuration/system/new_user_dialog.ui configuration/system/profile_avatar_dialog.h configuration/system/profile_avatar_dialog.cpp + configuration/addon/mod_select_dialog.h configuration/addon/mod_select_dialog.cpp configuration/addon/mod_select_dialog.ui ) set_target_properties(yuzu PROPERTIES OUTPUT_NAME "eden") diff --git a/src/yuzu/configuration/addon/mod_select_dialog.cpp b/src/yuzu/configuration/addon/mod_select_dialog.cpp new file mode 100644 index 0000000000..84154e7a5a --- /dev/null +++ b/src/yuzu/configuration/addon/mod_select_dialog.cpp @@ -0,0 +1,46 @@ +#include +#include +#include "mod_select_dialog.h" +#include "ui_mod_select_dialog.h" + +ModSelectDialog::ModSelectDialog(const QStringList& mods, QWidget* parent) + : QDialog(parent), ui(new Ui::ModSelectDialog) { + ui->setupUi(this); + + item_model = new QStandardItemModel(ui->treeView); + ui->treeView->setModel(item_model); + + // We must register all custom types with the Qt Automoc system so that we are able to use it + // with signals/slots. In this case, QList falls under the umbrella of custom types. + qRegisterMetaType>("QList"); + + for (const auto& mod : mods) { + const auto basename = QString::fromStdString(std::filesystem::path(mod.toStdString()).filename()); + + auto* const first_item = new QStandardItem; + first_item->setText(basename); + first_item->setData(mod); + + first_item->setCheckable(true); + first_item->setCheckState(Qt::Checked); + + item_model->appendRow(first_item); + item_model->layoutChanged(); + } + + connect(this, &QDialog::accepted, this, [this]() { + QStringList selected_mods; + + for (qsizetype i = 0; i < item_model->rowCount(); ++i) { + auto *const item = item_model->item(i); + if (item->checkState() == Qt::Checked) + selected_mods << item->data().toString(); + } + + emit modsSelected(selected_mods); + }); +} + +ModSelectDialog::~ModSelectDialog() { + delete ui; +} diff --git a/src/yuzu/configuration/addon/mod_select_dialog.h b/src/yuzu/configuration/addon/mod_select_dialog.h new file mode 100644 index 0000000000..ccd0e11a66 --- /dev/null +++ b/src/yuzu/configuration/addon/mod_select_dialog.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +namespace Ui { +class ModSelectDialog; +} + +class ModSelectDialog : public QDialog { + Q_OBJECT + +public: + explicit ModSelectDialog(const QStringList &mods, QWidget* parent = nullptr); + ~ModSelectDialog(); + +signals: + void modsSelected(const QStringList &mods); +private: + Ui::ModSelectDialog* ui; + + QStandardItemModel* item_model; +}; diff --git a/src/yuzu/configuration/addon/mod_select_dialog.ui b/src/yuzu/configuration/addon/mod_select_dialog.ui new file mode 100644 index 0000000000..547afb25a7 --- /dev/null +++ b/src/yuzu/configuration/addon/mod_select_dialog.ui @@ -0,0 +1,99 @@ + + + ModSelectDialog + + + + 0 + 0 + 400 + 300 + + + + Dialog + + + + + + The specified folder or archive contain the following mods. Select which ones to install. + + + true + + + + + + + Qt::ContextMenuPolicy::NoContextMenu + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + true + + + QAbstractItemView::ScrollMode::ScrollPerPixel + + + true + + + true + + + true + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + ModSelectDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ModSelectDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/yuzu/configuration/configure_per_game_addons.cpp b/src/yuzu/configuration/configure_per_game_addons.cpp index f62be148d4..3032e4c3d9 100644 --- a/src/yuzu/configuration/configure_per_game_addons.cpp +++ b/src/yuzu/configuration/configure_per_game_addons.cpp @@ -18,6 +18,7 @@ #include "common/fs/fs.h" #include "common/fs/path_util.h" +#include "configuration/addon/mod_select_dialog.h" #include "core/core.h" #include "core/file_sys/patch_manager.h" #include "core/loader/loader.h" @@ -105,34 +106,54 @@ void ConfigurePerGameAddons::SetTitleId(u64 id) { this->title_id = id; } -void ConfigurePerGameAddons::InstallModFolder() { - const auto path = QtCommon::Frontend::GetExistingDirectory( - tr("Mod Folder"), QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); - if (path.isEmpty()) { - return; +void ConfigurePerGameAddons::InstallMods(const QStringList& mods) { + QStringList failed; + for (const auto& mod : mods) { + if (FrontendCommon::InstallMod(mod.toStdString(), title_id, true) == + FrontendCommon::Failed) { + failed << QFileInfo(mod).baseName(); + } } - // TODO: Pending refresh game list - auto ret = QtCommon::Mod::InstallMod(path, {}, title_id); - switch (ret) { - case FrontendCommon::Success: - QtCommon::Frontend::Information(tr("Mod Installed"), tr("Mod was successfully installed.")); + if (failed.empty()) { + QtCommon::Frontend::Information(tr("Mod Install Succeeded"), + tr("Successfully installed all mods.")); + item_model->removeRows(0, item_model->rowCount()); list_items.clear(); - emit RefreshGameList(); LoadConfiguration(); - break; - case FrontendCommon::Failed: + + UISettings::values.is_game_list_reload_pending.exchange(true); + } else { QtCommon::Frontend::Critical( tr("Mod Install Failed"), - tr("Mod install was unsuccessful. Check the log for details.")); - break; - case FrontendCommon::Cancelled: - default: - break; + tr("Failed to install the following mods:\n\t%1\nCheck the log for details.") + .arg(failed.join(QStringLiteral("\n\t")))); } } +void ConfigurePerGameAddons::InstallModPath(const QString& path) { + const auto mods = QtCommon::Mod::GetModFolders(path, {}); + + if (mods.size() > 1) { + ModSelectDialog* dialog = new ModSelectDialog(mods, this); + connect(dialog, &ModSelectDialog::modsSelected, this, &ConfigurePerGameAddons::InstallMods); + dialog->show(); + } else { + InstallMods(mods); + } +} + +void ConfigurePerGameAddons::InstallModFolder() { + const auto path = QtCommon::Frontend::GetExistingDirectory( + tr("Mod Folder"), QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); + if (path.isEmpty()) { + return; + } + + InstallModPath(path); +} + void ConfigurePerGameAddons::InstallModZip() { const auto path = QtCommon::Frontend::GetOpenFileName( tr("Zipped Mod Location"), @@ -142,25 +163,9 @@ void ConfigurePerGameAddons::InstallModZip() { return; } - auto ret = QtCommon::Mod::InstallModFromZip(path, title_id); - - switch (ret) { - case FrontendCommon::Success: - QtCommon::Frontend::Information(tr("Mod Installed"), tr("Mod was successfully installed.")); - item_model->removeRows(0, item_model->rowCount()); - list_items.clear(); - emit RefreshGameList(); - LoadConfiguration(); - break; - case FrontendCommon::Failed: - QtCommon::Frontend::Critical( - tr("Mod Install Failed"), - tr("Mod install was unsuccessful. Check the log for details.")); - break; - case FrontendCommon::Cancelled: - default: - break; - } + const QString extracted = QtCommon::Mod::ExtractMod(path); + if (!extracted.isEmpty()) + InstallModPath(extracted); } void ConfigurePerGameAddons::changeEvent(QEvent* event) { diff --git a/src/yuzu/configuration/configure_per_game_addons.h b/src/yuzu/configuration/configure_per_game_addons.h index 22e0f7e1f5..c2738b1c28 100644 --- a/src/yuzu/configuration/configure_per_game_addons.h +++ b/src/yuzu/configuration/configure_per_game_addons.h @@ -43,6 +43,9 @@ public: void SetTitleId(u64 id); public slots: + void InstallMods(const QStringList &mods); + void InstallModPath(const QString& path); + void InstallModFolder(); void InstallModZip(); diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 0d3867d567..7b56c07260 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -3668,7 +3668,7 @@ void MainWindow::OpenPerGameConfiguration(u64 title_id, const std::string& file_ const auto reload = UISettings::values.is_game_list_reload_pending.exchange(false); if (reload) { - game_list->PopulateAsync(UISettings::values.game_dirs); + OnGameListRefresh(); } // Do not cause the global config to write local settings into the config file