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>dravee/discord
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