mirror of
https://git.citron-emu.org/citron/emulator
synced 2026-01-27 05:03:29 +00:00
Merge pull request 'Frontend: Introduce Integrated Mod Downloader and Revamp Add-ons UI' (#99) from feat/mod-installer into main
Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/99
This commit is contained in:
@@ -284,6 +284,17 @@ if (CITRON_USE_AUTO_UPDATER)
|
||||
target_link_libraries(citron PRIVATE Qt6::Network)
|
||||
endif()
|
||||
|
||||
# Mod Manager functionality for downloading patches
|
||||
target_sources(citron PRIVATE
|
||||
mod_manager/mod_service.cpp
|
||||
mod_manager/mod_service.h
|
||||
mod_manager/mod_downloader_dialog.cpp
|
||||
mod_manager/mod_downloader_dialog.h
|
||||
mod_manager/mod_downloader_dialog.ui
|
||||
)
|
||||
|
||||
target_link_libraries(citron PRIVATE Qt6::Network)
|
||||
|
||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
|
||||
target_compile_definitions(citron PRIVATE
|
||||
$<$<VERSION_LESS:$<CXX_COMPILER_VERSION>,15>:CANNOT_EXPLICITLY_INSTANTIATE>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// SPDX-FileCopyrightText: 2016 Citra Emulator Project
|
||||
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
@@ -11,6 +12,8 @@
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
#include <QTreeView>
|
||||
#include <QVBoxLayout>
|
||||
#include <QMessageBox>
|
||||
|
||||
#include "common/fs/fs.h"
|
||||
#include "common/fs/path_util.h"
|
||||
@@ -23,10 +26,17 @@
|
||||
#include "citron/configuration/configure_per_game_addons.h"
|
||||
#include "citron/uisettings.h"
|
||||
|
||||
#include "citron/mod_manager/mod_service.h"
|
||||
#include "citron/mod_manager/mod_downloader_dialog.h"
|
||||
|
||||
ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* parent)
|
||||
: QWidget(parent), ui{std::make_unique<Ui::ConfigurePerGameAddons>()}, system{system_} {
|
||||
: QWidget(parent), ui{std::make_unique<Ui::ConfigurePerGameAddons>()}, system{system_} {
|
||||
ui->setupUi(this);
|
||||
|
||||
mod_service = new ModManager::ModService(this);
|
||||
|
||||
ui->button_download_mods->setVisible(false);
|
||||
|
||||
layout = new QVBoxLayout;
|
||||
tree_view = new QTreeView;
|
||||
item_model = new QStandardItemModel(tree_view);
|
||||
@@ -39,7 +49,8 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
|
||||
tree_view->setSortingEnabled(true);
|
||||
tree_view->setEditTriggers(QHeaderView::NoEditTriggers);
|
||||
tree_view->setUniformRowHeights(true);
|
||||
tree_view->setContextMenuPolicy(Qt::NoContextMenu);
|
||||
tree_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(tree_view, &QTreeView::customContextMenuRequested, this, &ConfigurePerGameAddons::OnContextMenu);
|
||||
|
||||
item_model->insertColumns(0, 2);
|
||||
item_model->setHeaderData(0, Qt::Horizontal, tr("Patch Name"));
|
||||
@@ -49,8 +60,6 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
|
||||
tree_view->header()->setSectionResizeMode(0, QHeaderView::ResizeMode::Stretch);
|
||||
tree_view->header()->setMinimumSectionSize(150);
|
||||
|
||||
// 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*>");
|
||||
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
@@ -58,9 +67,33 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
|
||||
layout->addWidget(tree_view);
|
||||
|
||||
ui->scrollArea->setLayout(layout);
|
||||
|
||||
ui->scrollArea->setEnabled(!system.IsPoweredOn());
|
||||
|
||||
// 2. BACKGROUND FETCH: When the manifest is received
|
||||
connect(mod_service, &ModManager::ModService::ModsAvailable, this, [this](const ModManager::ModUpdateInfo& info) {
|
||||
if (!info.version_patches.empty()) {
|
||||
// Save the info and show the button because mods actually exist
|
||||
this->cached_mod_info = info;
|
||||
ui->button_download_mods->setVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. SILENT ERROR: If no mods found, just keep the button hidden (don't show a popup)
|
||||
connect(mod_service, &ModManager::ModService::Error, this, [](const QString& message) {
|
||||
// Do nothing, button remains invisible
|
||||
});
|
||||
|
||||
// 4. BUTTON CLICK: Since we already have the data, just open the dialog
|
||||
connect(ui->button_download_mods, &QPushButton::clicked, this, [this] {
|
||||
auto* dialog = new ModManager::ModDownloaderDialog(cached_mod_info, this);
|
||||
|
||||
connect(dialog, &QDialog::accepted, this, [this] {
|
||||
this->LoadConfiguration();
|
||||
});
|
||||
|
||||
dialog->show();
|
||||
});
|
||||
|
||||
connect(item_model, &QStandardItemModel::itemChanged,
|
||||
[] { UISettings::values.is_game_list_reload_pending.exchange(true); });
|
||||
}
|
||||
@@ -72,13 +105,17 @@ void ConfigurePerGameAddons::ApplyConfiguration() {
|
||||
|
||||
for (const auto& item : list_items) {
|
||||
const auto disabled = item.front()->checkState() == Qt::Unchecked;
|
||||
if (disabled)
|
||||
disabled_addons.push_back(item.front()->text().toStdString());
|
||||
if (disabled) {
|
||||
// Get the internal full name we stored in UserRole
|
||||
QString internal_name = item.front()->data(Qt::UserRole).toString();
|
||||
disabled_addons.push_back(internal_name.toStdString());
|
||||
}
|
||||
}
|
||||
|
||||
auto current = Settings::values.disabled_addons[title_id];
|
||||
std::sort(disabled_addons.begin(), disabled_addons.end());
|
||||
std::sort(current.begin(), current.end());
|
||||
|
||||
if (disabled_addons != current) {
|
||||
Common::FS::RemoveFile(Common::FS::GetCitronPath(Common::FS::CitronPath::CacheDir) /
|
||||
"game_list" / fmt::format("{:016X}.pv.txt", title_id));
|
||||
@@ -94,6 +131,10 @@ void ConfigurePerGameAddons::LoadFromFile(FileSys::VirtualFile file_) {
|
||||
|
||||
void ConfigurePerGameAddons::SetTitleId(u64 id) {
|
||||
this->title_id = id;
|
||||
|
||||
// Trigger the background check as soon as we know which game we are looking at
|
||||
QString tid_str = QString::fromStdString(fmt::format("{:016X}", title_id));
|
||||
mod_service->FetchAvailableMods(tid_str);
|
||||
}
|
||||
|
||||
void ConfigurePerGameAddons::changeEvent(QEvent* event) {
|
||||
@@ -109,35 +150,119 @@ void ConfigurePerGameAddons::RetranslateUI() {
|
||||
}
|
||||
|
||||
void ConfigurePerGameAddons::LoadConfiguration() {
|
||||
if (file == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (file == nullptr) return;
|
||||
|
||||
const FileSys::PatchManager pm{title_id, system.GetFileSystemController(),
|
||||
system.GetContentProvider()};
|
||||
item_model->removeRows(0, item_model->rowCount());
|
||||
list_items.clear();
|
||||
|
||||
const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), system.GetContentProvider()};
|
||||
const auto loader = Loader::GetLoader(system, file);
|
||||
|
||||
FileSys::VirtualFile update_raw;
|
||||
loader->ReadUpdateRaw(update_raw);
|
||||
|
||||
const auto& disabled = Settings::values.disabled_addons[title_id];
|
||||
const auto all_patches = pm.GetPatches(update_raw);
|
||||
|
||||
for (const auto& patch : pm.GetPatches(update_raw)) {
|
||||
const auto name = QString::fromStdString(patch.name);
|
||||
// --- PASS 1: SYSTEM ITEMS (Update, DLC, etc.) ---
|
||||
// We add these directly to the top of the list
|
||||
for (const auto& patch : all_patches) {
|
||||
// Skip folder-based mods for this pass
|
||||
if (patch.type == FileSys::PatchType::Mod) continue;
|
||||
|
||||
auto* const first_item = new QStandardItem;
|
||||
first_item->setText(name);
|
||||
first_item->setText(QString::fromStdString(patch.name));
|
||||
first_item->setCheckable(true);
|
||||
|
||||
const auto patch_disabled =
|
||||
std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end();
|
||||
first_item->setData(QString::fromStdString(patch.name), Qt::UserRole);
|
||||
|
||||
const auto patch_disabled = std::find(disabled.begin(), disabled.end(), patch.name) != disabled.end();
|
||||
first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked);
|
||||
|
||||
list_items.push_back(QList<QStandardItem*>{
|
||||
first_item, new QStandardItem{QString::fromStdString(patch.version)}});
|
||||
item_model->appendRow(list_items.back());
|
||||
QList<QStandardItem*> row;
|
||||
row << first_item << new QStandardItem{QString::fromStdString(patch.version)};
|
||||
item_model->appendRow(row);
|
||||
list_items.push_back(row);
|
||||
}
|
||||
|
||||
// --- PASS 2: FOLDER-BASED MODS (The Tree View) ---
|
||||
std::map<QString, QStandardItem*> groups;
|
||||
for (const auto& patch : all_patches) {
|
||||
// ONLY process mods in this pass
|
||||
if (patch.type != FileSys::PatchType::Mod) continue;
|
||||
|
||||
QString full_name = QString::fromStdString(patch.name);
|
||||
QStandardItem* parent_to_add_to = nullptr;
|
||||
|
||||
if (full_name.contains(QStringLiteral("/"))) {
|
||||
QStringList parts = full_name.split(QStringLiteral("/"));
|
||||
QString group_name = parts[0];
|
||||
QString mod_display_name = parts[1];
|
||||
|
||||
if (groups.find(group_name) == groups.end()) {
|
||||
auto* group_item = new QStandardItem(group_name);
|
||||
group_item->setCheckable(false);
|
||||
group_item->setEditable(false);
|
||||
item_model->appendRow(group_item); // Group folder goes at the bottom of the current list
|
||||
groups[group_name] = group_item;
|
||||
}
|
||||
parent_to_add_to = groups[group_name];
|
||||
full_name = mod_display_name;
|
||||
}
|
||||
|
||||
auto* const mod_item = new QStandardItem(full_name);
|
||||
mod_item->setCheckable(true);
|
||||
|
||||
mod_item->setData(QString::fromStdString(patch.name), Qt::UserRole);
|
||||
|
||||
const auto patch_disabled = std::find(disabled.begin(), disabled.end(), patch.name) != disabled.end();
|
||||
mod_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked);
|
||||
|
||||
QList<QStandardItem*> row;
|
||||
row << mod_item << new QStandardItem{QString::fromStdString(patch.version)};
|
||||
|
||||
if (parent_to_add_to) {
|
||||
parent_to_add_to->appendRow(row);
|
||||
} else {
|
||||
item_model->appendRow(row);
|
||||
}
|
||||
list_items.push_back(row);
|
||||
}
|
||||
|
||||
tree_view->expandAll();
|
||||
tree_view->resizeColumnToContents(1);
|
||||
}
|
||||
|
||||
void ConfigurePerGameAddons::OnContextMenu(const QPoint& pos) {
|
||||
QModelIndex index = tree_view->indexAt(pos);
|
||||
if (!index.isValid()) return;
|
||||
|
||||
// Get the item that was clicked
|
||||
QStandardItem* item = item_model->itemFromIndex(index);
|
||||
|
||||
// We only want to show the menu if the item has children (it's a folder/group)
|
||||
if (item->rowCount() == 0) return;
|
||||
|
||||
QMenu context_menu;
|
||||
|
||||
// Create "Check All" action
|
||||
QAction* check_all = context_menu.addAction(tr("Check All Mods in Folder"));
|
||||
connect(check_all, &QAction::triggered, this, [item] {
|
||||
for (int i = 0; i < item->rowCount(); ++i) {
|
||||
if (auto* child = item->child(i, 0)) {
|
||||
child->setCheckState(Qt::Checked);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create "Uncheck All" action
|
||||
QAction* uncheck_all = context_menu.addAction(tr("Uncheck All Mods in Folder"));
|
||||
connect(uncheck_all, &QAction::triggered, this, [item] {
|
||||
for (int i = 0; i < item->rowCount(); ++i) {
|
||||
if (auto* child = item->child(i, 0)) {
|
||||
child->setCheckState(Qt::Unchecked);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
context_menu.exec(tree_view->viewport()->mapToGlobal(pos));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <QWidget>
|
||||
|
||||
#include "core/file_sys/vfs/vfs_types.h"
|
||||
#include "citron/mod_manager/mod_service.h"
|
||||
|
||||
namespace Core {
|
||||
class System;
|
||||
@@ -40,12 +41,15 @@ public:
|
||||
void SetTitleId(u64 id);
|
||||
|
||||
private:
|
||||
void OnContextMenu(const QPoint& pos);
|
||||
void changeEvent(QEvent* event) override;
|
||||
void RetranslateUI();
|
||||
|
||||
void LoadConfiguration();
|
||||
|
||||
std::unique_ptr<Ui::ConfigurePerGameAddons> ui;
|
||||
ModManager::ModService* mod_service;
|
||||
ModManager::ModUpdateInfo cached_mod_info;
|
||||
FileSys::VirtualFile file;
|
||||
u64 title_id;
|
||||
|
||||
|
||||
@@ -22,16 +22,17 @@
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContents">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>380</width>
|
||||
<height>280</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContents"/>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="button_download_mods">
|
||||
<property name="toolTip">
|
||||
<string>Citron uses third-party created mods that are verified safe to use. All credits for mods go to their respective creators.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Download Enhancement Mods for Game</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
|
||||
165
src/citron/mod_manager/mod_downloader_dialog.cpp
Normal file
165
src/citron/mod_manager/mod_downloader_dialog.cpp
Normal file
@@ -0,0 +1,165 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QMessageBox>
|
||||
#include <QListWidgetItem>
|
||||
#include <filesystem>
|
||||
|
||||
#include "citron/mod_manager/mod_downloader_dialog.h"
|
||||
|
||||
// Generated header from uic
|
||||
#include "ui_mod_downloader_dialog.h"
|
||||
|
||||
#include "common/fs/path_util.h"
|
||||
#include "common/logging/log.h"
|
||||
|
||||
namespace ModManager {
|
||||
|
||||
ModDownloaderDialog::ModDownloaderDialog(const ModUpdateInfo& info, QWidget* parent)
|
||||
: QDialog(parent), mod_info(info), current_reply(nullptr) {
|
||||
|
||||
ui = new ::Ui::ModDownloaderDialog();
|
||||
ui->setupUi(this);
|
||||
|
||||
network_manager = new QNetworkAccessManager(this);
|
||||
|
||||
SetupModList();
|
||||
|
||||
connect(ui->buttonDownload, &QPushButton::clicked, this, &ModDownloaderDialog::OnDownloadClicked);
|
||||
connect(ui->buttonCancel, &QPushButton::clicked, this, &ModDownloaderDialog::OnCancelClicked);
|
||||
}
|
||||
|
||||
ModDownloaderDialog::~ModDownloaderDialog() {
|
||||
delete ui;
|
||||
}
|
||||
|
||||
void ModDownloaderDialog::SetupModList() {
|
||||
ui->treeWidget->setHeaderLabel(QStringLiteral("Version / Mod Name"));
|
||||
|
||||
// Iterate through the map of versions we fetched
|
||||
for (auto const& [version, patches] : mod_info.version_patches) {
|
||||
// Create the Parent (The Version Folder)
|
||||
QTreeWidgetItem* version_item = new QTreeWidgetItem(ui->treeWidget);
|
||||
version_item->setText(0, QStringLiteral("Update %1").arg(version));
|
||||
version_item->setCheckState(0, Qt::Unchecked);
|
||||
version_item->setFlags(version_item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
|
||||
|
||||
// Create the Children (The Mods)
|
||||
for (const auto& patch : patches) {
|
||||
QTreeWidgetItem* mod_item = new QTreeWidgetItem(version_item);
|
||||
mod_item->setText(0, patch.name);
|
||||
mod_item->setCheckState(0, Qt::Unchecked);
|
||||
mod_item->setFlags(mod_item->flags() | Qt::ItemIsUserCheckable);
|
||||
|
||||
// Store the patch data in the item so we can find it later
|
||||
mod_item->setData(0, Qt::UserRole, version); // Store which version it belongs to
|
||||
}
|
||||
}
|
||||
ui->treeWidget->expandAll();
|
||||
}
|
||||
|
||||
void ModDownloaderDialog::OnDownloadClicked() {
|
||||
pending_downloads.clear();
|
||||
|
||||
// Loop through the Tree headers (The Versions)
|
||||
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
|
||||
QTreeWidgetItem* version_node = ui->treeWidget->topLevelItem(i);
|
||||
|
||||
// Extract the version name from "Update 2.0.0" -> "2.0.0"
|
||||
QString version_str = version_node->text(0).replace(QStringLiteral("Update "), QStringLiteral(""));
|
||||
|
||||
// Loop through the mods inside that version
|
||||
for (int j = 0; j < version_node->childCount(); ++j) {
|
||||
QTreeWidgetItem* mod_node = version_node->child(j);
|
||||
|
||||
if (mod_node->checkState(0) == Qt::Checked) {
|
||||
QString mod_name = mod_node->text(0);
|
||||
|
||||
// Find the data for this mod in our info map
|
||||
const auto& patches = mod_info.version_patches[version_str];
|
||||
for (const auto& p : patches) {
|
||||
if (p.name == mod_name) {
|
||||
// Add to our task list
|
||||
pending_downloads.push_back({p, version_str});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pending_downloads.empty()) return;
|
||||
|
||||
ui->buttonDownload->setEnabled(false);
|
||||
ui->treeWidget->setEnabled(false);
|
||||
ui->progressBar->setVisible(true);
|
||||
|
||||
current_download_index = 0;
|
||||
current_file_index = 0;
|
||||
StartNextDownload();
|
||||
}
|
||||
|
||||
void ModDownloaderDialog::StartNextDownload() {
|
||||
if (current_download_index >= static_cast<int>(pending_downloads.size())) {
|
||||
QMessageBox::information(this, QStringLiteral("Success"), QStringLiteral("All selected mods have been installed."));
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& task = pending_downloads[current_download_index];
|
||||
const ModPatch& patch = task.patch;
|
||||
QString version_str = task.version; // We use this to create the folder structure
|
||||
|
||||
if (current_file_index >= patch.files.size()) {
|
||||
current_download_index++;
|
||||
current_file_index = 0;
|
||||
StartNextDownload();
|
||||
return;
|
||||
}
|
||||
|
||||
QString fileName = patch.files[current_file_index];
|
||||
QString urlBase = QStringLiteral("https://raw.githubusercontent.com/CollectingW/Citron-Mods/main/%1/%2");
|
||||
QString finalUrl = urlBase.arg(patch.rel_path).arg(fileName);
|
||||
QUrl url(finalUrl);
|
||||
|
||||
LOG_INFO(Frontend, "Downloading: {}", url.toString().toStdString());
|
||||
|
||||
QNetworkRequest request(url);
|
||||
current_reply = network_manager->get(request);
|
||||
|
||||
connect(current_reply, &QNetworkReply::finished, this, [this, patch, version_str, fileName]() {
|
||||
if (current_reply->error() == QNetworkReply::NoError) {
|
||||
std::filesystem::path load_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::LoadDir);
|
||||
std::filesystem::path tid_path = load_dir / mod_info.title_id.toStdString();
|
||||
|
||||
// This creates the hierarchy: load / [TID] / [Version] / [ModName] / [exefs/romfs]
|
||||
std::filesystem::path final_path = tid_path / version_str.toStdString() /
|
||||
patch.name.toStdString() / patch.type.toStdString();
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(final_path, ec);
|
||||
|
||||
QFile file(QString::fromStdString((final_path / fileName.toStdString()).string()));
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
file.write(current_reply->readAll());
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
current_reply->deleteLater();
|
||||
current_file_index++;
|
||||
ui->progressBar->setValue(((current_download_index + 1) * 100) / pending_downloads.size());
|
||||
StartNextDownload();
|
||||
});
|
||||
}
|
||||
|
||||
void ModDownloaderDialog::OnCancelClicked() {
|
||||
if (current_reply) current_reply->abort();
|
||||
reject();
|
||||
}
|
||||
|
||||
} // namespace ModManager
|
||||
49
src/citron/mod_manager/mod_downloader_dialog.h
Normal file
49
src/citron/mod_manager/mod_downloader_dialog.h
Normal file
@@ -0,0 +1,49 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "citron/mod_manager/mod_service.h"
|
||||
|
||||
namespace Ui {
|
||||
class ModDownloaderDialog;
|
||||
}
|
||||
|
||||
namespace ModManager {
|
||||
|
||||
// Helper to keep track of what version a patch belongs to during download
|
||||
struct DownloadTask {
|
||||
ModPatch patch;
|
||||
QString version;
|
||||
};
|
||||
|
||||
class ModDownloaderDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ModDownloaderDialog(const ModUpdateInfo& info, QWidget* parent = nullptr);
|
||||
~ModDownloaderDialog() override;
|
||||
|
||||
private slots:
|
||||
void OnDownloadClicked();
|
||||
void OnCancelClicked();
|
||||
|
||||
private:
|
||||
void SetupModList();
|
||||
void StartNextDownload();
|
||||
|
||||
::Ui::ModDownloaderDialog* ui;
|
||||
ModUpdateInfo mod_info;
|
||||
|
||||
class QNetworkAccessManager* network_manager;
|
||||
class QNetworkReply* current_reply;
|
||||
|
||||
std::vector<DownloadTask> pending_downloads;
|
||||
int current_download_index = -1;
|
||||
int current_file_index = 0;
|
||||
};
|
||||
|
||||
} // namespace ModManager
|
||||
70
src/citron/mod_manager/mod_downloader_dialog.ui
Normal file
70
src/citron/mod_manager/mod_downloader_dialog.ui
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>ModDownloaderDialog</class>
|
||||
<widget class="QDialog" name="ModDownloaderDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>450</width>
|
||||
<height>350</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Citron Mod Downloader</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Select the mods you wish to install for this game version:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="treeWidget">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Available Enhancements</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QProgressBar" name="progressBar">
|
||||
<property name="value">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="visible">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="buttonDownload">
|
||||
<property name="text">
|
||||
<string>Download Selected</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="buttonCancel">
|
||||
<property name="text">
|
||||
<string>Cancel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</ui>
|
||||
77
src/citron/mod_manager/mod_service.cpp
Normal file
77
src/citron/mod_manager/mod_service.cpp
Normal file
@@ -0,0 +1,77 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include "citron/mod_manager/mod_service.h"
|
||||
|
||||
namespace ModManager {
|
||||
|
||||
ModService::ModService(QObject* parent) : QObject(parent) {
|
||||
network_manager = std::make_unique<QNetworkAccessManager>(this);
|
||||
}
|
||||
|
||||
ModService::~ModService() = default;
|
||||
|
||||
void ModService::FetchAvailableMods(const QString& title_id) {
|
||||
QNetworkRequest request{QUrl(MANIFEST_URL)};
|
||||
QNetworkReply* reply = network_manager->get(request);
|
||||
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, title_id]() {
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
emit Error(reply->errorString());
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
||||
QJsonObject root = doc.object();
|
||||
|
||||
// Convert title_id to uppercase to match your folder/manifest structure
|
||||
QString tid_upper = title_id.toUpper();
|
||||
if (!root.contains(tid_upper)) {
|
||||
emit Error(QStringLiteral("No mods found for this game in the repository."));
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
ModUpdateInfo info;
|
||||
info.title_id = title_id;
|
||||
|
||||
QJsonObject tid_obj = root.value(tid_upper).toObject();
|
||||
QJsonObject versions_obj = tid_obj.value(QStringLiteral("versions")).toObject();
|
||||
|
||||
// Loop through every version found for this game (e.g., 1.0.0, 2.0.0)
|
||||
for (auto it = versions_obj.begin(); it != versions_obj.end(); ++it) {
|
||||
QString v_name = it.key();
|
||||
QJsonObject v_data = it.value().toObject();
|
||||
QJsonArray patches_array = v_data.value(QStringLiteral("patches")).toArray();
|
||||
|
||||
std::vector<ModPatch> patches;
|
||||
for (const QJsonValue& val : patches_array) {
|
||||
QJsonObject p_obj = val.toObject();
|
||||
ModPatch patch;
|
||||
patch.name = p_obj.value(QStringLiteral("name")).toString();
|
||||
patch.type = p_obj.value(QStringLiteral("type")).toString();
|
||||
patch.rel_path = p_obj.value(QStringLiteral("rel_path")).toString();
|
||||
|
||||
QJsonArray files_arr = p_obj.value(QStringLiteral("files")).toArray();
|
||||
for (const QJsonValue& f : files_arr) {
|
||||
patch.files << f.toString();
|
||||
}
|
||||
|
||||
patches.push_back(patch);
|
||||
}
|
||||
// Add this version and its mods to the map
|
||||
info.version_patches[v_name] = patches;
|
||||
}
|
||||
|
||||
emit ModsAvailable(info);
|
||||
reply->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace ModManager
|
||||
49
src/citron/mod_manager/mod_service.h
Normal file
49
src/citron/mod_manager/mod_service.h
Normal file
@@ -0,0 +1,49 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
namespace ModManager {
|
||||
|
||||
struct ModPatch {
|
||||
QString name;
|
||||
QString type; // "exefs" or "romfs"
|
||||
QString rel_path;
|
||||
QStringList files;
|
||||
};
|
||||
|
||||
struct ModUpdateInfo {
|
||||
QString title_id;
|
||||
// Maps Version String (e.g. "2.0.0") to its list of patches
|
||||
std::map<QString, std::vector<ModPatch>> version_patches;
|
||||
};
|
||||
|
||||
class ModService : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ModService(QObject* parent = nullptr);
|
||||
~ModService();
|
||||
|
||||
// Removed the version parameter so it fetches everything
|
||||
void FetchAvailableMods(const QString& title_id);
|
||||
|
||||
signals:
|
||||
void ModsAvailable(const ModUpdateInfo& info);
|
||||
void Error(const QString& message);
|
||||
|
||||
private:
|
||||
std::unique_ptr<QNetworkAccessManager> network_manager;
|
||||
const QString MANIFEST_URL = QStringLiteral("https://raw.githubusercontent.com/CollectingW/Citron-Mods/main/manifest.json");
|
||||
};
|
||||
|
||||
} // namespace ModManager
|
||||
@@ -107,9 +107,28 @@ void AppendCommaIfNotEmpty(std::string& to, std::string_view with) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string GetInternalModName(const VirtualDir& mod_dir, const VirtualDir& load_root) {
|
||||
if (!mod_dir || !load_root) return "";
|
||||
auto parent = mod_dir->GetParentDirectory();
|
||||
// If the parent is the root load folder, it's a flat mod (e.g., "Reduce Bloom")
|
||||
if (parent == load_root) {
|
||||
return mod_dir->GetName();
|
||||
}
|
||||
// If there is a parent in between, it's a hierarchical mod (e.g., "2.0.0/Reduce Bloom")
|
||||
return parent->GetName() + "/" + mod_dir->GetName();
|
||||
}
|
||||
|
||||
bool IsDirValidAndNonEmpty(const VirtualDir& dir) {
|
||||
return dir != nullptr && (!dir->GetFiles().empty() || !dir->GetSubdirectories().empty());
|
||||
}
|
||||
|
||||
bool IsAnyModFolder(const VirtualDir& dir) {
|
||||
if (!dir) return false;
|
||||
return FindSubdirectoryCaseless(dir, "exefs") != nullptr ||
|
||||
FindSubdirectoryCaseless(dir, "romfs") != nullptr ||
|
||||
FindSubdirectoryCaseless(dir, "romfslite") != nullptr ||
|
||||
FindSubdirectoryCaseless(dir, "cheats") != nullptr;
|
||||
}
|
||||
} // Anonymous namespace
|
||||
|
||||
PatchManager::PatchManager(u64 title_id_,
|
||||
@@ -174,8 +193,16 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const {
|
||||
const auto sdmc_load_dir = fs_controller.GetSDMCModificationLoadRoot(title_id);
|
||||
std::vector<VirtualDir> patch_dirs = {sdmc_load_dir};
|
||||
if (load_dir != nullptr) {
|
||||
const auto load_patch_dirs = load_dir->GetSubdirectories();
|
||||
patch_dirs.insert(patch_dirs.end(), load_patch_dirs.begin(), load_patch_dirs.end());
|
||||
for (const auto& top_dir : load_dir->GetSubdirectories()) {
|
||||
if (!top_dir) continue;
|
||||
if (IsAnyModFolder(top_dir)) {
|
||||
patch_dirs.push_back(top_dir);
|
||||
} else {
|
||||
for (const auto& sub_dir : top_dir->GetSubdirectories()) {
|
||||
if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::sort(patch_dirs.begin(), patch_dirs.end(),
|
||||
[](const VirtualDir& l, const VirtualDir& r) {
|
||||
@@ -191,7 +218,8 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const {
|
||||
std::vector<VirtualDir> layers;
|
||||
layers.reserve(patch_dirs.size() + 1);
|
||||
for (const auto& subdir : patch_dirs) {
|
||||
if (!subdir || std::find(disabled.begin(), disabled.end(), subdir->GetName()) != disabled.end())
|
||||
const std::string internal_name = GetInternalModName(subdir, load_dir);
|
||||
if (!subdir || std::find(disabled.begin(), disabled.end(), internal_name) != disabled.end())
|
||||
continue;
|
||||
auto exefs_dir = FindSubdirectoryCaseless(subdir, "exefs");
|
||||
if (exefs_dir != nullptr)
|
||||
@@ -222,8 +250,10 @@ std::vector<VirtualFile> PatchManager::CollectPatches(const std::vector<VirtualD
|
||||
const auto nso_build_id = fmt::format("{:0<64}", build_id);
|
||||
std::vector<VirtualFile> out;
|
||||
out.reserve(patch_dirs.size());
|
||||
const auto load_dir = fs_controller.GetModificationLoadRoot(title_id);
|
||||
for (const auto& subdir : patch_dirs) {
|
||||
if (!subdir || std::find(disabled.cbegin(), disabled.cend(), subdir->GetName()) != disabled.cend())
|
||||
const std::string internal_name = GetInternalModName(subdir, load_dir);
|
||||
if (!subdir || std::find(disabled.cbegin(), disabled.cend(), internal_name) != disabled.cend())
|
||||
continue;
|
||||
auto exefs_dir = FindSubdirectoryCaseless(subdir, "exefs");
|
||||
if (exefs_dir != nullptr) {
|
||||
@@ -276,7 +306,17 @@ std::vector<u8> PatchManager::PatchNSO(const std::vector<u8>& nso, const std::st
|
||||
LOG_ERROR(Loader, "Cannot load mods for invalid title_id={:016X}", title_id);
|
||||
return nso;
|
||||
}
|
||||
auto patch_dirs = load_dir->GetSubdirectories();
|
||||
std::vector<VirtualDir> patch_dirs;
|
||||
for (const auto& top_dir : load_dir->GetSubdirectories()) {
|
||||
if (!top_dir) continue;
|
||||
if (IsAnyModFolder(top_dir)) {
|
||||
patch_dirs.push_back(top_dir);
|
||||
} else {
|
||||
for (const auto& sub_dir : top_dir->GetSubdirectories()) {
|
||||
if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::sort(patch_dirs.begin(), patch_dirs.end(),
|
||||
[](const VirtualDir& l, const VirtualDir& r) { return l->GetName() < r->GetName(); });
|
||||
const auto patches = CollectPatches(patch_dirs, build_id);
|
||||
@@ -313,7 +353,17 @@ bool PatchManager::HasNSOPatch(const BuildID& build_id_, std::string_view name)
|
||||
LOG_ERROR(Loader, "Cannot load mods for invalid title_id={:016X}", title_id);
|
||||
return false;
|
||||
}
|
||||
auto patch_dirs = load_dir->GetSubdirectories();
|
||||
std::vector<VirtualDir> patch_dirs;
|
||||
for (const auto& top_dir : load_dir->GetSubdirectories()) {
|
||||
if (!top_dir) continue;
|
||||
if (IsAnyModFolder(top_dir)) {
|
||||
patch_dirs.push_back(top_dir);
|
||||
} else {
|
||||
for (const auto& sub_dir : top_dir->GetSubdirectories()) {
|
||||
if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::sort(patch_dirs.begin(), patch_dirs.end(),
|
||||
[](const VirtualDir& l, const VirtualDir& r) { return l->GetName() < r->GetName(); });
|
||||
return !CollectPatches(patch_dirs, build_id).empty();
|
||||
@@ -332,7 +382,8 @@ std::vector<Core::Memory::CheatEntry> PatchManager::CreateCheatList(
|
||||
[](const VirtualDir& l, const VirtualDir& r) { return l->GetName() < r->GetName(); });
|
||||
std::vector<Core::Memory::CheatEntry> out;
|
||||
for (const auto& subdir : patch_dirs) {
|
||||
if (!subdir || std::find(disabled.cbegin(), disabled.cend(), subdir->GetName()) != disabled.cend()) {
|
||||
const std::string internal_name = GetInternalModName(subdir, load_dir);
|
||||
if (!subdir || std::find(disabled.cbegin(), disabled.cend(), internal_name) != disabled.cend()) {
|
||||
continue;
|
||||
}
|
||||
auto cheats_dir = FindSubdirectoryCaseless(subdir, "cheats");
|
||||
@@ -361,7 +412,16 @@ static void ApplyLayeredFS(VirtualFile& romfs, u64 title_id, ContentRecordType t
|
||||
const auto& disabled = Settings::values.disabled_addons[title_id];
|
||||
std::vector<VirtualDir> patch_dirs;
|
||||
if (load_dir) {
|
||||
patch_dirs = load_dir->GetSubdirectories();
|
||||
for (const auto& top_dir : load_dir->GetSubdirectories()) {
|
||||
if (!top_dir) continue;
|
||||
if (IsAnyModFolder(top_dir)) {
|
||||
patch_dirs.push_back(top_dir);
|
||||
} else {
|
||||
for (const auto& sub_dir : top_dir->GetSubdirectories()) {
|
||||
if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (std::find(disabled.cbegin(), disabled.cend(), "SDMC") == disabled.cend()) {
|
||||
patch_dirs.push_back(sdmc_load_dir);
|
||||
@@ -379,7 +439,8 @@ static void ApplyLayeredFS(VirtualFile& romfs, u64 title_id, ContentRecordType t
|
||||
layers.reserve(patch_dirs.size() + 1);
|
||||
layers_ext.reserve(patch_dirs.size() + 1);
|
||||
for (const auto& subdir : patch_dirs) {
|
||||
if (!subdir || std::find(disabled.cbegin(), disabled.cend(), subdir->GetName()) != disabled.cend()) {
|
||||
const std::string internal_name = GetInternalModName(subdir, load_dir);
|
||||
if (!subdir || std::find(disabled.cbegin(), disabled.cend(), internal_name) != disabled.cend()) {
|
||||
continue;
|
||||
}
|
||||
auto romfs_dir = FindSubdirectoryCaseless(subdir, "romfs");
|
||||
@@ -693,28 +754,52 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
|
||||
scan_autoloader_content("DLC", PatchType::DLC);
|
||||
}
|
||||
|
||||
// --- 3. General Mods (from original code) ---
|
||||
// --- 3. General Mods (Recursive Scan) ---
|
||||
const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id);
|
||||
if (mod_dir != nullptr) {
|
||||
for (const auto& mod : mod_dir->GetSubdirectories()) {
|
||||
std::string types;
|
||||
const auto exefs_dir = FindSubdirectoryCaseless(mod, "exefs");
|
||||
if (IsDirValidAndNonEmpty(exefs_dir)) {
|
||||
bool ips = false, ipswitch = false, layeredfs = false;
|
||||
for (const auto& file : exefs_dir->GetFiles()) {
|
||||
if (file->GetExtension() == "ips") ips = true;
|
||||
else if (file->GetExtension() == "pchtxt") ipswitch = true;
|
||||
else if (std::find(EXEFS_FILE_NAMES.begin(), EXEFS_FILE_NAMES.end(), file->GetName()) != EXEFS_FILE_NAMES.end()) layeredfs = true;
|
||||
for (const auto& top_mod : mod_dir->GetSubdirectories()) {
|
||||
if (!top_mod) continue;
|
||||
|
||||
// Helper lambda to process a directory and return the patch types found
|
||||
auto get_mod_types = [this](const VirtualDir& dir) -> std::string {
|
||||
std::string types;
|
||||
const auto exefs_dir = FindSubdirectoryCaseless(dir, "exefs");
|
||||
if (IsDirValidAndNonEmpty(exefs_dir)) {
|
||||
bool ips = false, ipswitch = false, layeredfs = false;
|
||||
for (const auto& file : exefs_dir->GetFiles()) {
|
||||
if (file->GetExtension() == "ips") ips = true;
|
||||
else if (file->GetExtension() == "pchtxt") ipswitch = true;
|
||||
else if (std::find(EXEFS_FILE_NAMES.begin(), EXEFS_FILE_NAMES.end(), file->GetName()) != EXEFS_FILE_NAMES.end()) layeredfs = true;
|
||||
}
|
||||
if (ips) AppendCommaIfNotEmpty(types, "IPS");
|
||||
if (ipswitch) AppendCommaIfNotEmpty(types, "IPSwitch");
|
||||
if (layeredfs) AppendCommaIfNotEmpty(types, "LayeredExeFS");
|
||||
}
|
||||
if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(dir, "romfs")) || IsDirValidAndNonEmpty(FindSubdirectoryCaseless(dir, "romfslite"))) AppendCommaIfNotEmpty(types, "LayeredFS");
|
||||
if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(dir, "cheats"))) AppendCommaIfNotEmpty(types, "Cheats");
|
||||
return types;
|
||||
};
|
||||
|
||||
// 1. Check if the top-level folder is a mod (Existing behavior)
|
||||
std::string top_types = get_mod_types(top_mod);
|
||||
if (!top_types.empty()) {
|
||||
const std::string internal_name = GetInternalModName(top_mod, mod_dir);
|
||||
const auto mod_disabled = std::find(disabled.begin(), disabled.end(), internal_name) != disabled.end();
|
||||
out.push_back({.enabled = !mod_disabled, .name = top_mod->GetName(), .version = top_types, .type = PatchType::Mod, .program_id = title_id, .title_id = title_id});
|
||||
}
|
||||
// 2. If not a mod, check one level deeper (Grouped behavior)
|
||||
else {
|
||||
for (const auto& sub_mod : top_mod->GetSubdirectories()) {
|
||||
std::string sub_types = get_mod_types(sub_mod);
|
||||
if (sub_types.empty()) continue;
|
||||
|
||||
// We store the name as "FolderName/ModName"
|
||||
std::string hierarchical_name = top_mod->GetName() + "/" + sub_mod->GetName();
|
||||
const std::string internal_name = GetInternalModName(sub_mod, mod_dir);
|
||||
const auto mod_disabled = std::find(disabled.begin(), disabled.end(), internal_name) != disabled.end();
|
||||
out.push_back({.enabled = !mod_disabled, .name = hierarchical_name, .version = sub_types, .type = PatchType::Mod, .program_id = title_id, .title_id = title_id});
|
||||
}
|
||||
if (ips) AppendCommaIfNotEmpty(types, "IPS");
|
||||
if (ipswitch) AppendCommaIfNotEmpty(types, "IPSwitch");
|
||||
if (layeredfs) AppendCommaIfNotEmpty(types, "LayeredExeFS");
|
||||
}
|
||||
if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(mod, "romfs")) || IsDirValidAndNonEmpty(FindSubdirectoryCaseless(mod, "romfslite"))) AppendCommaIfNotEmpty(types, "LayeredFS");
|
||||
if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(mod, "cheats"))) AppendCommaIfNotEmpty(types, "Cheats");
|
||||
if (types.empty()) continue;
|
||||
const auto mod_disabled = std::find(disabled.begin(), disabled.end(), mod->GetName()) != disabled.end();
|
||||
out.push_back({.enabled = !mod_disabled, .name = mod->GetName(), .version = types, .type = PatchType::Mod, .program_id = title_id, .title_id = title_id});
|
||||
}
|
||||
}
|
||||
const auto sdmc_mod_dir = fs_controller.GetSDMCModificationLoadRoot(title_id);
|
||||
|
||||
Reference in New Issue
Block a user