Merge pull request #123

Revert #116
This commit is contained in:
CollectingW
2026-04-04 20:39:19 -04:00
committed by GitHub
29 changed files with 2541 additions and 603 deletions
+7 -6
View File
@@ -336,11 +336,13 @@ 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
mod_manager/gamebanana_service.cpp
mod_manager/gamebanana_service.h
mod_manager/gamebanana_dialog.cpp
mod_manager/gamebanana_dialog.h
mod_manager/gamebanana_dialog.ui
mod_manager/zip_extractor.cpp
mod_manager/zip_extractor.h
)
target_link_libraries(citron PRIVATE Qt6::Network)
@@ -636,7 +638,6 @@ create_target_directory_groups(citron)
if(UNIX AND CMAKE_UNITY_BUILD)
set_source_files_properties(
startup_checks.cpp compatdb.cpp setup_wizard.cpp
mod_manager/mod_service.cpp mod_manager/mod_downloader_dialog.cpp
PROPERTIES SKIP_UNITY_BUILD_INCLUSION TRUE
)
endif()
@@ -31,16 +31,13 @@
#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"
#include "citron/mod_manager/gamebanana_dialog.h"
ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* parent)
: 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);
ui->button_download_mods->setVisible(true);
layout = new QVBoxLayout;
tree_view = new QTreeView;
@@ -80,23 +77,17 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
ui->gridLayout->addLayout(layout, 0, 0);
ui->gridLayout->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
// BUTTON CLICK: Open GameBanana Dialog
connect(ui->button_download_mods, &QPushButton::clicked, this, [this] {
auto* dialog = new ModManager::ModDownloaderDialog(cached_mod_info, this);
if (file == nullptr) return;
const auto loader = Loader::GetLoader(system, file);
std::string title;
loader->ReadTitle(title);
QString game_name = QString::fromStdString(title);
const QString tid_str = QStringLiteral("%1").arg(title_id, 16, 16, QLatin1Char('0')).toUpper();
auto* dialog = new ModManager::GameBananaDialog(tid_str, game_name, this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
connect(dialog, &QDialog::accepted, this, [this] {
this->LoadConfiguration();
@@ -142,10 +133,6 @@ 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) {
@@ -11,7 +11,6 @@
#include <QWidget>
#include "core/file_sys/vfs/vfs_types.h"
#include "citron/mod_manager/mod_service.h"
namespace Core {
class System;
@@ -22,6 +21,10 @@ class QStandardItemModel;
class QTreeView;
class QVBoxLayout;
namespace ModManager {
class GameBananaDialog;
}
namespace Ui {
class ConfigurePerGameAddons;
}
@@ -48,8 +51,6 @@ private:
void LoadConfiguration();
std::unique_ptr<Ui::ConfigurePerGameAddons> ui;
ModManager::ModService* mod_service;
ModManager::ModUpdateInfo cached_mod_info;
FileSys::VirtualFile file;
u64 title_id;
@@ -31,7 +31,7 @@
<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>
<string>Download Mods VIA Gamebanana</string>
</property>
</widget>
</item>
+388 -70
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -116,11 +116,13 @@ public:
bool IsEmpty() const;
void LoadCompatibilityList();
void PopulateAsync(QVector<UISettings::GameDir>& game_dirs);
void PopulateAsync(QVector<UISettings::GameDir>& game_dirs, bool is_smart_update = false);
void CancelPopulation();
void RefreshGame(u64 program_id, u64 play_time);
void SaveInterfaceLayout();
void LoadInterfaceLayout();
void ClearLaunchOverlays();
enum class ViewMode { List, Grid, Carousel };
void SetViewMode(ViewMode mode);
@@ -197,7 +199,7 @@ private:
void WorkerEvent();
void AddDirEntry(GameListDir* entry_items);
void AddEntry(const QList<QStandardItem*>& entry_items, GameListDir* parent);
void AddEntry(const QList<QStandardItem*>& entry_items, const QString& parent_path);
void DonePopulating(const QStringList& watch_list);
private:
@@ -210,6 +212,8 @@ private:
void RemoveFavorite(u64 program_id);
void StartLaunchAnimation(const QModelIndex& item);
void SaveGameListIndex();
void LoadGameListIndex();
void ToggleHidden(const QString& path);
void UpdateCarouselSelection();
void AnimateDetailsPanel(bool show);
@@ -220,8 +224,8 @@ private:
void FilterTreeView(const QString& filter_text);
void PopupContextMenu(const QPoint& menu_location);
void AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path,
const QString& game_name);
void AddGamePopup(QMenu& context_menu, const QModelIndex& index, u64 program_id,
const std::string& path, const QString& game_name);
void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected,
bool show_hidden_action = true);
void AddPermDirPopup(QMenu& context_menu, QModelIndex selected);
@@ -273,6 +277,7 @@ private:
bool toolbar_in_main = false;
class NavigationSettingsOverlay* m_nav_overlay = nullptr;
bool m_is_controller_mode = false;
bool m_is_launching = false;
QWidget* footer_widget = nullptr;
QToolButton* btn_add_dir = nullptr;
+47
View File
@@ -181,6 +181,9 @@ void GameListDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opti
case GameList::COLUMN_COMPATIBILITY:
PaintCompatibility(painter, rect, index);
break;
case GameList::COLUMN_PLAY_TIME:
PaintPlayTime(painter, rect, option, index);
break;
default:
PaintDefault(painter, rect, option, index);
break;
@@ -298,6 +301,21 @@ void GameListDelegate::AdvanceAnimations() {
// 4. Entry animations (Bubble/Fade-in)
auto it_entry = entry_animations.begin();
// 5. Global Refresh Spinner Animation
refresh_angle -= 10.0; // Rotate 10 degrees per frame
if (refresh_angle <= -360.0) refresh_angle += 360.0;
// Trigger repaint for any item currently in 'Refreshing' state
for (int i = 0; i < tree_view->model()->rowCount(); ++i) {
QModelIndex folder = tree_view->model()->index(i, 0);
for (int j = 0; j < tree_view->model()->rowCount(folder); ++j) {
QModelIndex play_time_idx = tree_view->model()->index(j, GameList::COLUMN_PLAY_TIME, folder);
if (play_time_idx.data(GameListItem::IsRefreshingRole).toBool()) {
indices_to_update.append(play_time_idx);
}
}
}
while (it_entry != entry_animations.end()) {
const QPersistentModelIndex& key = it_entry.key();
if (!key.isValid()) {
@@ -796,4 +814,33 @@ void GameListDelegate::ClearAnimations() {
}
}
void GameListDelegate::PaintPlayTime(QPainter* painter, const QRect& rect,
const QStyleOptionViewItem& option,
const QModelIndex& index) const {
const bool is_refreshing = index.data(GameListItem::IsRefreshingRole).toBool();
if (is_refreshing) {
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
// Center the spinner in the cell
const int size = 16;
const int x = rect.left() + (rect.width() - size) / 2;
const int y = rect.top() + (rect.height() - size) / 2;
QRect spinner_rect(x, y, size, size);
QPen pen(AccentColor(), 2);
pen.setCapStyle(Qt::RoundCap);
painter->setPen(pen);
// Draw a partial arc that rotates based on the global refresh_angle
painter->drawArc(spinner_rect, static_cast<int>(refresh_angle * 16), 270 * 16);
painter->restore();
} else {
// Fallback to standard text rendering if not refreshing
PaintDefault(painter, rect, option, index);
}
}
#include "game_list_delegate.moc"
+3
View File
@@ -60,6 +60,8 @@ private:
const QModelIndex& index) const;
void PaintGameInfo(QPainter* painter, const QRect& rect, const QStyleOptionViewItem& option,
const QModelIndex& index) const;
void PaintPlayTime(QPainter* painter, const QRect& rect, const QStyleOptionViewItem& option,
const QModelIndex& index) const;
void PaintCompatibility(QPainter* painter, const QRect& rect,
const QModelIndex& index) const;
void PaintDefault(QPainter* painter, const QRect& rect, const QStyleOptionViewItem& option,
@@ -83,6 +85,7 @@ private:
bool is_populating = false;
bool enable_bubble_animations = false;
qreal population_fade_global = 1.0;
mutable qreal refresh_angle = 0.0;
// Performance optimizations
mutable QMap<QString, QIcon> greyscale_icon_cache;
mutable QMap<QString, QStringList> addons_item_cache;
+20 -7
View File
@@ -83,6 +83,7 @@ public:
// used to access type from item index
static constexpr int TypeRole = Qt::UserRole + 1;
static constexpr int SortRole = Qt::UserRole + 2;
static constexpr int IsRefreshingRole = Qt::UserRole + 20;
GameListItem() = default;
explicit GameListItem(const QString& string) : QStandardItem(string) {
setData(string, SortRole);
@@ -102,13 +103,16 @@ public:
static constexpr int ProgramIdRole = SortRole + 3;
static constexpr int FileTypeRole = SortRole + 4;
static constexpr int HighResIconRole = SortRole + 5;
static constexpr int OriginalTitleRole = SortRole + 6;
GameListItemPath() = default;
GameListItemPath(const QString& game_path, const std::vector<u8>& picture_data,
const QString& game_name, const QString& game_type, u64 program_id) {
const QString& game_name, const QString& original_name,
const QString& game_type, u64 program_id) {
setData(type(), TypeRole);
setData(game_path, FullPathRole);
setData(game_name, TitleRole);
setData(original_name, OriginalTitleRole);
setData(qulonglong(program_id), ProgramIdRole);
setData(game_type, FileTypeRole);
@@ -271,15 +275,19 @@ public:
GameListItemPlayTime() = default;
explicit GameListItemPlayTime(const qulonglong time_seconds) {
setData(time_seconds, PlayTimeRole);
// Explicitly set display role since constructor doesn't call virtual setData
GameListItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole);
// setData(time_seconds, PlayTimeRole) might call virtual and it is safer here to be explicit
QStandardItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole);
QStandardItem::setData(time_seconds, PlayTimeRole);
}
void setData(const QVariant& value, int role) override {
qulonglong time_seconds = value.toULongLong();
GameListItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole);
GameListItem::setData(value, PlayTimeRole);
if (role == PlayTimeRole) {
qulonglong time_seconds = value.toULongLong();
QStandardItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole);
QStandardItem::setData(value, PlayTimeRole);
} else {
QStandardItem::setData(value, role);
}
}
bool operator<(const QStandardItem& other) const override {
@@ -309,6 +317,7 @@ public:
class GameListDir : public GameListItem {
public:
static constexpr int GameDirRole = Qt::UserRole + 2;
static constexpr int FullPathRole = Qt::UserRole + 3;
explicit GameListDir(UISettings::GameDir& directory,
GameListItemType dir_type_ = GameListItemType::CustomDir)
@@ -327,6 +336,7 @@ public:
.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation),
Qt::DecorationRole);
setData(QObject::tr("Installed SD Titles"), Qt::DisplayRole);
setData(QObject::tr("Installed SD Titles"), FullPathRole);
break;
case GameListItemType::UserNandDir:
setData(
@@ -335,6 +345,7 @@ public:
.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation),
Qt::DecorationRole);
setData(QObject::tr("Installed NAND Titles"), Qt::DisplayRole);
setData(QObject::tr("Installed NAND Titles"), FullPathRole);
break;
case GameListItemType::SysNandDir:
setData(
@@ -343,6 +354,7 @@ public:
.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation),
Qt::DecorationRole);
setData(QObject::tr("System Titles"), Qt::DisplayRole);
setData(QObject::tr("System Titles"), FullPathRole);
break;
case GameListItemType::CustomDir: {
const QString path = QString::fromStdString(game_dir->path);
@@ -352,6 +364,7 @@ public:
icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation),
Qt::DecorationRole);
setData(path, Qt::DisplayRole);
setData(path, FullPathRole);
break;
}
default:
+36 -26
View File
@@ -347,16 +347,18 @@ std::pair<std::vector<u8>, std::string> GetGameListCachedObject(
}
void GetMetadataFromControlNCA(const FileSys::PatchManager& patch_manager, const FileSys::NCA& nca,
std::vector<u8>& icon, std::string& name) {
std::vector<u8>& icon, std::string& name,
std::string& original_name) {
const auto program_id = patch_manager.GetTitleID();
auto& custom_metadata = Citron::CustomMetadata::GetInstance();
std::tie(icon, name) =
std::tie(icon, original_name) =
GetGameListCachedObject(fmt::format("{:016X}", program_id), {}, [&patch_manager, &nca] {
const auto [nacp, icon_f] = patch_manager.ParseControlNCA(nca);
return std::make_pair(icon_f->ReadAllBytes(), nacp->GetApplicationName());
});
name = original_name;
if (auto custom_title = custom_metadata.GetCustomTitle(program_id)) {
name = *custom_title;
}
@@ -451,6 +453,7 @@ QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager,
}
QList<QStandardItem*> MakeGameListEntry(const std::string& path, const std::string& name,
const std::string& original_name,
const std::size_t size, const std::vector<u8>& icon,
Loader::AppLoader& loader, u64 program_id,
const CompatibilityList& compatibility_list,
@@ -477,8 +480,9 @@ QList<QStandardItem*> MakeGameListEntry(const std::string& path, const std::stri
}
QList<QStandardItem*> list{new GameListItemPath(FormatGameName(path), icon,
QString::fromStdString(name), file_type_string,
program_id),
QString::fromStdString(name),
QString::fromStdString(original_name),
file_type_string, program_id),
new GameListItemCompat(compatibility),
new GameListItem(file_type_string),
new GameListItemSize(size),
@@ -576,7 +580,7 @@ void GameListWorker::RecordEvent(F&& func) {
emit DataAvailable();
}
void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir,
void GameListWorker::AddTitlesToGameList(const QString& parent_path,
const std::map<u64, std::pair<int, int>>& online_stats) {
using namespace FileSys;
@@ -585,13 +589,13 @@ void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir,
auto installed_games = cache.ListEntriesFilterOrigin(std::nullopt, TitleType::Application,
ContentRecordType::Program);
if (parent_dir->type() == static_cast<int>(GameListItemType::SdmcDir)) {
if (parent_path == QObject::tr("Installed SD Titles")) {
installed_games = cache.ListEntriesFilterOrigin(
ContentProviderUnionSlot::SDMC, TitleType::Application, ContentRecordType::Program);
} else if (parent_dir->type() == static_cast<int>(GameListItemType::UserNandDir)) {
} else if (parent_path == QObject::tr("Installed NAND Titles")) {
installed_games = cache.ListEntriesFilterOrigin(
ContentProviderUnionSlot::UserNAND, TitleType::Application, ContentRecordType::Program);
} else if (parent_dir->type() == static_cast<int>(GameListItemType::SysNandDir)) {
} else if (parent_path == QObject::tr("System Titles")) {
installed_games = cache.ListEntriesFilterOrigin(
ContentProviderUnionSlot::SysNAND, TitleType::Application, ContentRecordType::Program);
}
@@ -613,6 +617,7 @@ void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir,
std::vector<u8> icon;
std::string name;
std::string original_name;
u64 program_id = 0;
const auto result = loader->ReadProgramId(program_id);
@@ -624,21 +629,21 @@ void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir,
system.GetContentProvider()};
const auto control = cache.GetEntry(game.title_id, ContentRecordType::Control);
if (control != nullptr) {
GetMetadataFromControlNCA(patch, *control, icon, name);
GetMetadataFromControlNCA(patch, *control, icon, name, original_name);
}
auto entry =
MakeGameListEntry(file->GetFullPath(), name, file->GetSize(), icon, *loader, program_id,
compatibility_list, play_time_manager, patch, online_stats);
RecordEvent([=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); });
auto entry = MakeGameListEntry(file->GetFullPath(), name, original_name, file->GetSize(),
icon, *loader, program_id, compatibility_list,
play_time_manager, patch, online_stats);
RecordEvent([=](GameList* game_list) { game_list->AddEntry(entry, parent_path); });
}
}
void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan,
GameListDir* parent_dir,
const QString& parent_path,
const std::map<u64, std::pair<int, int>>& online_stats,
int& processed_files, int total_files) {
const auto callback = [this, target, parent_dir, &online_stats, &processed_files,
const auto callback = [this, target, parent_path, &online_stats, &processed_files,
total_files](const std::filesystem::directory_entry& dir_entry) -> bool {
if (stop_requested) {
return false;
@@ -697,13 +702,14 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
system.GetContentProvider()};
auto loader = Loader::GetLoader(system, file);
if (loader) {
std::string title = cached->title;
std::string name = cached->title;
std::string original_name = cached->title;
std::vector<u8> icon = cached->icon;
auto& custom_metadata = Citron::CustomMetadata::GetInstance();
if (auto custom_title =
custom_metadata.GetCustomTitle(cached->program_id)) {
title = *custom_title;
name = *custom_title;
}
if (auto custom_icon_path =
@@ -716,11 +722,11 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
}
auto entry =
MakeGameListEntry(physical_name, title, cached->file_size, icon,
*loader, cached->program_id, compatibility_list,
MakeGameListEntry(physical_name, name, original_name, cached->file_size,
icon, *loader, cached->program_id, compatibility_list,
play_time_manager, patch, online_stats);
RecordEvent(
[=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); });
[=](GameList* game_list) { game_list->AddEntry(entry, parent_path); });
}
}
}
@@ -772,9 +778,11 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
// 3. FILTER UPDATES: Only add to UI if it's a Base Game (ID ends in 000)
if ((program_id & 0xFFF) == 0) {
std::vector<u8> icon;
std::string original_name = " ";
std::string name = " ";
loader->ReadIcon(icon);
loader->ReadTitle(name);
loader->ReadTitle(original_name);
name = original_name;
auto& custom_metadata = Citron::CustomMetadata::GetInstance();
if (auto custom_title = custom_metadata.GetCustomTitle(program_id)) {
@@ -791,15 +799,16 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
std::size_t file_size = Common::FS::GetSize(physical_name);
CacheGameMetadata(physical_name, program_id, file_type, file_size, name, icon);
CacheGameMetadata(physical_name, program_id, file_type, file_size,
original_name, icon);
const FileSys::PatchManager patch{program_id, system.GetFileSystemController(),
system.GetContentProvider()};
auto entry = MakeGameListEntry(physical_name, name, file_size, icon, *loader,
program_id, compatibility_list,
auto entry = MakeGameListEntry(physical_name, name, original_name, file_size,
icon, *loader, program_id, compatibility_list,
play_time_manager, patch, online_stats);
RecordEvent(
[=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); });
[=](GameList* game_list) { game_list->AddEntry(entry, parent_path); });
}
}
}
@@ -884,9 +893,10 @@ void GameListWorker::run() {
watch_list.append(QString::fromStdString(game_dir.path));
auto* const game_list_dir = new GameListDir(game_dir);
const QString dir_path_q = game_list_dir->data(GameListDir::FullPathRole).toString();
DirEntryReady(game_list_dir);
ScanFileSystem(ScanTarget::Both, game_dir.path, game_dir.deep_scan, game_list_dir,
ScanFileSystem(ScanTarget::Both, game_dir.path, game_dir.deep_scan, dir_path_q,
online_stats, processed_files, total_files);
}
+2 -2
View File
@@ -86,11 +86,11 @@ private:
void RecordEvent(F&& func);
private:
void AddTitlesToGameList(GameListDir* parent_dir,
void AddTitlesToGameList(const QString& parent_path,
const std::map<u64, std::pair<int, int>>& online_stats);
void ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan,
GameListDir* parent_dir,
const QString& parent_path,
const std::map<u64, std::pair<int, int>>& online_stats,
int& processed_files, int total_files);
+18 -7
View File
@@ -2262,6 +2262,7 @@ void GMainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletP
LOG_INFO(Frontend, "citron starting...");
game_list->CancelPopulation();
game_list->ClearLaunchOverlays();
RegisterAutoloaderContents();
@@ -2521,7 +2522,14 @@ void GMainWindow::OnEmulationStopped() {
system->GetFileSystemController().CreateFactories(*vfs, true);
// Refresh the game list now that the filesystem is valid again.
game_list->PopulateAsync(UISettings::values.game_dirs);
game_list->ClearLaunchOverlays();
if (play_time_manager) {
const u64 program_id = play_time_manager->GetProgramId();
game_list->RefreshGame(program_id, play_time_manager->GetPlayTime(program_id));
}
game_list->PopulateAsync(UISettings::values.game_dirs, true);
game_list->setEnabled(true);
game_list->setFocus();
discord_rpc->Update();
@@ -4092,9 +4100,9 @@ void GMainWindow::OnPauseContinueGame() {
void GMainWindow::OnStopGame() {
if (ConfirmShutdownGame()) {
play_time_manager->Stop();
// Update game list to show new play time
game_list->PopulateAsync(UISettings::values.game_dirs);
game_list->ClearLaunchOverlays();
game_list->PopulateAsync(UISettings::values.game_dirs, true);
if (OnShutdownBegin()) {
OnShutdownBeginDialog();
} else {
@@ -6264,14 +6272,17 @@ void GMainWindow::UpdateUITheme() {
if (f.open(QFile::ReadOnly | QFile::Text)) {
QString style = QString::fromUtf8(f.readAll());
// Append Grey Onyx overrides for popups (Menus)
const QString onyx_popups = QStringLiteral(
"QMenu { background-color: #24242a !important; border: 1px solid #32323a; border-radius: 8px; padding: 4px; color: #ffffff; }"
// Append Grey Onyx overrides for popups (Menus) and the Top Unified Toolbar
const QString onyx_overrides = QStringLiteral(
"QMenuBar { background-color: #08080a; border-bottom: 1px solid #1a1a1e; min-height: 38px; }"
"QMenuBar::item { padding: 0px 14px; background: transparent; border-radius: 4px; color: #ffffff; margin: 4px 2px; height: 30px; }"
"QMenuBar::item:selected { background-color: #24242a; }"
"QMenu { background-color: #1a1a1e !important; border: 1px solid #32323a; border-radius: 8px; padding: 4px; color: #ffffff; }"
"QMenu::item { padding: 6px 25px; border-radius: 4px; margin: 1px; color: #ffffff; }"
"QMenu::item:selected { background-color: #32323a; border: 1px solid #42424a; }"
"QMenu::separator { height: 1px; background: #32323a; margin: 4px 10px; }"
);
qApp->setStyleSheet(style + onyx_popups);
qApp->setStyleSheet(style + onyx_overrides);
} else {
LOG_ERROR(Frontend, "Unable to set style \"{}\", stylesheet file not found",
UISettings::values.theme);
@@ -0,0 +1,402 @@
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QCoreApplication>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QListWidgetItem>
#include <QMessageBox>
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
#include <QNetworkReply>
#else
#include <QtNetwork/QNetworkReply>
#endif
#include <QPixmap>
#include <QPushButton>
#include <QUrl>
#include "citron/mod_manager/gamebanana_dialog.h"
#include "citron/mod_manager/zip_extractor.h"
#include "citron/uisettings.h"
#include "common/fs/fs.h"
#include "common/fs/path_util.h"
#include "common/logging.h"
#include "ui_gamebanana_dialog.h"
namespace ModManager {
GameBananaDialog::GameBananaDialog(const QString& title_id_, const QString& game_name_,
QWidget* parent)
: QDialog(parent), ui(std::make_unique<Ui::GameBananaDialog>()),
service(new GameBananaService(this)), title_id(title_id_), game_name(game_name_) {
ui->setupUi(this);
ui->gameTitleLabel->setText(tr("Mods for: %1").arg(game_name));
ui->progressBar->setVisible(false);
ui->buttonLoadMore->setVisible(false);
ui->modDetailsGroup->setVisible(true);
// Initialize checkboxes from settings
ui->alwaysAskManualExtraction->setChecked(
UISettings::values.always_ask_manual_extraction.GetValue());
ui->disableBackupModArchives->setChecked(UISettings::values.disable_backup_archives.GetValue());
ui->label_version->setText(tr("Compatible Ver:"));
// Hide sorting options as we are forcing a single reliable view of all mods.
ui->sortComboBox->setVisible(false);
SetupConnections();
// Check for cached game ID first
QString cached_id = GameBananaService::GetCachedGameId(title_id);
if (!cached_id.isEmpty()) {
current_game_id = cached_id;
ui->statusLabel->setText(tr("Loading mods..."));
service->FetchModsForGame(cached_id);
} else {
// Auto-search for the game on GameBanana
SearchForGame();
}
}
GameBananaDialog::~GameBananaDialog() = default;
void GameBananaDialog::SetupConnections() {
connect(ui->modList, &QListWidget::currentRowChanged, this, &GameBananaDialog::OnModSelected);
connect(ui->buttonDownload, &QPushButton::clicked, this, &GameBananaDialog::OnDownloadClicked);
connect(ui->buttonCancel, &QPushButton::clicked, this, &GameBananaDialog::OnCancelClicked);
connect(ui->buttonLoadMore, &QPushButton::clicked, this, &GameBananaDialog::OnLoadMoreClicked);
connect(ui->searchEdit, &QLineEdit::returnPressed, this, &GameBananaDialog::OnSearchTriggered);
connect(service, &GameBananaService::GamesFound, this, &GameBananaDialog::OnGamesFound);
connect(service, &GameBananaService::ModsAvailable, this, &GameBananaDialog::PopulateMods);
connect(service, &GameBananaService::ModDetailsReady, this,
&GameBananaDialog::UpdateModDetails);
connect(service, &GameBananaService::DownloadComplete, this, &GameBananaDialog::FinishDownload);
connect(service, &GameBananaService::DownloadProgress, this,
[this](qint64 received, qint64 total) {
if (total > 0) {
ui->progressBar->setValue(static_cast<int>((received * 100) / total));
}
});
connect(service, &GameBananaService::Error, this, [this](const QString& msg) {
ui->statusLabel->setText(tr("Error: %1").arg(msg));
ui->progressBar->setVisible(false);
ui->buttonDownload->setEnabled(true);
});
// Save checkbox states when toggled
connect(ui->alwaysAskManualExtraction, &QCheckBox::toggled, this, [](bool checked) {
UISettings::values.always_ask_manual_extraction.SetValue(checked);
});
connect(ui->disableBackupModArchives, &QCheckBox::toggled, this,
[](bool checked) { UISettings::values.disable_backup_archives.SetValue(checked); });
}
void GameBananaDialog::SearchForGame() {
if (game_name.isEmpty()) {
ui->statusLabel->setText(tr("No game name available to search."));
return;
}
ui->statusLabel->setText(tr("Searching for '%1' on GameBanana...").arg(game_name));
service->SearchGames(game_name);
}
void GameBananaDialog::OnGamesFound(const QVector<GameBananaGame>& games) {
if (games.isEmpty()) {
ui->statusLabel->setText(
tr("No matching game found on GameBanana for '%1'.").arg(game_name));
return;
}
const auto& game = games.first();
current_game_id = game.id;
GameBananaService::CacheGameId(title_id, current_game_id);
ui->statusLabel->setText(tr("Found '%1'. Loading mods...").arg(game.name));
current_page = 1;
current_query.clear();
service->FetchModsForGame(current_game_id, current_page);
}
void GameBananaDialog::OnSearchTriggered() {
current_query = ui->searchEdit->text().trimmed();
current_page = 1;
loaded_mods.clear();
ui->modList->clear();
/* Keep details group visible */
if (current_query.isEmpty()) {
ui->statusLabel->setText(tr("Refreshing mod list..."));
service->FetchModsForGame(current_game_id, current_page);
} else {
ui->statusLabel->setText(tr("Searching for '%1'...").arg(current_query));
service->SearchMods(current_game_id, current_query, current_page);
}
}
void GameBananaDialog::PopulateMods(const QVector<GameBananaMod>& mods, bool has_more) {
if (current_page == 1) {
loaded_mods.clear();
ui->modList->clear();
}
for (const auto& mod : mods) {
QString display = QStringLiteral("%1 by %2").arg(mod.name, mod.submitter);
auto* item = new QListWidgetItem(display);
item->setData(Qt::UserRole, loaded_mods.size());
ui->modList->addItem(item);
loaded_mods.append(mod);
}
ui->statusLabel->setText(tr("Showing %1 mods").arg(loaded_mods.size()));
// Automatic Pagination: Fetch more mods until we have everything available
if (has_more) {
current_page++;
// Map current sort index to string for the Subfeed request
QString sort = QStringLiteral("new");
switch (ui->sortComboBox->currentIndex()) {
case 1:
sort = QStringLiteral("Generic_LatestUpdated");
break;
case 2:
sort = QStringLiteral("Generic_MostDownloaded");
break;
case 3:
sort = QStringLiteral("Generic_MostViewed");
break;
case 4:
sort = QStringLiteral("Generic_MostLiked");
break;
case 5:
sort = QStringLiteral("Generic_HighestRated");
break;
}
if (current_query.isEmpty()) {
service->FetchModsForGame(current_game_id, current_page, sort);
} else {
service->SearchMods(current_game_id, current_query, current_page);
}
} else {
ui->buttonLoadMore->setVisible(false);
}
}
void GameBananaDialog::OnLoadMoreClicked() {
current_page++;
if (current_query.isEmpty()) {
service->FetchModsForGame(current_game_id, current_page, QStringLiteral("new"));
} else {
service->SearchMods(current_game_id, current_query, current_page);
}
}
void GameBananaDialog::OnModSelected() {
int row = ui->modList->currentRow();
if (row < 0 || row >= loaded_mods.size()) {
ui->modNameLabel->setText(tr("Select a Mod"));
ui->modCategoryLabel->clear();
ui->modVersionLabel->clear();
ui->modDownloadsLabel->clear();
ui->modDescriptionBrowser->clear();
ui->screenshotLabel->clear();
ui->statusArchiveLabel->clear();
ui->statusLinkLabel->clear();
return;
}
const auto& mod = loaded_mods[row];
ui->statusLabel->setText(tr("Loading mod details..."));
service->FetchModDetails(mod);
}
void GameBananaDialog::UpdateModDetails(const GameBananaMod& mod) {
selected_mod = mod;
ui->modDetailsGroup->setVisible(true);
ui->modNameLabel->setText(mod.name);
ui->modCategoryLabel->setText(mod.category);
ui->modVersionLabel->setText(mod.version.isEmpty() ? tr("-") : mod.version);
ui->modDownloadsLabel->setText(QString::number(mod.download_count));
ui->statusArchiveLabel->setText(
tr("Archive Type: %1").arg(mod.archive_type.isEmpty() ? tr("-") : mod.archive_type.toUpper()));
QString mod_url = mod.website_url;
if (mod_url.isEmpty()) {
QString item_type_plural = mod.item_type.toLower();
if (item_type_plural == QStringLiteral("mod")) {
item_type_plural = QStringLiteral("mods");
} else {
item_type_plural += QStringLiteral("s");
}
mod_url = QStringLiteral("https://gamebanana.com/%1/%2").arg(item_type_plural, mod.id);
}
ui->statusLinkLabel->setText(
tr("<a href=\"%1\" style=\"color: #3ed194; text-decoration: none;\">Website Link For "
"Mod</a>")
.arg(mod_url));
if (!mod.description.isEmpty()) {
ui->modDescriptionBrowser->setHtml(mod.description);
} else {
ui->modDescriptionBrowser->setPlainText(tr("No description available."));
}
ui->screenshotLabel->clear();
ui->screenshotLabel->setText(tr("Loading preview..."));
if (!mod.screenshots.isEmpty()) {
QString url = mod.screenshots[0];
if (screenshot_cache.contains(url)) {
ui->screenshotLabel->setPixmap(screenshot_cache[url]);
} else {
QNetworkRequest request{QUrl{url}};
request.setHeader(QNetworkRequest::UserAgentHeader,
QStringLiteral("CitronEmulator/1.0"));
QNetworkReply* reply = service->GetNetworkManager()->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply, url]() {
reply->deleteLater();
if (reply->error() == QNetworkReply::NoError) {
QPixmap pixmap;
if (pixmap.loadFromData(reply->readAll())) {
pixmap = pixmap.scaled(ui->screenshotLabel->size(), Qt::KeepAspectRatio,
Qt::SmoothTransformation);
screenshot_cache[url] = pixmap;
if (selected_mod.screenshots.contains(url)) {
ui->screenshotLabel->setPixmap(pixmap);
}
}
}
});
}
} else {
ui->screenshotLabel->setText(tr("No preview available"));
}
ui->buttonDownload->setEnabled(!mod.download_url.isEmpty());
ui->statusLabel->setText(tr("Ready to download"));
}
void GameBananaDialog::OnDownloadClicked() {
if (selected_mod.download_url.isEmpty()) {
QMessageBox::warning(this, tr("Error"), tr("No download URL available for this mod."));
return;
}
StartDownload(selected_mod);
}
void GameBananaDialog::StartDownload(const GameBananaMod& mod) {
ui->progressBar->setVisible(true);
ui->progressBar->setValue(0);
ui->buttonDownload->setEnabled(false);
ui->statusLabel->setText(tr("Downloading %1...").arg(mod.name));
std::filesystem::path mod_folder;
namespace FS = Common::FS;
if (ui->locationComboBox->currentIndex() == 0) {
mod_folder = FS::GetCitronPath(FS::CitronPath::LoadDir) / title_id.toStdString() /
mod.name.toStdString();
} else {
mod_folder = FS::GetCitronPath(FS::CitronPath::SDMCDir) / "atmosphere" / "contents" /
title_id.toStdString();
}
std::filesystem::create_directories(mod_folder);
QString dest_file = QString::fromStdString((mod_folder / mod.file_name.toStdString()).string());
service->DownloadMod(mod.download_url, dest_file);
}
void GameBananaDialog::FinishDownload(const QString& file_path) {
ui->progressBar->setVisible(false);
ui->buttonDownload->setEnabled(true);
// 1. Backup logic
if (!ui->disableBackupModArchives->isChecked()) {
QString backup_dir =
QCoreApplication::applicationDirPath() + QStringLiteral("/citron-mods");
QDir().mkpath(backup_dir);
QString backup_path = backup_dir + QStringLiteral("/") + QFileInfo(file_path).fileName();
if (QFile::copy(file_path, backup_path)) {
LOG_INFO(WebService, "Backed up mod archive to: {}", backup_path.toStdString());
}
}
// 2. Check for archive and extraction
bool is_archive = file_path.endsWith(QStringLiteral(".zip"), Qt::CaseInsensitive) ||
file_path.endsWith(QStringLiteral(".7z"), Qt::CaseInsensitive) ||
file_path.endsWith(QStringLiteral(".rar"), Qt::CaseInsensitive);
if (is_archive) {
bool always_ask = ui->alwaysAskManualExtraction->isChecked();
bool can_extract = ZipExtractor::CanExtract();
if (always_ask || !can_extract) {
QString reason = always_ask ? tr("User requested manual extraction.")
: tr("No system extraction tools (7z/unzip) found.");
ui->statusLabel->setText(tr("Manual extraction required"));
auto reply = QMessageBox::information(
this, tr("Manual Extraction Required"),
tr("%1\n\nThe mod archive has been downloaded to the mod directory. "
"Please manually extract its contents to complete the installation.\n\n"
"Open mod folder now?")
.arg(reason),
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::Yes) {
OpenModFolder(QFileInfo(file_path).absolutePath());
}
return;
}
ui->statusLabel->setText(tr("Extracting mod..."));
QFileInfo file_info(file_path);
QString mod_folder = file_info.absolutePath();
bool success = ZipExtractor::ExtractAndOrganize(file_path, mod_folder);
if (success) {
QFile::remove(file_path); // Only remove if successful
ui->statusLabel->setText(tr("Mod installed successfully!"));
QMessageBox::information(
this, tr("Success"),
tr("The mod '%1' has been installed successfully.").arg(selected_mod.name));
} else {
ui->statusLabel->setText(tr("Extraction failed - Archive preserved"));
auto reply = QMessageBox::warning(this, tr("Extraction Error"),
tr("Failed to extract the mod automatically. The "
"archive has been preserved in the mod directory "
"for manual extraction.\n\nOpen mod folder now?"),
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::Yes) {
OpenModFolder(mod_folder);
}
}
} else {
ui->statusLabel->setText(tr("Mod downloaded successfully!"));
QMessageBox::information(this, tr("Success"),
tr("The mod '%1' has been downloaded.").arg(selected_mod.name));
}
}
void GameBananaDialog::OpenModFolder(const QString& path) {
QDesktopServices::openUrl(QUrl::fromLocalFile(path));
}
void GameBananaDialog::OnCancelClicked() {
service->CancelDownload();
reject();
}
} // namespace ModManager
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <memory>
#include <QDialog>
#include <QMap>
#include <QPixmap>
#include "citron/mod_manager/gamebanana_service.h"
namespace Ui {
class GameBananaDialog;
}
namespace ModManager {
class GameBananaDialog : public QDialog {
Q_OBJECT
public:
explicit GameBananaDialog(const QString& title_id, const QString& game_name,
QWidget* parent = nullptr);
~GameBananaDialog() override;
private slots:
void OnModSelected();
void OnDownloadClicked();
void OnCancelClicked();
void OnLoadMoreClicked();
void OnSearchTriggered();
private:
void SetupConnections();
void SearchForGame();
void OnGamesFound(const QVector<GameBananaGame>& games);
void PopulateMods(const QVector<GameBananaMod>& mods, bool has_more);
void UpdateModDetails(const GameBananaMod& mod);
void StartDownload(const GameBananaMod& mod);
void FinishDownload(const QString& file_path);
void OpenModFolder(const QString& path);
std::unique_ptr<Ui::GameBananaDialog> ui;
GameBananaService* service;
QLabel* archiveTypeLabel;
QString title_id;
QString game_name;
QString current_game_id;
QString current_query;
int current_page = 1;
QVector<GameBananaMod> loaded_mods;
GameBananaMod selected_mod;
QMap<QString, QPixmap> screenshot_cache;
};
} // namespace ModManager
+491
View File
@@ -0,0 +1,491 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>GameBananaDialog</class>
<widget class="QDialog" name="GameBananaDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1000</width>
<height>800</height>
</rect>
</property>
<property name="windowTitle">
<string>GameBanana Mod Browser</string>
</property>
<property name="styleSheet">
<string notr="true">
QDialog {
background-color: #08080a;
color: #e0e0e0;
font-family: 'Inter', 'Roboto', 'Segoe UI', Arial, sans-serif;
}
QLabel {
color: #e0e0e0;
}
QLineEdit {
background-color: #1a1a1c;
border: 1px solid #2a2a2c;
border-radius: 4px;
padding: 5px;
color: #ffffff;
}
QComboBox {
background-color: #1a1a1c;
border: 1px solid #2a2a2c;
border-radius: 4px;
padding: 5px;
color: #ffffff;
}
QListWidget {
background-color: #0c0c0e;
border: 1px solid #2a2a2c;
border-radius: 8px;
padding: 5px;
}
QListWidget::item {
background-color: #161618;
border-radius: 6px;
margin-bottom: 5px;
padding: 10px;
color: #ffffff;
}
QListWidget::item:selected {
background-color: #2a2a2c;
border: 1px solid #3ed194;
}
QPushButton {
background-color: #1a1a1c;
border: 1px solid #2a2a2c;
border-radius: 4px;
padding: 8px 15px;
color: #ffffff;
}
QPushButton:hover {
background-color: #2a2a2c;
}
QPushButton#buttonDownload {
background-color: #3ed194;
color: #000000;
font-weight: bold;
}
QPushButton#buttonDownload:disabled {
background-color: #1a1a1c;
color: #555555;
}
QGroupBox {
border: 1px solid #2a2a2c;
border-radius: 8px;
margin-top: 20px;
padding-top: 15px;
color: #3ed194;
font-weight: bold;
}
QTextBrowser {
background-color: #0c0c0e;
border: 1px solid #2a2a2c;
border-radius: 4px;
color: #bbbbbb;
}
QProgressBar {
border: 1px solid #2a2a2c;
border-radius: 4px;
text-align: center;
background-color: #0c0c0e;
}
QProgressBar::chunk {
background-color: #3ed194;
}
</string>
</property>
<layout class="QVBoxLayout" name="mainLayout">
<property name="spacing">
<number>15</number>
</property>
<property name="leftMargin">
<number>20</number>
</property>
<property name="topMargin">
<number>20</number>
</property>
<property name="rightMargin">
<number>20</number>
</property>
<property name="bottomMargin">
<number>20</number>
</property>
<item>
<widget class="QLabel" name="gameTitleLabel">
<property name="font">
<font>
<pointsize>16</pointsize>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Loading mods for game...</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="contentLayout">
<property name="spacing">
<number>20</number>
</property>
<item>
<layout class="QVBoxLayout" name="leftLayout">
<property name="spacing">
<number>10</number>
</property>
<item>
<layout class="QHBoxLayout" name="searchSortLayout">
<item>
<widget class="QLineEdit" name="searchEdit">
<property name="placeholderText">
<string>Search for mods...</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="sortComboBox">
<property name="minimumSize">
<size>
<width>150</width>
<height>0</height>
</size>
</property>
<item>
<property name="text">
<string>Newest</string>
</property>
</item>
<item>
<property name="text">
<string>Recently Updated</string>
</property>
</item>
<item>
<property name="text">
<string>Most Downloaded</string>
</property>
</item>
<item>
<property name="text">
<string>Most Viewed</string>
</property>
</item>
<item>
<property name="text">
<string>Most Liked</string>
</property>
</item>
<item>
<property name="text">
<string>Highest Rated</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QListWidget" name="modList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonLoadMore">
<property name="text">
<string>Load More Mods...</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="modDetailsGroup">
<property name="minimumSize">
<size>
<width>380</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>400</width>
<height>16777215</height>
</size>
</property>
<property name="title">
<string>Mod Details</string>
</property>
<layout class="QVBoxLayout" name="detailsMainLayout">
<property name="spacing">
<number>12</number>
</property>
<item>
<widget class="QLabel" name="screenshotLabel">
<property name="minimumSize">
<size>
<width>0</width>
<height>200</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">border: 1px solid #2a2a2c; border-radius: 4px; background-color: #000000;</string>
</property>
<property name="text">
<string/>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="modNameLabel">
<property name="font">
<font>
<pointsize>12</pointsize>
<bold>true</bold>
</font>
</property>
<property name="styleSheet">
<string notr="true">color: #ffffff;</string>
</property>
<property name="text">
<string>Select a Mod</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QFormLayout" name="detailsFormLayout">
<property name="horizontalSpacing">
<number>20</number>
</property>
<property name="verticalSpacing">
<number>8</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_date">
<property name="styleSheet">
<string notr="true">color: #888888;</string>
</property>
<property name="text">
<string>Updated:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="modCategoryLabel">
<property name="text">
<string>-</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_version">
<property name="styleSheet">
<string notr="true">color: #888888;</string>
</property>
<property name="text">
<string>Compatible Ver:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="modVersionLabel">
<property name="styleSheet">
<string notr="true">color: #3ed194;</string>
</property>
<property name="text">
<string>-</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_downloads">
<property name="styleSheet">
<string notr="true">color: #888888;</string>
</property>
<property name="text">
<string>Downloads:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="modDownloadsLabel">
<property name="text">
<string>-</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_description">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Description</string>
</property>
</widget>
</item>
<item>
<widget class="QTextBrowser" name="modDescriptionBrowser">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="locationLayout">
<item>
<widget class="QComboBox" name="locationComboBox">
<item>
<property name="text">
<string>Load Directory</string>
</property>
</item>
<item>
<property name="text">
<string>Atmosphere Contents</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QProgressBar" name="progressBar">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>8</height>
</size>
</property>
<property name="value">
<number>0</number>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="footerLayout">
<item>
<widget class="QLabel" name="statusLabel">
<property name="text">
<string>Ready</string>
</property>
<property name="styleSheet">
<string notr="true">color: #666666;</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="statusArchiveLabel">
<property name="text">
<string/>
</property>
<property name="styleSheet">
<string notr="true">color: #999999; font-weight: bold; margin-left: 15px;</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="statusLinkLabel">
<property name="text">
<string/>
</property>
<property name="styleSheet">
<string notr="true">color: #3ed194; margin-left: 15px;</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="footerSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QCheckBox" name="alwaysAskManualExtraction">
<property name="text">
<string>Manual Extract</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="disableBackupModArchives">
<property name="text">
<string>No Backup</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonDownload">
<property name="minimumSize">
<size>
<width>150</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Install Mod</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonCancel">
<property name="text">
<string>Close</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,491 @@
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <QSettings>
#include <QStandardPaths>
#include <QUrl>
#include <QUrlQuery>
#include "citron/mod_manager/gamebanana_service.h"
#include "common/logging.h"
namespace ModManager {
GameBananaService::GameBananaService(QObject* parent)
: QObject(parent), network_manager(new QNetworkAccessManager(this)), current_download(nullptr),
is_fallback_search(false) {
}
GameBananaService::~GameBananaService() {
CancelDownload();
}
void GameBananaService::SearchGames(const QString& query) {
// Sanitize query by removing special characters like ™, ® and © which break matching.
QString sanitized_query = query;
sanitized_query.remove(QRegularExpression(QStringLiteral("[™®©℠]")));
sanitized_query = sanitized_query.trimmed();
LOG_DEBUG(WebService, "Searching GameBanana for game: {} (Sanitized from: {})",
sanitized_query.toStdString(), query.toStdString());
current_game_query = sanitized_query;
is_fallback_search = false;
QUrl url(QString::fromLatin1(API_BASE) + QStringLiteral("/Util/Game/NameMatch"));
QUrlQuery url_query;
url_query.addQueryItem(QStringLiteral("_sName"), sanitized_query);
url.setQuery(url_query);
QNetworkRequest request{url};
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("CitronEmulator/1.0"));
QNetworkReply* reply = network_manager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit Error(tr("Failed to search GameBanana: %1").arg(reply->errorString()));
return;
}
ParseGamesResponse(reply->readAll());
});
}
void GameBananaService::FetchModsForGame(const QString& game_id, int page, const QString& sort) {
LOG_DEBUG(WebService, "Fetching mods for game: {} page: {} sort: {}", game_id.toStdString(),
page, sort.toStdString());
// Switch to the modern Subfeed endpoint used by the website's main listing.
// Filtering by _sModelName=Mod ensures we only get mods and avoids Tutorial/Guide ID conflicts.
QUrl url(QString::fromLatin1(API_BASE) + QStringLiteral("/Game/") + game_id +
QStringLiteral("/Subfeed"));
QUrlQuery url_query;
url_query.addQueryItem(QStringLiteral("_nPage"), QString::number(page));
url_query.addQueryItem(QStringLiteral("_sModelName"), QStringLiteral("Mod"));
// NOTE: _sModelName is ignored by the Subfeed API, so we MUST filter records manually.
url_query.addQueryItem(QStringLiteral("_sSort"), QStringLiteral("new"));
url.setQuery(url_query);
QNetworkRequest request{url};
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("CitronEmulator/1.0"));
LOG_DEBUG(WebService, "Request URL: {}", url.toString().toStdString());
QNetworkReply* reply = network_manager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit Error(tr("Failed to fetch mods: %1").arg(reply->errorString()));
return;
}
ParseModsResponse(reply->readAll());
});
}
void GameBananaService::SearchMods(const QString& game_id, const QString& query, int page) {
// Util/Search/Results is required for actual text searching.
// It strictly mandates _sSearchString even if it were empty (which it isn't here).
QUrl url(QString::fromLatin1(API_BASE) + QStringLiteral("/Util/Search/Results"));
QUrlQuery url_query;
url_query.addQueryItem(QStringLiteral("_sSearchString"), query);
url_query.addQueryItem(QStringLiteral("_idGameRow"), game_id);
url_query.addQueryItem(QStringLiteral("_sModelName"), QStringLiteral("Mod"));
url_query.addQueryItem(QStringLiteral("_nPage"), QString::number(page));
url.setQuery(url_query);
QNetworkRequest request{url};
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("CitronEmulator/1.0"));
QNetworkReply* reply = network_manager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit Error(tr("Failed to search mods: %1").arg(reply->errorString()));
return;
}
ParseModsResponse(reply->readAll());
});
}
void GameBananaService::FetchModDetails(const GameBananaMod& mod) {
if (mod.id.isEmpty())
return;
// Use the specific item type endpoint to fetch the correct details and avoid fallbacks.
QUrl url(QString::fromLatin1(API_BASE) + QStringLiteral("/") + mod.item_type +
QStringLiteral("/") + mod.id);
QUrlQuery url_query;
// Customize properties by item type to avoid 400 Bad Request errors.
// "Mod" type supports _tsDateUpdated and _nDownloadCount.
// "Request" and "Question" types do NOT support these and will 400 if requested.
QString properties = QStringLiteral(
"_sName,_sText,_sProfileUrl,_aPreviewMedia,_aSubmitter,_tsDateAdded,_nViewCount");
if (mod.item_type == QStringLiteral("Mod")) {
properties += QStringLiteral(
",_tsDateUpdated,_sDescription,_sVersion,_aFiles,_aCategory,_nDownloadCount");
}
url_query.addQueryItem(QStringLiteral("_csvProperties"), properties);
url.setQuery(url_query);
QNetworkRequest request{url};
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("CitronEmulator/1.0"));
QNetworkReply* reply = network_manager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit Error(tr("Failed to fetch mod details: %1").arg(reply->errorString()));
return;
}
ParseModDetailsResponse(reply->readAll());
});
}
void GameBananaService::DownloadMod(const QString& download_url, const QString& dest_path) {
CancelDownload();
current_download_path = dest_path;
QNetworkRequest request{QUrl{download_url}};
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("CitronEmulator/1.0"));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
current_download = network_manager->get(request);
connect(current_download, &QNetworkReply::downloadProgress, this,
[this](qint64 received, qint64 total) { emit DownloadProgress(received, total); });
connect(current_download, &QNetworkReply::finished, this, [this]() {
if (!current_download)
return;
current_download->deleteLater();
if (current_download->error() != QNetworkReply::NoError) {
emit Error(tr("Download failed: %1").arg(current_download->errorString()));
current_download = nullptr;
return;
}
QDir().mkpath(QFileInfo(current_download_path).absolutePath());
QFile file(current_download_path);
if (file.open(QIODevice::WriteOnly)) {
file.write(current_download->readAll());
file.close();
emit DownloadComplete(current_download_path);
} else {
emit Error(tr("Failed to save file: %1").arg(current_download_path));
}
current_download = nullptr;
});
}
void GameBananaService::CancelDownload() {
if (current_download) {
current_download->abort();
current_download->deleteLater();
current_download = nullptr;
}
}
QString GameBananaService::GetCachedGameId(const QString& title_id) {
QSettings settings;
settings.beginGroup(QStringLiteral("GameBanana"));
QString result = settings.value(title_id).toString();
settings.endGroup();
return result;
}
void GameBananaService::CacheGameId(const QString& title_id, const QString& game_id) {
QSettings settings;
settings.beginGroup(QStringLiteral("GameBanana"));
settings.setValue(title_id, game_id);
settings.endGroup();
}
void GameBananaService::ParseGamesResponse(const QByteArray& data) {
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) {
emit Error(tr("Invalid search response from GameBanana"));
return;
}
QJsonObject root = doc.object();
QJsonArray arr = root[QStringLiteral("_aRecords")].toArray();
QVector<GameBananaGame> games;
for (const QJsonValue& val : arr) {
if (!val.isObject())
continue;
QJsonObject obj = val.toObject();
GameBananaGame game;
game.id = obj[QStringLiteral("_idRow")].toVariant().toString();
game.name = obj[QStringLiteral("_sName")].toString();
game.icon_url = obj[QStringLiteral("_sIconUrl")].toString();
game.mod_count = obj[QStringLiteral("_nModCount")].toInt();
if (!game.id.isEmpty() && !game.name.isEmpty()) {
games.append(game);
}
}
if (games.isEmpty() && !is_fallback_search && current_game_query.contains(QLatin1Char(' '))) {
// Fallback: Try searching for the game without the last word (e.g. "Mario Kart 8 Deluxe" -> "Mario Kart 8")
QString fallback_query = current_game_query.section(QLatin1Char(' '), 0, -2);
if (!fallback_query.isEmpty()) {
LOG_DEBUG(WebService, "No results for '{}', trying fallback: '{}'",
current_game_query.toStdString(), fallback_query.toStdString());
is_fallback_search = true;
QUrl url(QString::fromLatin1(API_BASE) + QStringLiteral("/Util/Game/NameMatch"));
QUrlQuery url_query;
url_query.addQueryItem(QStringLiteral("_sName"), fallback_query);
url.setQuery(url_query);
QNetworkRequest request{url};
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("CitronEmulator/1.0"));
QNetworkReply* reply = network_manager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() == QNetworkReply::NoError) {
ParseGamesResponse(reply->readAll());
} else {
emit GamesFound({});
}
});
return;
}
}
emit GamesFound(games);
}
void GameBananaService::ParseModsResponse(const QByteArray& data) {
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) {
emit Error(tr("Invalid mods response from GameBanana"));
return;
}
QJsonObject root = doc.object();
QJsonObject metadata = root[QStringLiteral("_aMetadata")].toObject();
QJsonArray records = root[QStringLiteral("_aRecords")].toArray();
// Handle pagination for both Subfeed and Search endpoints
bool has_more = false;
if (metadata.contains(QStringLiteral("_bIsComplete"))) {
has_more = !metadata[QStringLiteral("_bIsComplete")].toBool();
} else {
int total_records = 0;
if (metadata.contains(QStringLiteral("_nTotalTotalRecords"))) {
total_records = metadata[QStringLiteral("_nTotalTotalRecords")].toInt();
} else if (metadata.contains(QStringLiteral("_nRecordCount"))) {
total_records = metadata[QStringLiteral("_nRecordCount")].toInt();
}
const int per_page = metadata[QStringLiteral("_nPerpage")].toInt();
const int current_page = metadata[QStringLiteral("_nPage")].toInt();
if (per_page > 0) {
has_more = (current_page * per_page) < total_records;
}
}
QVector<GameBananaMod> mods;
for (const QJsonValue& val : records) {
if (!val.isObject())
continue;
QJsonObject obj = val.toObject();
GameBananaMod mod;
// Use toVariant().toString() to prevent any conversion issues with IDs
if (obj.contains(QStringLiteral("_idRow"))) {
mod.id = obj[QStringLiteral("_idRow")].toVariant().toString();
} else if (obj.contains(QStringLiteral("_idRowEntity"))) {
mod.id = obj[QStringLiteral("_idRowEntity")].toVariant().toString();
} else if (obj.contains(QStringLiteral("id"))) {
mod.id = obj[QStringLiteral("id")].toVariant().toString();
}
mod.item_type = obj[QStringLiteral("_sModelName")].toString();
// Use a blacklist approach for mod discovery. Standard mods use 'Mod', but Switch
// games often have 'Sound', 'Skin', 'Map', or game-specific models.
// We only skip types that are definitely NOT mods (support threads, members, etc).
if (!mod.item_type.isEmpty()) {
static const QStringList blacklist = {
QStringLiteral("Question"), QStringLiteral("Request"), QStringLiteral("Member"),
QStringLiteral("Club"), QStringLiteral("Tutorial"), QStringLiteral("Idea"),
QStringLiteral("Poll"), QStringLiteral("Article"), QStringLiteral("ForumThread"),
QStringLiteral("WikiPage"), QStringLiteral("Concept"), QStringLiteral("BugReport")};
if (blacklist.contains(mod.item_type)) {
continue;
}
}
if (mod.item_type.isEmpty()) {
mod.item_type = QStringLiteral("Mod");
}
mod.name = obj[QStringLiteral("_sName")].toString();
// Use Views or Download count for the list preview
mod.download_count = obj.contains(QStringLiteral("_nViewCount"))
? obj[QStringLiteral("_nViewCount")].toInt()
: obj[QStringLiteral("_nDownloadCount")].toInt();
// Get submitter info
if (obj.contains(QStringLiteral("_aSubmitter"))) {
QJsonObject submitter = obj[QStringLiteral("_aSubmitter")].toObject();
mod.submitter = submitter[QStringLiteral("_sName")].toString();
}
// Get category
if (obj.contains(QStringLiteral("_aRootCategory"))) {
QJsonObject category = obj[QStringLiteral("_aRootCategory")].toObject();
mod.category = category[QStringLiteral("_sName")].toString();
} else if (obj.contains(QStringLiteral("_aCategory"))) {
QJsonObject category = obj[QStringLiteral("_aCategory")].toObject();
mod.category = category[QStringLiteral("_sName")].toString();
}
// Get thumbnail
if (obj.contains(QStringLiteral("_aPreviewMedia"))) {
QJsonObject preview = obj[QStringLiteral("_aPreviewMedia")].toObject();
QJsonArray images = preview[QStringLiteral("_aImages")].toArray();
if (!images.isEmpty()) {
QJsonObject img = images[0].toObject();
QString base_url = img[QStringLiteral("_sBaseUrl")].toString();
QString file = img[QStringLiteral("_sFile100")].toString();
if (file.isEmpty()) {
file = img[QStringLiteral("_sFile")].toString();
}
mod.thumbnail_url = base_url + QStringLiteral("/") + file;
}
}
if (obj.contains(QStringLiteral("_sProfileUrl"))) {
mod.website_url = obj[QStringLiteral("_sProfileUrl")].toString();
}
mods.append(mod);
}
emit ModsAvailable(mods, has_more);
}
void GameBananaService::ParseModDetailsResponse(const QByteArray& data) {
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) {
emit Error(tr("Invalid mod details response"));
return;
}
QJsonObject obj = doc.object();
GameBananaMod mod;
mod.id = obj[QStringLiteral("_idRow")].toVariant().toString();
mod.name = obj[QStringLiteral("_sName")].toString();
mod.website_url = obj[QStringLiteral("_sProfileUrl")].toString();
mod.version = obj[QStringLiteral("_sVersion")].toString();
// Switch Game Version Detection Heuristic
static QRegularExpression version_regex(QStringLiteral("(\\d+\\.\\d+\\.\\d+)"));
QString potential_game_version;
QRegularExpressionMatch name_match = version_regex.match(mod.name);
if (name_match.hasMatch()) {
potential_game_version = name_match.captured(1);
} else {
QRegularExpression desc_regex(
QStringLiteral(
"(?:update|version|compat|works with)\\s*[:\\-]?\\s*(\\d+\\.\\d+\\.\\d+)"),
QRegularExpression::CaseInsensitiveOption);
QRegularExpressionMatch desc_match =
desc_regex.match(obj[QStringLiteral("_sText")].toString());
if (desc_match.hasMatch()) {
potential_game_version = desc_match.captured(1);
}
}
if (!potential_game_version.isEmpty()) {
mod.version = potential_game_version;
} else if (mod.version.isEmpty() || mod.version == QStringLiteral("N/A")) {
mod.version = tr("(Unknown: May not be compatible with specific updates)");
}
if (obj.contains(QStringLiteral("_tsDateUpdated"))) {
qint64 ts = obj[QStringLiteral("_tsDateUpdated")].toVariant().toLongLong();
if (ts > 0) {
mod.category = QDateTime::fromSecsSinceEpoch(ts).toString(QStringLiteral("yyyy-MM-dd"));
}
} else if (obj.contains(QStringLiteral("_tsDateAdded"))) {
qint64 ts = obj[QStringLiteral("_tsDateAdded")].toVariant().toLongLong();
if (ts > 0) {
mod.category = QDateTime::fromSecsSinceEpoch(ts).toString(QStringLiteral("yyyy-MM-dd"));
}
}
// Prefer _sDescription if available, fallback to _sText (which Requests/Questions use)
mod.description = obj[QStringLiteral("_sDescription")].toString();
if (mod.description.isEmpty()) {
mod.description = obj[QStringLiteral("_sText")].toString();
}
mod.download_count = obj[QStringLiteral("_nDownloadCount")].toInt();
// Get submitter
if (obj.contains(QStringLiteral("_aSubmitter"))) {
QJsonObject submitter = obj[QStringLiteral("_aSubmitter")].toObject();
mod.submitter = submitter[QStringLiteral("_sName")].toString();
}
// Get the first downloadable file
if (obj.contains(QStringLiteral("_aFiles"))) {
QJsonArray files = obj[QStringLiteral("_aFiles")].toArray();
for (const QJsonValue& file_val : files) {
QJsonObject file_obj = file_val.toObject();
mod.file_name = file_obj[QStringLiteral("_sFile")].toString();
mod.archive_type = QFileInfo(mod.file_name).suffix().toLower();
mod.download_url = file_obj[QStringLiteral("_sDownloadUrl")].toString();
mod.file_size = file_obj[QStringLiteral("_nFilesize")].toVariant().toLongLong();
break;
}
}
// Get preview images
if (obj.contains(QStringLiteral("_aPreviewMedia"))) {
QJsonObject media = obj[QStringLiteral("_aPreviewMedia")].toObject();
QJsonArray images = media[QStringLiteral("_aImages")].toArray();
for (const QJsonValue& img_val : images) {
QJsonObject img_obj = img_val.toObject();
QString base_url = img_obj[QStringLiteral("_sBaseUrl")].toString();
QString file = img_obj[QStringLiteral("_sFile")].toString();
mod.screenshots.append(base_url + QStringLiteral("/") + file);
}
}
emit ModDetailsReady(mod);
}
} // namespace ModManager
@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QNetworkAccessManager>
#include <QObject>
#include <QString>
#include <QVector>
namespace ModManager {
struct GameBananaMod {
QString id;
QString item_type;
QString name;
QString submitter;
QString category;
QString version;
QString updated_date;
QString description;
QString download_url;
QString file_name;
qint64 file_size;
int download_count;
QString archive_type;
QString thumbnail_url;
QStringList screenshots;
QString website_url;
};
struct GameBananaGame {
QString id;
QString name;
QString icon_url;
int mod_count;
};
class GameBananaService : public QObject {
Q_OBJECT
public:
explicit GameBananaService(QObject* parent = nullptr);
~GameBananaService() override;
// Search for games by name
void SearchGames(const QString& query);
// Fetch mods for a specific game ID
void FetchModsForGame(const QString& game_id, int page = 1,
const QString& sort = QStringLiteral("default"));
// Get detailed download info for a specific mod
void FetchModDetails(const GameBananaMod& mod);
// Search for mods within a specific game
void SearchMods(const QString& game_id, const QString& query, int page = 1);
// Download a mod file to a specific path
void DownloadMod(const QString& download_url, const QString& dest_path);
// Cancel any ongoing download
void CancelDownload();
// Get access to network manager for images
QNetworkAccessManager* GetNetworkManager() const {
return network_manager;
}
// Get cached game ID for a title (returns empty if not cached)
static QString GetCachedGameId(const QString& title_id);
// Cache a game ID for a title
static void CacheGameId(const QString& title_id, const QString& game_id);
signals:
void GamesFound(const QVector<GameBananaGame>& games);
void ModsAvailable(const QVector<GameBananaMod>& mods, bool has_more);
void ModDetailsReady(const GameBananaMod& mod);
void DownloadProgress(qint64 bytes_received, qint64 bytes_total);
void DownloadComplete(const QString& file_path);
void Error(const QString& message);
private:
void ParseGamesResponse(const QByteArray& data);
void ParseModsResponse(const QByteArray& data);
void ParseModDetailsResponse(const QByteArray& data);
QNetworkAccessManager* network_manager;
QNetworkReply* current_download;
QString current_download_path;
QString current_game_query;
bool is_fallback_search;
static constexpr const char* API_BASE = "https://gamebanana.com/apiv11";
};
} // namespace ModManager
@@ -1,160 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QFile>
#include <QDir>
#include <QMessageBox>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QListWidgetItem>
#include <QInputDialog>
#include <QProcess>
#include <filesystem>
#include <set>
#include "citron/mod_manager/mod_downloader_dialog.h"
#include "ui_mod_downloader_dialog.h"
#include "common/fs/path_util.h"
#include "common/logging.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"));
for (auto const& [version, patches] : mod_info.version_patches) {
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);
std::set<QString> seen;
for (const auto& patch : patches) {
if (seen.count(patch.name)) continue;
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);
seen.insert(patch.name);
}
}
ui->treeWidget->expandAll();
}
void ModDownloaderDialog::OnDownloadClicked() {
pending_downloads.clear();
QString os_target;
#ifdef _WIN32
os_target = QStringLiteral("exe");
#elif __APPLE__
os_target = QStringLiteral("zip");
#else
os_target = QStringLiteral("AppImage");
#endif
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
QTreeWidgetItem* version_node = ui->treeWidget->topLevelItem(i);
QString v_str = version_node->text(0).replace(QStringLiteral("Update "), QStringLiteral(""));
for (int j = 0; j < version_node->childCount(); ++j) {
QTreeWidgetItem* mod_node = version_node->child(j);
if (mod_node->checkState(0) != Qt::Checked) continue;
const auto& patches = mod_info.version_patches[v_str];
for (auto p : patches) {
if (p.name == mod_node->text(0)) {
if (p.type == QStringLiteral("tool")) {
QStringList filtered;
for (const QString& f : p.files) {
if (f.endsWith(os_target, Qt::CaseInsensitive)) filtered << f;
}
if (filtered.size() > 1) {
bool ok;
QString choice = QInputDialog::getItem(this, QStringLiteral("Select Architecture"),
QStringLiteral("Choose your system type:"), filtered, 0, false, &ok);
if (ok && !choice.isEmpty()) p.files = {choice};
else continue;
} else { p.files = filtered; }
}
pending_downloads.push_back({p, v_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 items installed."));
accept();
return;
}
const auto& task = pending_downloads[current_download_index];
if (current_file_index >= task.patch.files.size()) {
current_download_index++;
current_file_index = 0;
StartNextDownload();
return;
}
QString file_val = task.patch.files[current_file_index];
QUrl url = (task.patch.type == QStringLiteral("tool")) ? QUrl(file_val) :
QUrl(QStringLiteral("https://raw.githubusercontent.com/citron-neo/Citron-Mods/main/%1/%2")
.arg(task.patch.rel_path).arg(file_val));
QString fileName = file_val.contains(u'/') ? file_val.split(u'/').last() : file_val;
current_reply = network_manager->get(QNetworkRequest(url));
connect(current_reply, &QNetworkReply::finished, this, [this, task, fileName]() {
if (current_reply->error() == QNetworkReply::NoError) {
std::filesystem::path base = Common::FS::GetCitronPath(Common::FS::CitronPath::LoadDir);
std::filesystem::path final_path = base / mod_info.title_id.toStdString();
if (task.patch.type == QStringLiteral("tool")) {
final_path = Common::FS::GetCitronPath(Common::FS::CitronPath::ConfigDir) / "tools";
} else {
final_path /= task.version.toStdString();
final_path /= task.patch.name.toStdString();
final_path /= task.patch.type.toStdString();
}
std::filesystem::create_directories(final_path);
QString full_save_path = QString::fromStdString((final_path / fileName.toStdString()).string());
QFile file(full_save_path);
if (file.open(QIODevice::WriteOnly)) {
file.write(current_reply->readAll());
file.close();
if (fileName.endsWith(QStringLiteral(".zip"))) {
QProcess::execute(QStringLiteral("unzip"), {full_save_path, QStringLiteral("-d"), QString::fromStdString(final_path.string())});
}
#ifndef _WIN32
std::filesystem::permissions(final_path / fileName.toStdString(),
std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec, std::filesystem::perm_options::add);
#endif
}
}
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
@@ -1,49 +0,0 @@
// 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
@@ -1,70 +0,0 @@
<?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>

Some files were not shown because too many files have changed in this diff Show More