mirror of
https://github.com/citron-neo/emulator.git
synced 2026-07-05 15:21:57 -07:00
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user