Browse Source
[desktop] Add mod importer from folder and zip (#3472)
[desktop] Add mod importer from folder and zip (#3472)
Closes #3125 Adds buttons to the addons page that imports a mod (or mods) from zip or folder. Currently known to work with mods that provide proper romfs/exefs things, unsure about cheats and such. Also works on mods that just stuff things into the root of the zip. TODO: - [ ] test folder more thoroughly - [ ] cheats - [ ] test all sorts of mod pack types Signed-off-by: crueter <crueter@eden-emu.dev> Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3472 Reviewed-by: Lizzie <lizzie@eden-emu.dev>pull/3481/head
No known key found for this signature in database
GPG Key ID: 425ACD2D4830EBC6
18 changed files with 570 additions and 14 deletions
-
3src/frontend_common/CMakeLists.txt
-
62src/frontend_common/mod_manager.cpp
-
21src/frontend_common/mod_manager.h
-
6src/qt_common/CMakeLists.txt
-
27src/qt_common/abstract/frontend.cpp
-
8src/qt_common/abstract/frontend.h
-
137src/qt_common/util/mod.cpp
-
16src/qt_common/util/mod.h
-
1src/yuzu/CMakeLists.txt
-
66src/yuzu/configuration/addon/mod_select_dialog.cpp
-
26src/yuzu/configuration/addon/mod_select_dialog.h
-
99src/yuzu/configuration/addon/mod_select_dialog.ui
-
2src/yuzu/configuration/configure_per_game.cpp
-
2src/yuzu/configuration/configure_per_game.h
-
75src/yuzu/configuration/configure_per_game_addons.cpp
-
11src/yuzu/configuration/configure_per_game_addons.h
-
18src/yuzu/configuration/configure_per_game_addons.ui
-
4src/yuzu/main_window.cpp
@ -0,0 +1,62 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
||||
|
#include <algorithm>
|
||||
|
#include <filesystem>
|
||||
|
#include <fmt/format.h>
|
||||
|
#include "common/fs/fs.h"
|
||||
|
#include "common/fs/fs_types.h"
|
||||
|
#include "common/logging/backend.h"
|
||||
|
#include "frontend_common/data_manager.h"
|
||||
|
#include "mod_manager.h"
|
||||
|
|
||||
|
namespace FrontendCommon { |
||||
|
|
||||
|
// TODO: Handle cases where the folder appears to contain multiple mods.
|
||||
|
std::vector<std::filesystem::path> GetModFolder(const std::string& root) { |
||||
|
std::vector<std::filesystem::path> paths; |
||||
|
|
||||
|
auto callback = [&paths](const std::filesystem::directory_entry& entry) -> bool { |
||||
|
const auto name = entry.path().filename().string(); |
||||
|
static constexpr const std::array<std::string, 5> valid_names = {"exefs", |
||||
|
"romfs" |
||||
|
"romfs_ext", |
||||
|
"cheats", "romfslite"}; |
||||
|
|
||||
|
if (std::ranges::find(valid_names, name) != valid_names.end()) { |
||||
|
paths.emplace_back(entry.path().parent_path()); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
}; |
||||
|
|
||||
|
Common::FS::IterateDirEntriesRecursively(root, callback, Common::FS::DirEntryFilter::Directory); |
||||
|
|
||||
|
return paths; |
||||
|
} |
||||
|
|
||||
|
ModInstallResult InstallMod(const std::filesystem::path& path, const u64 program_id, const bool copy) { |
||||
|
const auto program_id_string = fmt::format("{:016X}", program_id); |
||||
|
const auto mod_name = path.filename(); |
||||
|
const auto mod_dir = |
||||
|
DataManager::GetDataDir(DataManager::DataDir::Mods) / program_id_string / mod_name; |
||||
|
|
||||
|
// pre-emptively remove any existing mod here
|
||||
|
std::filesystem::remove_all(mod_dir); |
||||
|
|
||||
|
// now copy
|
||||
|
try { |
||||
|
std::filesystem::copy(path, mod_dir, std::filesystem::copy_options::recursive); |
||||
|
if (!copy) |
||||
|
std::filesystem::remove_all(path); |
||||
|
} catch (std::exception& e) { |
||||
|
LOG_ERROR(Frontend, "Mod install failed with message {}", e.what()); |
||||
|
return Failed; |
||||
|
} |
||||
|
|
||||
|
LOG_INFO(Frontend, "Copied mod from {} to {}", path.string(), mod_dir.string()); |
||||
|
|
||||
|
return Success; |
||||
|
} |
||||
|
|
||||
|
} // namespace FrontendCommon
|
||||
@ -0,0 +1,21 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later |
||||
|
|
||||
|
#pragma once |
||||
|
|
||||
|
#include <filesystem> |
||||
|
#include <optional> |
||||
|
#include "common/common_types.h" |
||||
|
|
||||
|
namespace FrontendCommon { |
||||
|
|
||||
|
enum ModInstallResult { |
||||
|
Cancelled, |
||||
|
Failed, |
||||
|
Success, |
||||
|
}; |
||||
|
|
||||
|
std::vector<std::filesystem::path> GetModFolder(const std::string& root); |
||||
|
|
||||
|
ModInstallResult InstallMod(const std::filesystem::path &path, const u64 program_id, const bool copy = true); |
||||
|
} |
||||
@ -0,0 +1,137 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
||||
|
#include <filesystem>
|
||||
|
#include <JlCompress.h>
|
||||
|
#include "frontend_common/mod_manager.h"
|
||||
|
#include "mod.h"
|
||||
|
#include "qt_common/abstract/frontend.h"
|
||||
|
|
||||
|
namespace QtCommon::Mod { |
||||
|
QStringList GetModFolders(const QString& root, const QString& fallbackName) { |
||||
|
namespace fs = std::filesystem; |
||||
|
|
||||
|
const auto std_root = root.toStdString(); |
||||
|
|
||||
|
auto paths = FrontendCommon::GetModFolder(std_root); |
||||
|
|
||||
|
// 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()); |
||||
|
} |
||||
|
|
||||
|
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().string()); |
||||
|
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 {QString::fromStdString(std_path.string())}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 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)) { |
||||
|
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()); |
||||
|
|
||||
|
QFile zip{path}; |
||||
|
|
||||
|
// TODO(crueter): use QtCompress
|
||||
|
QStringList result = JlCompress::extractDir(&zip, qCacheDir); |
||||
|
if (result.isEmpty()) { |
||||
|
QtCommon::Frontend::Critical(tr("Mod Extract Failed"), |
||||
|
tr("Zip file %1 is empty").arg(path)); |
||||
|
return QString(); |
||||
|
} |
||||
|
|
||||
|
return qCacheDir; |
||||
|
} |
||||
|
|
||||
|
} // namespace QtCommon::Mod
|
||||
@ -0,0 +1,16 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later |
||||
|
|
||||
|
#pragma once |
||||
|
|
||||
|
#include <QString> |
||||
|
#include "common/common_types.h" |
||||
|
#include "frontend_common/mod_manager.h" |
||||
|
|
||||
|
namespace QtCommon::Mod { |
||||
|
|
||||
|
QStringList GetModFolders(const QString &root, const QString &fallbackName); |
||||
|
|
||||
|
const QString ExtractMod(const QString &path); |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
||||
|
#include <QFileInfo>
|
||||
|
#include <qnamespace.h>
|
||||
|
#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<QStandardItem*>>("QList<QStandardItem*>"); |
||||
|
|
||||
|
for (const auto& mod : mods) { |
||||
|
const auto basename = QFileInfo(mod).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); |
||||
|
} |
||||
|
|
||||
|
ui->treeView->expandAll(); |
||||
|
ui->treeView->resizeColumnToContents(0); |
||||
|
|
||||
|
int rows = item_model->rowCount(); |
||||
|
int height = |
||||
|
ui->treeView->contentsMargins().top() * 4 + ui->treeView->contentsMargins().bottom() * 4; |
||||
|
int width = 0; |
||||
|
|
||||
|
for (int i = 0; i < rows; ++i) { |
||||
|
height += ui->treeView->sizeHintForRow(i); |
||||
|
width = qMax(width, item_model->item(i)->sizeHint().width()); |
||||
|
} |
||||
|
|
||||
|
width += ui->treeView->contentsMargins().left() * 4 + ui->treeView->contentsMargins().right() * 4; |
||||
|
ui->treeView->setMinimumHeight(qMin(height, 600)); |
||||
|
ui->treeView->setMinimumWidth(qMin(width, 700)); |
||||
|
adjustSize(); |
||||
|
|
||||
|
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; |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later |
||||
|
|
||||
|
#pragma once |
||||
|
|
||||
|
#include <QDialog> |
||||
|
#include <QStandardItemModel> |
||||
|
|
||||
|
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; |
||||
|
}; |
||||
@ -0,0 +1,99 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<ui version="4.0"> |
||||
|
<class>ModSelectDialog</class> |
||||
|
<widget class="QDialog" name="ModSelectDialog"> |
||||
|
<property name="geometry"> |
||||
|
<rect> |
||||
|
<x>0</x> |
||||
|
<y>0</y> |
||||
|
<width>400</width> |
||||
|
<height>430</height> |
||||
|
</rect> |
||||
|
</property> |
||||
|
<property name="windowTitle"> |
||||
|
<string>Dialog</string> |
||||
|
</property> |
||||
|
<layout class="QVBoxLayout" name="verticalLayout"> |
||||
|
<item> |
||||
|
<widget class="QLabel" name="label"> |
||||
|
<property name="text"> |
||||
|
<string>The specified folder or archive contains the following mods. Select which ones to install.</string> |
||||
|
</property> |
||||
|
<property name="wordWrap"> |
||||
|
<bool>true</bool> |
||||
|
</property> |
||||
|
</widget> |
||||
|
</item> |
||||
|
<item> |
||||
|
<widget class="QTreeView" name="treeView"> |
||||
|
<property name="contextMenuPolicy"> |
||||
|
<enum>Qt::ContextMenuPolicy::NoContextMenu</enum> |
||||
|
</property> |
||||
|
<property name="editTriggers"> |
||||
|
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set> |
||||
|
</property> |
||||
|
<property name="alternatingRowColors"> |
||||
|
<bool>true</bool> |
||||
|
</property> |
||||
|
<property name="verticalScrollMode"> |
||||
|
<enum>QAbstractItemView::ScrollMode::ScrollPerPixel</enum> |
||||
|
</property> |
||||
|
<property name="uniformRowHeights"> |
||||
|
<bool>true</bool> |
||||
|
</property> |
||||
|
<property name="sortingEnabled"> |
||||
|
<bool>true</bool> |
||||
|
</property> |
||||
|
<property name="headerHidden"> |
||||
|
<bool>true</bool> |
||||
|
</property> |
||||
|
</widget> |
||||
|
</item> |
||||
|
<item> |
||||
|
<widget class="QDialogButtonBox" name="buttonBox"> |
||||
|
<property name="orientation"> |
||||
|
<enum>Qt::Orientation::Horizontal</enum> |
||||
|
</property> |
||||
|
<property name="standardButtons"> |
||||
|
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set> |
||||
|
</property> |
||||
|
</widget> |
||||
|
</item> |
||||
|
</layout> |
||||
|
</widget> |
||||
|
<resources/> |
||||
|
<connections> |
||||
|
<connection> |
||||
|
<sender>buttonBox</sender> |
||||
|
<signal>accepted()</signal> |
||||
|
<receiver>ModSelectDialog</receiver> |
||||
|
<slot>accept()</slot> |
||||
|
<hints> |
||||
|
<hint type="sourcelabel"> |
||||
|
<x>248</x> |
||||
|
<y>254</y> |
||||
|
</hint> |
||||
|
<hint type="destinationlabel"> |
||||
|
<x>157</x> |
||||
|
<y>274</y> |
||||
|
</hint> |
||||
|
</hints> |
||||
|
</connection> |
||||
|
<connection> |
||||
|
<sender>buttonBox</sender> |
||||
|
<signal>rejected()</signal> |
||||
|
<receiver>ModSelectDialog</receiver> |
||||
|
<slot>reject()</slot> |
||||
|
<hints> |
||||
|
<hint type="sourcelabel"> |
||||
|
<x>316</x> |
||||
|
<y>260</y> |
||||
|
</hint> |
||||
|
<hint type="destinationlabel"> |
||||
|
<x>286</x> |
||||
|
<y>274</y> |
||||
|
</hint> |
||||
|
</hints> |
||||
|
</connection> |
||||
|
</connections> |
||||
|
</ui> |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue