diff --git a/src/citron/CMakeLists.txt b/src/citron/CMakeLists.txt index 5f84b54951..59dee78871 100644 --- a/src/citron/CMakeLists.txt +++ b/src/citron/CMakeLists.txt @@ -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() diff --git a/src/citron/configuration/configure_per_game_addons.cpp b/src/citron/configuration/configure_per_game_addons.cpp index 96c6a4e85e..54f651a321 100644 --- a/src/citron/configuration/configure_per_game_addons.cpp +++ b/src/citron/configuration/configure_per_game_addons.cpp @@ -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()}, 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) { diff --git a/src/citron/configuration/configure_per_game_addons.h b/src/citron/configuration/configure_per_game_addons.h index 5ae41f184e..78a3df3222 100644 --- a/src/citron/configuration/configure_per_game_addons.h +++ b/src/citron/configuration/configure_per_game_addons.h @@ -11,7 +11,6 @@ #include #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; - ModManager::ModService* mod_service; - ModManager::ModUpdateInfo cached_mod_info; FileSys::VirtualFile file; u64 title_id; diff --git a/src/citron/configuration/configure_per_game_addons.ui b/src/citron/configuration/configure_per_game_addons.ui index d5714217f1..3385e32ba7 100644 --- a/src/citron/configuration/configure_per_game_addons.ui +++ b/src/citron/configuration/configure_per_game_addons.ui @@ -31,7 +31,7 @@ Citron uses third-party created mods that are verified safe to use. All credits for mods go to their respective creators. - Download Enhancement Mods for Game + Download Mods VIA Gamebanana diff --git a/src/citron/game_list.cpp b/src/citron/game_list.cpp index cb07b61af6..32256c371d 100644 --- a/src/citron/game_list.cpp +++ b/src/citron/game_list.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -50,11 +51,11 @@ #include #include #include +#include #include #include #include #include -#include #include #include #include "citron/compatibility_list.h" @@ -68,6 +69,7 @@ #include "citron/game_list_p.h" #include "citron/game_list_worker.h" #include "citron/main.h" +#include "citron/mod_manager/gamebanana_dialog.h" #include "citron/ui/game_carousel_view.h" #include "citron/ui/game_grid_view.h" #include "citron/ui/game_tree_view.h" @@ -79,6 +81,7 @@ #include "citron/util/controller_navigation.h" #include "citron/util/plinko_widget.h" #include "common/common_types.h" +#include "common/fs/fs.h" #include "common/fs/path_util.h" #include "common/logging.h" #include "common/settings.h" @@ -994,6 +997,9 @@ GameList::GameList(std::shared_ptr vfs_, grid_view->view()->setContextMenuPolicy(Qt::CustomContextMenu); carousel_view->view()->setContextMenuPolicy(Qt::CustomContextMenu); + // Load the persistent index instantly to provide a console-grade experience + LoadGameListIndex(); + connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu); connect(grid_view->view(), &QListView::customContextMenuRequested, this, &GameList::PopupContextMenu); @@ -1003,9 +1009,6 @@ GameList::GameList(std::shared_ptr vfs_, item_model->insertColumns(0, COLUMN_COUNT); RetranslateUI(); - item_model->insertColumns(0, COLUMN_COUNT); - RetranslateUI(); - item_model->setSortRole(GameListItemPath::SortRole); // Apply modernized delegate @@ -1013,12 +1016,10 @@ GameList::GameList(std::shared_ptr vfs_, tree_view->setIndentation(0); tree_view->setRootIsDecorated(true); - // Always default to List View on launch as requested + // Always default to List View on launch SetViewMode(ViewMode::List); connect(main_window, &GMainWindow::UpdateThemedIcons, this, &GameList::OnUpdateThemedIcons); - connect(tree_view, &QTreeView::activated, this, &GameList::ValidateEntry); - connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu); connect(tree_view, &QTreeView::expanded, this, &GameList::OnItemExpanded); connect(tree_view, &QTreeView::collapsed, this, &GameList::OnItemExpanded); // Sync sort button with Name column header sort order @@ -1611,25 +1612,146 @@ void GameList::WorkerEvent() { } void GameList::AddDirEntry(GameListDir* entry_items) { + if (!entry_items) + return; + + const QString new_path = entry_items->data(GameListDir::FullPathRole).toString(); + + // Check if we already have this directory folder in the root to prevent duplicates + for (int i = 0; i < item_model->rowCount(); ++i) { + QStandardItem* existing = item_model->item(i, 0); + if (existing && existing->data(GameListItem::TypeRole).value() == + entry_items->data(GameListItem::TypeRole).value()) { + if (existing->data(GameListDir::FullPathRole).toString() == new_path) { + // We already have this folder. The worker will fill its children via AddEntry. + delete entry_items; + return; + } + } + } + item_model->invisibleRootItem()->appendRow(entry_items); tree_view->setExpanded( entry_items->index(), UISettings::values.game_dirs[entry_items->data(GameListDir::GameDirRole).toInt()].expanded); } -void GameList::AddEntry(const QList& entry_items, GameListDir* parent) { - parent->appendRow(entry_items); +void GameList::AddEntry(const QList& entry_items, const QString& parent_path) { + if (entry_items.isEmpty()) + return; - // Register the new index for bubble animation immediately - auto* delegate = qobject_cast(tree_view->itemDelegate()); - if (delegate) { - delegate->RegisterEntryAnimation(entry_items.first()->index()); + // Find the parent directory item in the model by its path. + GameListDir* parent = nullptr; + for (int i = 0; i < item_model->rowCount(); ++i) { + QStandardItem* item = item_model->item(i, 0); + if (item && item->data(GameListDir::FullPathRole).toString() == parent_path) { + parent = static_cast(item); + break; + } } - // Auto-scroll to show new games as they appear - if (loading_overlay && loading_overlay->isVisible() && tree_view) { + if (!parent) { + // Parent not found (might have been removed or not added yet). + // For safety, cleanup memory. + for (auto* item : entry_items) + delete item; + return; + } + + // Delta Update: Check if the game already exists in this folder to prevent duplicates + // and instead update the metadata (like play-time) in place. + const auto* path_item = static_cast(entry_items.at(COLUMN_NAME)); + const QString new_path = path_item->data(GameListItemPath::FullPathRole).toString(); + const u64 program_id = path_item->data(GameListItemPath::ProgramIdRole).toULongLong(); + bool found = false; + + for (int i = 0; i < parent->rowCount(); ++i) { + auto* existing_path_item = static_cast(parent->child(i, COLUMN_NAME)); + if (existing_path_item && + (existing_path_item->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id || + existing_path_item->data(GameListItemPath::FullPathRole).toString() == new_path)) { + // Update existing row columns + for (int col = 0; col < entry_items.size(); ++col) { + QStandardItem* existing_col = parent->child(i, col); + QStandardItem* new_col = entry_items.at(col); + if (existing_col && new_col) { + existing_col->setText(new_col->text()); + existing_col->setData(new_col->data(Qt::DisplayRole), Qt::DisplayRole); + existing_col->setData(new_col->data(GameListItem::SortRole), + GameListItem::SortRole); + + // Specific roles for GameListItemPath + if (col == 0) { + existing_col->setData(new_col->data(Qt::DecorationRole), + Qt::DecorationRole); + existing_col->setData(new_col->data(GameListItemPath::HighResIconRole), + GameListItemPath::HighResIconRole); + } + + // Clear refreshing state on the play-time column once updated + if (col == COLUMN_PLAY_TIME) { + existing_col->setData(false, GameListItem::IsRefreshingRole); + existing_col->setData(new_col->data(GameListItemPlayTime::PlayTimeRole), + GameListItemPlayTime::PlayTimeRole); + } + } + } + found = true; + break; + } + } + + if (!found) { + parent->appendRow(entry_items); + // Register the new index for bubble animation immediately + auto* delegate = qobject_cast(tree_view->itemDelegate()); + if (delegate) { + delegate->RegisterEntryAnimation(entry_items.first()->index()); + } + } + + // Auto-scroll to show new games as they appear (only on first-time or full rebuild) + if (loading_overlay && loading_overlay->isVisible() && tree_view && !found) { tree_view->scrollTo(entry_items.first()->index(), QAbstractItemView::PositionAtBottom); } + + // Clean up temporary entry_items if we merged them + if (found) { + qDeleteAll(entry_items); + } +} + +void GameList::RefreshGame(u64 program_id, u64 play_time) { + if (program_id == 0) + return; + + for (int i = 0; i < item_model->rowCount(); ++i) { + QStandardItem* folder = item_model->item(i, 0); + if (!folder) + continue; + + for (int j = 0; j < folder->rowCount(); ++j) { + auto* path_item = static_cast(folder->child(j, COLUMN_NAME)); + if (path_item && path_item->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { + QStandardItem* play_time_item = folder->child(j, COLUMN_PLAY_TIME); + if (play_time_item) { + play_time_item->setData(static_cast(play_time), GameListItemPlayTime::PlayTimeRole); + play_time_item->setData(false, GameListItem::IsRefreshingRole); + } + return; + } + } + } +} + +void GameList::ClearLaunchOverlays() { + m_is_launching = false; + if (fade_overlay) { + fade_overlay->hide(); + } + if (loading_overlay) { + loading_overlay->FadeOut(); + } } void GameList::UpdateOnlineStatus() { @@ -1730,9 +1852,16 @@ void GameList::OnOnlineStatusUpdated(const std::map>& o } void GameList::StartLaunchAnimation(const QModelIndex& item) { - const QString file_path = item.data(GameListItemPath::FullPathRole).toString(); - if (file_path.isEmpty()) + if (m_is_launching || !item.isValid() || !item.model()) { return; + } + + const QString file_path = item.data(GameListItemPath::FullPathRole).toString(); + if (file_path.isEmpty()) { + return; + } + + m_is_launching = true; u64 program_id = item.data(GameListItemPath::ProgramIdRole).toULongLong(); QStandardItem* original_item = nullptr; @@ -1769,6 +1898,7 @@ void GameList::StartLaunchAnimation(const QModelIndex& item) { if (icon.isNull()) { const auto title_id = item.data(GameListItemPath::ProgramIdRole).toULongLong(); emit GameChosen(file_path, title_id); + m_is_launching = false; return; } @@ -1913,19 +2043,39 @@ void GameList::StartLaunchAnimation(const QModelIndex& item) { main_group->addPause(400); // Shorter pause before final effect main_group->addAnimation(final_fly_fade); - // When the animation finishes, launch the game and clean up. + // When the zoom animation is done, we already know the core can start booting + // in the background while the rest of the animation plays out. + // The game core will be signaled to boot when the FULL animation finishes. + // This prevents the core's loading screen from overlapping with our pre-launch effects. + + // When the FULL animation group finishes, clean up the UI overlays safely. connect(main_group, &QSequentialAnimationGroup::finished, this, - [this, file_path, title_id, animation_label, logo_widget]() { + [this, animation_label, logo_widget, file_path, title_id]() { + if (animation_label) { + animation_label->hide(); + animation_label->deleteLater(); + } + if (logo_widget) { + logo_widget->hide(); + logo_widget->deleteLater(); + } + + if (fade_overlay) { + fade_overlay->hide(); + } + search_field->clear(); + m_is_launching = false; emit GameChosen(file_path, title_id); - animation_label->deleteLater(); - logo_widget->deleteLater(); }); main_group->start(QAbstractAnimation::DeleteWhenStopped); } void GameList::ValidateEntry(const QModelIndex& item) { + if (m_is_launching || !item.isValid() || !item.model()) { + return; + } const auto selected = item.sibling(item.row(), 0); switch (selected.data(GameListItem::TypeRole).value()) { case GameListItemType::Game: { @@ -1938,11 +2088,13 @@ void GameList::ValidateEntry(const QModelIndex& item) { // If the entry is a directory (e.g., for homebrew), launch it directly without animation. if (file_info.isDir()) { + m_is_launching = true; const QDir dir{file_path}; const QStringList matching_main = dir.entryList({QStringLiteral("main")}, QDir::Files); if (matching_main.size() == 1) { emit GameChosen(dir.path() + QDir::separator() + matching_main[0]); } + m_is_launching = false; return; // Exit here for directories } @@ -2023,6 +2175,9 @@ void GameList::DonePopulating(const QStringList& watch_list) { if (delegate) delegate->SetPopulating(false); + // Save the newly populated index for instant loading next time. + SaveGameListIndex(); + // Clean up worker safely outside of its execution context QTimer::singleShot(0, this, [this]() { current_worker.reset(); }); }); @@ -2044,8 +2199,23 @@ void GameList::DonePopulating(const QStringList& watch_list) { emit ShowList(!IsEmpty()); // Only add the "Add Directory" item to the main model for List View. // Grid and Carousel use a flat model and explicitly skip this type. - item_model->invisibleRootItem()->appendRow(new GameListAddDir()); - item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); + // Deduplicate special rows (Add Directory and Favorites) + bool has_add_dir = false; + bool has_favorites = false; + for (int i = 0; i < item_model->rowCount(); ++i) { + auto type = item_model->item(i)->data(GameListItem::TypeRole).value(); + if (type == GameListItemType::AddDir) + has_add_dir = true; + if (type == GameListItemType::Favorites) + has_favorites = true; + } + + if (!has_add_dir) { + item_model->invisibleRootItem()->appendRow(new GameListAddDir()); + } + if (!has_favorites) { + item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); + } tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), UISettings::values.favorited_ids.size() == 0); tree_view->setExpanded(item_model->invisibleRootItem()->child(0)->index(), @@ -2060,11 +2230,13 @@ void GameList::DonePopulating(const QStringList& watch_list) { constexpr int LIMIT_WATCH_DIRECTORIES = 5000; constexpr int SLICE_SIZE = 25; int len = std::min(static_cast(watch_list.size()), LIMIT_WATCH_DIRECTORIES); + tree_view->setEnabled(true); + tree_view->setFocus(); + for (int i = 0; i < len; i += SLICE_SIZE) { watcher->addPaths(watch_list.mid(i, i + SLICE_SIZE)); QCoreApplication::processEvents(); } - tree_view->setEnabled(true); int children_total = 0; for (int i = 1; i < item_model->rowCount() - 1; ++i) { children_total += item_model->item(i, 0)->rowCount(); @@ -2128,18 +2300,18 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { QMenu context_menu(parent_widget); context_menu.setAttribute(Qt::WA_TranslucentBackground, false); context_menu.setStyleSheet(QStringLiteral( - "QMenu { background: #24242a; border: 1px solid #32323a; border-radius: 8px; padding: 6px; color: #ffffff; }" + "QMenu { background: #24242a; border: 1px solid #32323a; border-radius: 8px; padding: 6px; " + "color: #ffffff; }" "QMenu::item { padding: 6px 30px; border-radius: 4px; margin: 2px; color: #ffffff; }" "QMenu::item:selected { background-color: #32323a; border: 1px solid #42424a; }" - "QMenu::separator { height: 1px; background: #32323a; margin: 4px 10px; }" - )); + "QMenu::separator { height: 1px; background: #32323a; margin: 4px 10px; }")); switch (selected.data(GameListItem::TypeRole).value()) { case GameListItemType::Game: { const u64 program_id = selected.data(GameListItemPath::ProgramIdRole).toULongLong(); const std::string path = selected.data(GameListItemPath::FullPathRole).toString().toStdString(); - const QString game_name = selected.data(GameListItemPath::TitleRole).toString(); - AddGamePopup(context_menu, program_id, path, game_name); + const auto game_name = selected.data(GameListItemPath::OriginalTitleRole).toString(); + AddGamePopup(context_menu, selected, program_id, path, game_name); break; } case GameListItemType::CustomDir: @@ -2168,8 +2340,8 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { } } -void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path_str, - const QString& game_name) { +void GameList::AddGamePopup(QMenu& context_menu, const QModelIndex& index, u64 program_id, + const std::string& path_str, const QString& game_name) { const QString path = QString::fromStdString(path_str); const bool is_mirrored = Settings::values.mirrored_save_paths.count(program_id); const bool has_custom_path = Settings::values.custom_save_paths.count(program_id); @@ -2274,26 +2446,37 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri QAction* edit_metadata = context_menu.addAction(tr("Edit Metadata")); QAction* properties = context_menu.addAction(tr("Properties")); - connect(edit_metadata, &QAction::triggered, [this, program_id, game_name] { - const u64 current_play_time = play_time_manager.GetPlayTime(program_id); - CustomMetadataDialog dialog(this, program_id, game_name.toStdString(), current_play_time); - if (dialog.exec() == QDialog::Accepted) { - auto& custom_metadata = Citron::CustomMetadata::GetInstance(); - if (dialog.WasReset()) { - custom_metadata.RemoveCustomMetadata(program_id); - } else { - custom_metadata.SetCustomTitle(program_id, dialog.GetTitle()); - const std::string icon_path = dialog.GetIconPath(); - if (!icon_path.empty()) { - custom_metadata.SetCustomIcon(program_id, icon_path); + connect( + edit_metadata, &QAction::triggered, this, + [this, program_id, game_name] { + const u64 current_play_time = play_time_manager.GetPlayTime(program_id); + CustomMetadataDialog dialog(this, program_id, game_name.toStdString(), + current_play_time); + if (dialog.exec() == QDialog::Accepted) { + auto& custom_metadata = Citron::CustomMetadata::GetInstance(); + if (dialog.WasReset()) { + custom_metadata.RemoveCustomMetadata(program_id); + } else { + custom_metadata.SetCustomTitle(program_id, dialog.GetTitle()); + const std::string icon_path = dialog.GetIconPath(); + if (!icon_path.empty()) { + custom_metadata.SetCustomIcon(program_id, icon_path); + } + play_time_manager.SetPlayTime(program_id, dialog.GetPlayTime()); + } + // Invalidate game list cache to force re-scan of canonical titles + const auto cache_dir = + Common::FS::GetCitronPath(Common::FS::CitronPath::CacheDir) / "game_list"; + const auto cache_file = + Common::FS::PathToUTF8String(cache_dir / "game_metadata_cache.json"); + Common::FS::RemoveFile(cache_file); + + if (main_window) { + main_window->RefreshGameList(); } - play_time_manager.SetPlayTime(program_id, dialog.GetPlayTime()); } - if (main_window) { - main_window->RefreshGameList(); - } - } - }); + }, + Qt::QueuedConnection); favorite->setVisible(program_id != 0); favorite->setCheckable(true); @@ -2343,7 +2526,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri connect(open_nand_location, &QAction::triggered, [this, program_id, mirror_base_path]() { const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128(); const std::string relative_save_path = fmt::format( - "user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id); + "user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, static_cast(user_id[1]), + static_cast(user_id[0]), static_cast(program_id)); const auto full_save_path = std::filesystem::path(mirror_base_path.toStdString()) / relative_save_path; if (!std::filesystem::exists(full_save_path.parent_path())) { @@ -2382,7 +2566,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri const QString base_dir = QString::fromStdString(base_save_path_str); const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128(); const std::string relative_save_path = fmt::format( - "user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id); + "user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, static_cast(user_id[1]), + static_cast(user_id[0]), static_cast(program_id)); const QString internal_save_path = QDir(base_dir).filePath(QString::fromStdString(relative_save_path)); bool mirroring_enabled = false; @@ -2474,8 +2659,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri }); connect(open_current_game_sdmc, &QAction::triggered, [program_id]() { const auto sdmc_path = Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir); - const auto full_path = - sdmc_path / "atmosphere" / "contents" / fmt::format("{:016X}", program_id); + const auto full_path = sdmc_path / "atmosphere" / "contents" / + fmt::format("{:016X}", static_cast(program_id)); const QString qpath = QString::fromStdString(Common::FS::PathToUTF8String(full_path)); QDir dir(qpath); if (!dir.exists()) @@ -2491,9 +2676,7 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri dir.mkpath(QStringLiteral(".")); QDesktopServices::openUrl(QUrl::fromLocalFile(qpath)); }); - connect(start_game, &QAction::triggered, [this, path_str]() { - emit BootGame(QString::fromStdString(path_str), StartGameType::Normal); - }); + connect(start_game, &QAction::triggered, [this, index]() { ValidateEntry(index); }); connect(start_game_global, &QAction::triggered, [this, path_str]() { emit BootGame(QString::fromStdString(path_str), StartGameType::Global); }); @@ -2525,6 +2708,7 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri connect(remove_cache_storage, &QAction::triggered, [this, program_id, path_str] { emit RemoveFileRequested(program_id, GameListRemoveTarget::CacheStorage, path_str); }); + connect(dump_romfs, &QAction::triggered, [this, program_id, path_str]() { emit DumpRomFSRequested(program_id, path_str, DumpRomFSTarget::Normal); }); @@ -2545,7 +2729,9 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri return; } const QString clean_tid = - QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char('0')).toUpper(); + QStringLiteral("%1") + .arg(static_cast(program_id), 16, 16, QLatin1Char('0')) + .toUpper(); QUrl url(QStringLiteral("https://github.com/citron-neo/Citron-Compatability/issues/new")); QUrlQuery query; query.addQueryItem(QStringLiteral("template"), QStringLiteral("compat.yml")); @@ -2562,8 +2748,9 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri emit CreateShortcut(program_id, path_str, GameListShortcutTarget::Applications); }); #endif - connect(properties, &QAction::triggered, - [this, path_str]() { emit OpenPerGameGeneralRequested(path_str); }); + connect( + properties, &QAction::triggered, this, + [this, path_str]() { emit OpenPerGameGeneralRequested(path_str); }, Qt::QueuedConnection); } void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected, @@ -2760,12 +2947,12 @@ QStandardItemModel* GameList::GetModel() const { return item_model; } -void GameList::PopulateAsync(QVector& game_dirs) { +void GameList::PopulateAsync(QVector& game_dirs, bool is_smart_update) { if (current_worker) { return; } - if (loading_overlay) { + if (loading_overlay && !is_smart_update) { loading_overlay->ShowLoading(); } auto* delegate = qobject_cast(tree_view->itemDelegate()); @@ -2773,9 +2960,14 @@ void GameList::PopulateAsync(QVector& game_dirs) { delegate->SetPopulating(true); } - item_model->clear(); - item_model->insertColumns(0, COLUMN_COUNT); - RetranslateUI(); + if (!is_smart_update) { + item_model->clear(); + item_model->insertColumns(0, COLUMN_COUNT); + RetranslateUI(); + } else { + // Targeted metadata updates are now handled by RefreshGame() or worker signals + // to avoid global UI-wide 'refreshing' indicators that can feel unresponsive. + } // Set columns to interactive sizing and calibrate for 720p displays if (tree_view && tree_view->header()) { @@ -2801,7 +2993,11 @@ void GameList::PopulateAsync(QVector& game_dirs) { } UpdateProgressBarColor(); - tree_view->setEnabled(false); + if (!is_smart_update) { + tree_view->setEnabled(false); + item_model->removeRows(0, item_model->rowCount()); + search_field->clear(); + } emit ShowList(true); tree_view->setColumnHidden(COLUMN_ADD_ONS, !UISettings::values.show_add_ons); tree_view->setColumnHidden(COLUMN_COMPATIBILITY, !UISettings::values.show_compat); @@ -2810,8 +3006,6 @@ void GameList::PopulateAsync(QVector& game_dirs) { tree_view->setColumnHidden(COLUMN_PLAY_TIME, !UISettings::values.show_play_time); tree_view->setColumnHidden(COLUMN_ONLINE, !UISettings::values.show_online_column); current_worker.reset(); - item_model->removeRows(0, item_model->rowCount()); - search_field->clear(); if (progress_bar) { progress_bar->setValue(0); @@ -2866,7 +3060,6 @@ void GameList::LoadInterfaceLayout() { const QStringList GameList::supported_file_extensions = { QStringLiteral("xci"), QStringLiteral("nsp"), QStringLiteral("nso"), QStringLiteral("nro"), QStringLiteral("kip")}; - void GameList::RefreshGameDirectory() { if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) { LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); @@ -3320,7 +3513,131 @@ void GameList::UpdateAccentColorStyles() { .arg(color_name)); } +void GameList::SaveGameListIndex() { + QJsonArray root_array; + for (int i = 0; i < item_model->rowCount(); ++i) { + QStandardItem* folder = item_model->item(i, 0); + if (!folder) + continue; + + QJsonObject folder_obj; + folder_obj[QStringLiteral("name")] = folder->text(); + folder_obj[QStringLiteral("type")] = folder->data(GameListItem::TypeRole).toInt(); + + QJsonArray games_array; + for (int j = 0; j < folder->rowCount(); ++j) { + QStandardItem* game = folder->child(j, 0); + if (!game) + continue; + + QJsonObject game_obj; + game_obj[QStringLiteral("path")] = + game->data(GameListItemPath::FullPathRole).toString(); + game_obj[QStringLiteral("title")] = game->data(GameListItemPath::TitleRole).toString(); + game_obj[QStringLiteral("program_id")] = + QString::number(game->data(GameListItemPath::ProgramIdRole).toULongLong()); + game_obj[QStringLiteral("file_type")] = + game->data(GameListItemPath::FileTypeRole).toString(); + game_obj[QStringLiteral("original_title")] = + game->data(GameListItemPath::OriginalTitleRole).toString(); + + // Save metadata from other columns + game_obj[QStringLiteral("compat")] = folder->child(j, COLUMN_COMPATIBILITY) + ->data(GameListItemCompat::CompatNumberRole) + .toString(); + game_obj[QStringLiteral("play_time")] = + QString::number(folder->child(j, COLUMN_PLAY_TIME) + ->data(GameListItemPlayTime::PlayTimeRole) + .toULongLong()); + game_obj[QStringLiteral("size")] = + folder->child(j, COLUMN_SIZE)->data(GameListItemSize::SizeRole).toLongLong(); + + games_array.append(game_obj); + } + folder_obj[QStringLiteral("games")] = games_array; + folder_obj[QStringLiteral("expanded")] = tree_view->isExpanded(folder->index()); + root_array.append(folder_obj); + } + + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/game_list_metadata.json"); + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + if (file.open(QIODevice::WriteOnly)) { + file.write(QJsonDocument(root_array).toJson()); + } +} + +void GameList::LoadGameListIndex() { + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/game_list_metadata.json"); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return; + + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isArray()) + return; + + QJsonArray root_array = doc.array(); + item_model->clear(); + item_model->insertColumns(0, COLUMN_COUNT); + + for (const auto& val : root_array) { + QJsonObject folder_obj = val.toObject(); + UISettings::GameDir dir_struct; + dir_struct.path = folder_obj[QStringLiteral("name")].toString().toStdString(); + + GameListDir* folder = new GameListDir( + dir_struct, static_cast(folder_obj[QStringLiteral("type")].toInt())); + + QJsonArray games_array = folder_obj[QStringLiteral("games")].toArray(); + for (const auto& game_val : games_array) { + QJsonObject game_obj = game_val.toObject(); + + QString game_path = game_obj[QStringLiteral("path")].toString(); + QString game_title = game_obj[QStringLiteral("title")].toString(); + u64 program_id = game_obj[QStringLiteral("program_id")].toVariant().toULongLong(); + QString file_type = game_obj[QStringLiteral("file_type")].toString(); + QString original_title = game_obj[QStringLiteral("original_title")].toString(); + + QList row; + // Note: We leave icon data empty for the background scan to fill in, providing instant + // text results. + row.append(new GameListItemPath(game_path, {}, game_title, original_title, file_type, + program_id)); + row.append(new GameListItemCompat(game_obj[QStringLiteral("compat")].toString())); + row.append(new QStandardItem()); // Add-ons + row.append(new QStandardItem(file_type)); + row.append( + new GameListItemSize(game_obj[QStringLiteral("size")].toVariant().toULongLong())); + row.append(new GameListItemPlayTime( + game_obj[QStringLiteral("play_time")].toVariant().toULongLong())); + row.append(new GameListItemOnline()); + + folder->appendRow(row); + } + item_model->appendRow(folder); + + // Restore folder expanded state + if (folder_obj[QStringLiteral("expanded")].toBool(true)) { + tree_view->expand(folder->index()); + } + } + + // Sync other view modes (Grid/Carousel) + if (auto* gm = qobject_cast(grid_view->model())) { + gm->clear(); + } + if (auto* cm = qobject_cast(carousel_view->view()->model())) { + cm->clear(); + } + // (Actual synchronization logic would follow the Worker's pattern, + // but clearing ensures no stale state while background scan runs). +} + void GameList::OnEmulationEnded() { + m_is_launching = false; // Ensure guard is reset when emulation stops auto* effect = new QGraphicsOpacityEffect(fade_overlay); fade_overlay->setGraphicsEffect(effect); auto* fade_anim = new QPropertyAnimation(effect, "opacity"); @@ -3444,13 +3761,14 @@ void GameList::onControllerFocusChanged(ControllerNavigation::FocusTarget target carousel_view->setControllerFocus(true); details_panel->setControllerFocus(false); // Ensure panel hides in List view if focus returns to list, but stays for Grid/Carousel - if (main_stack->currentIndex() == 0) AnimateDetailsPanel(false); + if (main_stack->currentIndex() == 0) + AnimateDetailsPanel(false); } else { tree_view->setControllerFocus(false); grid_view->setControllerFocus(false); carousel_view->setControllerFocus(false); details_panel->setControllerFocus(true); - + // CRITICAL: Actually show the panel when focus moves to it! if (!details_panel->isVisible() || details_panel->width() < 10) { AnimateDetailsPanel(true); diff --git a/src/citron/game_list.h b/src/citron/game_list.h index 7b2ced25ca..628c228913 100644 --- a/src/citron/game_list.h +++ b/src/citron/game_list.h @@ -116,11 +116,13 @@ public: bool IsEmpty() const; void LoadCompatibilityList(); - void PopulateAsync(QVector& game_dirs); + void PopulateAsync(QVector& 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& entry_items, GameListDir* parent); + void AddEntry(const QList& 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; diff --git a/src/citron/game_list_delegate.cpp b/src/citron/game_list_delegate.cpp index 06e22de7dc..7667415783 100644 --- a/src/citron/game_list_delegate.cpp +++ b/src/citron/game_list_delegate.cpp @@ -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(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" diff --git a/src/citron/game_list_delegate.h b/src/citron/game_list_delegate.h index d863faf281..9833364fbb 100644 --- a/src/citron/game_list_delegate.h +++ b/src/citron/game_list_delegate.h @@ -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 greyscale_icon_cache; mutable QMap addons_item_cache; diff --git a/src/citron/game_list_p.h b/src/citron/game_list_p.h index 3ce0f664d2..6a69115dcc 100644 --- a/src/citron/game_list_p.h +++ b/src/citron/game_list_p.h @@ -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& 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: diff --git a/src/citron/game_list_worker.cpp b/src/citron/game_list_worker.cpp index 21afe9ad4c..6881a5298a 100644 --- a/src/citron/game_list_worker.cpp +++ b/src/citron/game_list_worker.cpp @@ -347,16 +347,18 @@ std::pair, std::string> GetGameListCachedObject( } void GetMetadataFromControlNCA(const FileSys::PatchManager& patch_manager, const FileSys::NCA& nca, - std::vector& icon, std::string& name) { + std::vector& 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 MakeGameListEntry(const std::string& path, const std::string& name, + const std::string& original_name, const std::size_t size, const std::vector& icon, Loader::AppLoader& loader, u64 program_id, const CompatibilityList& compatibility_list, @@ -477,8 +480,9 @@ QList MakeGameListEntry(const std::string& path, const std::stri } QList 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>& 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(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(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(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 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>& 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 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 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); } diff --git a/src/citron/game_list_worker.h b/src/citron/game_list_worker.h index 8377f65000..e189a991c8 100644 --- a/src/citron/game_list_worker.h +++ b/src/citron/game_list_worker.h @@ -86,11 +86,11 @@ private: void RecordEvent(F&& func); private: - void AddTitlesToGameList(GameListDir* parent_dir, + void AddTitlesToGameList(const QString& parent_path, const std::map>& online_stats); void ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, - GameListDir* parent_dir, + const QString& parent_path, const std::map>& online_stats, int& processed_files, int total_files); diff --git a/src/citron/main.cpp b/src/citron/main.cpp index eeeb0def15..23cd7b97df 100644 --- a/src/citron/main.cpp +++ b/src/citron/main.cpp @@ -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); diff --git a/src/citron/mod_manager/gamebanana_dialog.cpp b/src/citron/mod_manager/gamebanana_dialog.cpp new file mode 100644 index 0000000000..7d97fca3a7 --- /dev/null +++ b/src/citron/mod_manager/gamebanana_dialog.cpp @@ -0,0 +1,402 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) +#include +#else +#include +#endif +#include +#include +#include + +#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()), + 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((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& 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& 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("Website Link For " + "Mod") + .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 diff --git a/src/citron/mod_manager/gamebanana_dialog.h b/src/citron/mod_manager/gamebanana_dialog.h new file mode 100644 index 0000000000..74cbce42bc --- /dev/null +++ b/src/citron/mod_manager/gamebanana_dialog.h @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#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& games); + void PopulateMods(const QVector& 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; + GameBananaService* service; + QLabel* archiveTypeLabel; + + QString title_id; + QString game_name; + QString current_game_id; + QString current_query; + int current_page = 1; + + QVector loaded_mods; + GameBananaMod selected_mod; + + QMap screenshot_cache; +}; + +} // namespace ModManager diff --git a/src/citron/mod_manager/gamebanana_dialog.ui b/src/citron/mod_manager/gamebanana_dialog.ui new file mode 100644 index 0000000000..9e8851955d --- /dev/null +++ b/src/citron/mod_manager/gamebanana_dialog.ui @@ -0,0 +1,491 @@ + + + GameBananaDialog + + + + 0 + 0 + 1000 + 800 + + + + GameBanana Mod Browser + + + + 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; + } + + + + + 15 + + + 20 + + + 20 + + + 20 + + + 20 + + + + + + 16 + true + + + + Loading mods for game... + + + Qt::AlignCenter + + + + + + + 20 + + + + + 10 + + + + + + + Search for mods... + + + + + + + + 150 + 0 + + + + + Newest + + + + + Recently Updated + + + + + Most Downloaded + + + + + Most Viewed + + + + + Most Liked + + + + + Highest Rated + + + + + + + + + + + 0 + 0 + + + + + + + + Load More Mods... + + + + + + + + + + 380 + 0 + + + + + 400 + 16777215 + + + + Mod Details + + + + 12 + + + + + + 0 + 200 + + + + border: 1px solid #2a2a2c; border-radius: 4px; background-color: #000000; + + + + + + true + + + Qt::AlignCenter + + + + + + + + 12 + true + + + + color: #ffffff; + + + Select a Mod + + + true + + + + + + + 20 + + + 8 + + + + + color: #888888; + + + Updated: + + + + + + + - + + + + + + + color: #888888; + + + Compatible Ver: + + + + + + + color: #3ed194; + + + - + + + true + + + + + + + color: #888888; + + + Downloads: + + + + + + + - + + + + + + + + + + true + + + + Description + + + + + + + + 0 + 0 + + + + + + + + + + + Load Directory + + + + + Atmosphere Contents + + + + + + + + + + + + + + + + 16777215 + 8 + + + + 0 + + + false + + + + + + + + + Ready + + + color: #666666; + + + + + + + + + + color: #999999; font-weight: bold; margin-left: 15px; + + + + + + + + + + color: #3ed194; margin-left: 15px; + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Manual Extract + + + + + + + No Backup + + + + + + + + 150 + 0 + + + + Install Mod + + + + + + + Close + + + + + + + + + + diff --git a/src/citron/mod_manager/gamebanana_service.cpp b/src/citron/mod_manager/gamebanana_service.cpp new file mode 100644 index 0000000000..4c003dcd61 --- /dev/null +++ b/src/citron/mod_manager/gamebanana_service.cpp @@ -0,0 +1,491 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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 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 diff --git a/src/citron/mod_manager/gamebanana_service.h b/src/citron/mod_manager/gamebanana_service.h new file mode 100644 index 0000000000..dd2c0b2653 --- /dev/null +++ b/src/citron/mod_manager/gamebanana_service.h @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +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& games); + void ModsAvailable(const QVector& 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 diff --git a/src/citron/mod_manager/mod_downloader_dialog.cpp b/src/citron/mod_manager/mod_downloader_dialog.cpp deleted file mode 100644 index 2849ddfd73..0000000000 --- a/src/citron/mod_manager/mod_downloader_dialog.cpp +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#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 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(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 diff --git a/src/citron/mod_manager/mod_downloader_dialog.h b/src/citron/mod_manager/mod_downloader_dialog.h deleted file mode 100644 index 222d85f5c1..0000000000 --- a/src/citron/mod_manager/mod_downloader_dialog.h +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -#pragma once - -#include -#include -#include -#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 pending_downloads; - int current_download_index = -1; - int current_file_index = 0; -}; - -} // namespace ModManager diff --git a/src/citron/mod_manager/mod_downloader_dialog.ui b/src/citron/mod_manager/mod_downloader_dialog.ui deleted file mode 100644 index 2b44e1a581..0000000000 --- a/src/citron/mod_manager/mod_downloader_dialog.ui +++ /dev/null @@ -1,70 +0,0 @@ - - - ModDownloaderDialog - - - - 0 - 0 - 450 - 350 - - - - Citron Mod Downloader - - - - - - Select the mods you wish to install for this game version: - - - - - - - - Available Enhancements - - - - - - - - 0 - - - false - - - - - - - - - Qt::Horizontal - - - - - - - Download Selected - - - - - - - Cancel - - - - - - - - diff --git a/src/citron/mod_manager/mod_service.cpp b/src/citron/mod_manager/mod_service.cpp deleted file mode 100644 index 532f47347b..0000000000 --- a/src/citron/mod_manager/mod_service.cpp +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -#include -#include -#include -#include -#include -#include "citron/mod_manager/mod_service.h" - -namespace ModManager { - -ModService::ModService(QObject* parent) : QObject(parent) { - network_manager = std::make_unique(this); -} - -ModService::~ModService() = default; - -void ModService::FetchAvailableMods(const QString& title_id) { - const QStringList optimizer_supported = { - QStringLiteral("0100F2C0115B6000"), - QStringLiteral("01007EF00011E000"), - QStringLiteral("0100A3D008C5C000"), - QStringLiteral("01008F6008C5E000"), - QStringLiteral("01008CF01BAAC000"), - QStringLiteral("0100453019AA8000") - }; - - QNetworkRequest request((QUrl(MANIFEST_URL))); - QNetworkReply* reply = network_manager->get(request); - - connect(reply, &QNetworkReply::finished, this, [this, reply, title_id, optimizer_supported]() { - if (reply->error() != QNetworkReply::NoError) { - emit Error(reply->errorString()); - reply->deleteLater(); - return; - } - - QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); - QJsonObject root = doc.object(); - QString tid_upper = title_id.toUpper(); - - ModUpdateInfo info; - info.title_id = title_id; - - // 1. Process standard mods from manifest - if (root.contains(tid_upper)) { - QJsonObject tid_obj = root.value(tid_upper).toObject(); - QJsonObject versions_obj = tid_obj.value(QStringLiteral("versions")).toObject(); - for (auto it = versions_obj.begin(); it != versions_obj.end(); ++it) { - QString v_name = it.key(); - QJsonObject v_data = it.value().toObject(); - QJsonArray patches_array = v_data.value(QStringLiteral("patches")).toArray(); - std::vector patches; - for (const QJsonValue& val : patches_array) { - QJsonObject p_obj = val.toObject(); - ModPatch patch; - patch.name = p_obj.value(QStringLiteral("name")).toString(); - patch.type = p_obj.value(QStringLiteral("type")).toString(); - patch.rel_path = p_obj.value(QStringLiteral("rel_path")).toString(); - QJsonArray files_arr = p_obj.value(QStringLiteral("files")).toArray(); - for (const QJsonValue& f : files_arr) patch.files << f.toString(); - patches.push_back(patch); - } - info.version_patches[v_name] = patches; - } - } - - // 2. Also Fetch NX-Optimizer if supported - if (optimizer_supported.contains(tid_upper)) { - QNetworkRequest github_req(QUrl(QStringLiteral("https://api.github.com/repos/MaxLastBreath/nx-optimizer/releases/latest"))); - github_req.setRawHeader("Accept", "application/vnd.github.v3+json"); - github_req.setRawHeader("User-Agent", "Citron-Emulator"); - QNetworkReply* github_reply = network_manager->get(github_req); - - connect(github_reply, &QNetworkReply::finished, this, [this, github_reply, info]() mutable { - if (github_reply->error() == QNetworkReply::NoError) { - QJsonObject release = QJsonDocument::fromJson(github_reply->readAll()).object(); - QJsonArray assets = release.value(QStringLiteral("assets")).toArray(); - ModPatch tool; - tool.name = QStringLiteral("NX-Optimizer by MaxLastBreath"); - tool.type = QStringLiteral("tool"); - for (const QJsonValue& asset : assets) { - tool.files << asset.toObject().value(QStringLiteral("browser_download_url")).toString(); - } - info.version_patches[QStringLiteral("Global Tools")].push_back(tool); - } - emit ModsAvailable(info); - github_reply->deleteLater(); - }); - } else { - emit ModsAvailable(info); - } - reply->deleteLater(); - }); -} - -} // namespace ModManager diff --git a/src/citron/mod_manager/mod_service.h b/src/citron/mod_manager/mod_service.h deleted file mode 100644 index 868bdcedc0..0000000000 --- a/src/citron/mod_manager/mod_service.h +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -class QNetworkAccessManager; - -namespace ModManager { - -struct ModPatch { - QString name; - QString type; // "exefs" or "romfs" - QString rel_path; - QStringList files; -}; - -struct ModUpdateInfo { - QString title_id; - // Maps Version String (e.g. "2.0.0") to its list of patches - std::map> version_patches; -}; - -class ModService : public QObject { - Q_OBJECT -public: - explicit ModService(QObject* parent = nullptr); - ~ModService(); - - // Removed the version parameter so it fetches everything - void FetchAvailableMods(const QString& title_id); - -signals: - void ModsAvailable(const ModUpdateInfo& info); - void Error(const QString& message); - -private: - std::unique_ptr network_manager; - const QString MANIFEST_URL = QStringLiteral("https://raw.githubusercontent.com/citron-neo/Citron-Mods/main/manifest.json"); -}; - -} // namespace ModManager diff --git a/src/citron/mod_manager/zip_extractor.cpp b/src/citron/mod_manager/zip_extractor.cpp new file mode 100644 index 0000000000..b6bf35c42d --- /dev/null +++ b/src/citron/mod_manager/zip_extractor.cpp @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include + +#include "citron/mod_manager/zip_extractor.h" +#include "common/fs/fs.h" +#include "common/logging.h" + +namespace ModManager { + +bool ZipExtractor::CanExtract() { +#ifdef _WIN32 + // Check for PowerShell (built-in) or 7z + if (!QStandardPaths::findExecutable(QStringLiteral("powershell")).isEmpty()) { + return true; + } + if (!QStandardPaths::findExecutable(QStringLiteral("7z")).isEmpty()) { + return true; + } +#else + // Check for 7z, unzip, or unar + if (!QStandardPaths::findExecutable(QStringLiteral("7z")).isEmpty()) { + return true; + } + if (!QStandardPaths::findExecutable(QStringLiteral("7za")).isEmpty()) { + return true; + } + if (!QStandardPaths::findExecutable(QStringLiteral("unzip")).isEmpty()) { + return true; + } + if (!QStandardPaths::findExecutable(QStringLiteral("unar")).isEmpty()) { + return true; + } +#endif + return false; +} + +bool ZipExtractor::ExtractToPath(const QString& zip_path, const QString& dest_path) { + QDir().mkpath(dest_path); + +#ifdef _WIN32 + // On Windows, use PowerShell's Expand-Archive for .zip, or 7z for others + if (zip_path.endsWith(QStringLiteral(".zip"), Qt::CaseInsensitive)) { + QString powershell = QStandardPaths::findExecutable(QStringLiteral("powershell")); + if (!powershell.isEmpty()) { + QProcess process; + process.setProgram(powershell); + process.setArguments( + {QStringLiteral("-Command"), + QStringLiteral("Expand-Archive -Path '%1' -DestinationPath '%2' -Force") + .arg(zip_path, dest_path)}); + process.start(); + process.waitForFinished(60000); + if (process.exitCode() == 0) + return true; + } + } + + // Fallback to 7z + QString p7z = QStandardPaths::findExecutable(QStringLiteral("7z")); + if (!p7z.isEmpty()) { + QProcess process; + process.setProgram(p7z); + process.setArguments( + {QStringLiteral("x"), zip_path, QStringLiteral("-o") + dest_path, QStringLiteral("-y")}); + process.start(); + process.waitForFinished(120000); + return process.exitCode() == 0; + } +#else + // On Linux/macOS, try 7z first + QString p7z = QStandardPaths::findExecutable(QStringLiteral("7z")); + if (!p7z.isEmpty()) { + QProcess process; + process.setProgram(p7z); + process.setArguments( + {QStringLiteral("x"), zip_path, QStringLiteral("-o") + dest_path, QStringLiteral("-y")}); + process.start(); + if (process.waitForFinished(120000) && process.exitCode() == 0) { + return true; + } + } + + // Try unzip for .zip files + if (zip_path.endsWith(QStringLiteral(".zip"), Qt::CaseInsensitive)) { + QString unzip = QStandardPaths::findExecutable(QStringLiteral("unzip")); + if (!unzip.isEmpty()) { + QProcess process; + process.setProgram(unzip); + process.setArguments({QStringLiteral("-o"), zip_path, QStringLiteral("-d"), dest_path}); + process.start(); + if (process.waitForFinished(60000) && process.exitCode() == 0) + return true; + } + } + + // Try 7za + QString p7za = QStandardPaths::findExecutable(QStringLiteral("7za")); + if (!p7za.isEmpty()) { + QProcess process; + process.setProgram(p7za); + process.setArguments( + {QStringLiteral("x"), zip_path, QStringLiteral("-o") + dest_path, QStringLiteral("-y")}); + process.start(); + if (process.waitForFinished(120000) && process.exitCode() == 0) + return true; + } + + // Try unar + QString unar = QStandardPaths::findExecutable(QStringLiteral("unar")); + if (!unar.isEmpty()) { + QProcess process; + process.setProgram(unar); + process.setArguments({QStringLiteral("-o"), dest_path, QStringLiteral("-f"), zip_path}); + process.start(); + if (process.waitForFinished(120000) && process.exitCode() == 0) + return true; + } +#endif + + return false; +} + +QStringList ZipExtractor::ListContents(const QString& zip_path) { + QStringList contents; + +#ifdef _WIN32 + QString p7z = QStandardPaths::findExecutable(QStringLiteral("7z")); + if (!p7z.isEmpty()) { + QProcess process; + process.setProgram(p7z); + process.setArguments({QStringLiteral("l"), zip_path}); + process.start(); + process.waitForFinished(30000); + QString output = QString::fromUtf8(process.readAllStandardOutput()); + QStringList lines = output.split(QStringLiteral("\n")); + bool in_list = false; + for (const QString& line : lines) { + if (line.contains(QStringLiteral("-------------------"))) { + in_list = !in_list; + continue; + } + if (in_list && line.length() > 53) { + contents.append(line.mid(53).trimmed()); + } + } + if (!contents.isEmpty()) + return contents; + } + + QString powershell = QStandardPaths::findExecutable(QStringLiteral("powershell")); + if (!powershell.isEmpty()) { + QProcess process; + process.setProgram(powershell); + process.setArguments( + {QStringLiteral("-Command"), + QStringLiteral( + "(Get-ChildItem -Path (New-Object " + "System.IO.Compression.ZipFile)::OpenRead('%1').Entries.FullName) -join \"`n\"") + .arg(zip_path)}); + process.start(); + process.waitForFinished(30000); + QString output = QString::fromUtf8(process.readAllStandardOutput()); + return output.split(QStringLiteral("\n"), Qt::SkipEmptyParts); + } +#else + QString p7z = QStandardPaths::findExecutable(QStringLiteral("7z")); + if (!p7z.isEmpty()) { + QProcess process; + process.setProgram(p7z); + process.setArguments({QStringLiteral("l"), zip_path}); + process.start(); + process.waitForFinished(30000); + QString output = QString::fromUtf8(process.readAllStandardOutput()); + + QStringList lines = output.split(QStringLiteral("\n")); + bool in_list = false; + for (const QString& line : lines) { + if (line.contains(QStringLiteral("-------------------"))) { + in_list = !in_list; + continue; + } + if (in_list && line.length() > 53) { + contents.append(line.mid(53).trimmed()); + } + } + if (!contents.isEmpty()) + return contents; + } + + QString unzip = QStandardPaths::findExecutable(QStringLiteral("unzip")); + if (!unzip.isEmpty()) { + QProcess process; + process.setProgram(unzip); + process.setArguments({QStringLiteral("-l"), zip_path}); + process.start(); + process.waitForFinished(30000); + QString output = QString::fromUtf8(process.readAllStandardOutput()); + + QStringList lines = output.split(QStringLiteral("\n")); + bool in_file_list = false; + for (const QString& line : lines) { + if (line.contains(QStringLiteral("----"))) { + in_file_list = !in_file_list; + continue; + } + if (in_file_list && !line.trimmed().isEmpty()) { + QStringList parts = + line.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + if (parts.size() >= 4) { + contents.append(parts.last()); + } + } + } + } +#endif + + return contents; +} + +bool ZipExtractor::HasPrefix(const QStringList& entries, const QString& prefix) { + for (const QString& entry : entries) { + if (entry.startsWith(prefix, Qt::CaseInsensitive)) { + return true; + } + } + return false; +} + +ModStructure ZipExtractor::DetectModStructure(const QString& zip_path) { + QStringList contents = ListContents(zip_path); + + if (contents.isEmpty()) { + return ModStructure::Unknown; + } + + bool has_romfs = HasPrefix(contents, QStringLiteral("romfs/")) || + HasPrefix(contents, QStringLiteral("romfs\\")); + bool has_exefs = HasPrefix(contents, QStringLiteral("exefs/")) || + HasPrefix(contents, QStringLiteral("exefs\\")); + bool has_cheats = HasPrefix(contents, QStringLiteral("cheats/")) || + HasPrefix(contents, QStringLiteral("cheats\\")); + + if (!has_romfs && !has_exefs && !has_cheats) { + for (const QString& entry : contents) { + if (entry.contains(QStringLiteral("/romfs/"), Qt::CaseInsensitive) || + entry.contains(QStringLiteral("\\romfs\\"), Qt::CaseInsensitive)) { + has_romfs = true; + } + if (entry.contains(QStringLiteral("/exefs/"), Qt::CaseInsensitive) || + entry.contains(QStringLiteral("\\exefs\\"), Qt::CaseInsensitive)) { + has_exefs = true; + } + if (entry.contains(QStringLiteral("/cheats/"), Qt::CaseInsensitive) || + entry.contains(QStringLiteral("\\cheats\\"), Qt::CaseInsensitive)) { + has_cheats = true; + } + } + } + + if (has_cheats && !has_romfs && !has_exefs) { + return ModStructure::Cheats; + } + if (has_romfs && has_exefs) { + return ModStructure::Mixed; + } + if (has_romfs) { + return ModStructure::RomFS; + } + if (has_exefs) { + return ModStructure::ExeFS; + } + + bool only_txt = true; + for (const QString& entry : contents) { + QFileInfo info(entry); + if (info.isDir()) + continue; + if (info.suffix().compare(QStringLiteral("txt"), Qt::CaseInsensitive) != 0) { + only_txt = false; + break; + } + } + if (only_txt && !contents.isEmpty()) + return ModStructure::Cheats; + + return ModStructure::Flat; +} + +static void RobustMove(const std::filesystem::path& src, const std::filesystem::path& dst) { + std::error_code ec; + if (std::filesystem::exists(dst, ec)) { + std::filesystem::remove_all(dst, ec); + } + std::filesystem::create_directories(dst.parent_path(), ec); + + std::filesystem::rename(src, dst, ec); + if (ec) { + std::filesystem::copy(src, dst, std::filesystem::copy_options::recursive, ec); + if (!ec) { + std::filesystem::remove_all(src, ec); + } + } +} + +bool ZipExtractor::ExtractAndOrganize(const QString& zip_path, const QString& mod_folder) { + QTemporaryDir temp_dir; + if (!temp_dir.isValid()) + return false; + + if (!ExtractToPath(zip_path, temp_dir.path())) + return false; + + ModStructure structure = DetectModStructure(zip_path); + std::filesystem::path src_root(temp_dir.path().toStdString()); + std::filesystem::path dst_root(mod_folder.toStdString()); + + QDir temp(temp_dir.path()); + QStringList entries = temp.entryList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); + if (entries.size() == 1 && QFileInfo(temp.absoluteFilePath(entries[0])).isDir()) { + src_root /= entries[0].toStdString(); + } + + if (structure == ModStructure::Flat) { + std::filesystem::path romfs_dst = dst_root / "romfs"; + RobustMove(src_root, romfs_dst); + } else if (structure == ModStructure::Cheats) { + std::filesystem::path cheats_src = src_root / "cheats"; + if (!std::filesystem::exists(cheats_src)) { + std::filesystem::path cheats_dst = dst_root / "cheats"; + RobustMove(src_root, cheats_dst); + } else { + RobustMove(src_root, dst_root); + } + } else { + RobustMove(src_root, dst_root); + } + + return true; +} + +} // namespace ModManager diff --git a/src/citron/mod_manager/zip_extractor.h b/src/citron/mod_manager/zip_extractor.h new file mode 100644 index 0000000000..1ad0d2d22b --- /dev/null +++ b/src/citron/mod_manager/zip_extractor.h @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +namespace ModManager { + +enum class ModStructure { + Unknown, + RomFS, // Contains romfs/ folder + ExeFS, // Contains exefs/ folder + Mixed, // Contains both romfs/ and exefs/ + Cheats, // Contains cheats/ folder + Flat // Loose files, no standard structure +}; + +class ZipExtractor { +public: + // Extract a ZIP file to the destination path + // Returns true on success, false on failure + static bool ExtractToPath(const QString& zip_path, const QString& dest_path); + + // Analyze a ZIP file to determine its mod structure + static ModStructure DetectModStructure(const QString& zip_path); + + // Get the list of files in a ZIP archive + static QStringList ListContents(const QString& zip_path); + + // Extract and automatically organize based on detected structure + // Places files in appropriate subdirectories (romfs/, exefs/, cheats/) + static bool ExtractAndOrganize(const QString& zip_path, const QString& mod_folder); + + // Check if necessary extraction tools are available on the system + static bool CanExtract(); + +private: + // Helper to check if any entry starts with a given prefix + static bool HasPrefix(const QStringList& entries, const QString& prefix); +}; + +} // namespace ModManager diff --git a/src/citron/play_time_manager.cpp b/src/citron/play_time_manager.cpp index 08a553d7ce..5beaf85bc4 100644 --- a/src/citron/play_time_manager.cpp +++ b/src/citron/play_time_manager.cpp @@ -100,7 +100,7 @@ std::optional GetCurrentUserPlayTimePath( } // namespace PlayTimeManager::PlayTimeManager(Service::Account::ProfileManager& profile_manager) - : manager{profile_manager} { + : running_program_id{0}, last_timestamp{std::chrono::steady_clock::now()}, manager{profile_manager} { if (!ReadPlayTimeFile(database, manager)) { LOG_ERROR(Frontend, "Failed to read play time database! Resetting to default."); } @@ -115,33 +115,38 @@ void PlayTimeManager::SetProgramId(u64 program_id) { } void PlayTimeManager::Start() { + last_timestamp = std::chrono::steady_clock::now(); play_time_thread = std::jthread([&](std::stop_token stop_token) { AutoTimestamp(stop_token); }); } void PlayTimeManager::Stop() { play_time_thread = {}; + + // Record the final duration since the last tick + std::lock_guard lock{database_mutex}; + if (running_program_id != 0) { + database[running_program_id] += GetDuration(); + Save(); + } +} + +u64 PlayTimeManager::GetProgramId() const { + return running_program_id; } void PlayTimeManager::AutoTimestamp(std::stop_token stop_token) { Common::SetCurrentThreadName("PlayTimeReport"); using namespace std::literals::chrono_literals; - using std::chrono::seconds; - using std::chrono::steady_clock; - - auto timestamp = steady_clock::now(); - - const auto GetDuration = [&]() -> u64 { - const auto last_timestamp = std::exchange(timestamp, steady_clock::now()); - const auto duration = std::chrono::duration_cast(timestamp - last_timestamp); - return static_cast(duration.count()); - }; while (!stop_token.stop_requested()) { Common::StoppableTimedWait(stop_token, 30s); - database[running_program_id] += GetDuration(); - Save(); + std::lock_guard lock{database_mutex}; + if (running_program_id != 0) { + database[running_program_id] += GetDuration(); + Save(); + } } } @@ -152,6 +157,7 @@ void PlayTimeManager::Save() { } u64 PlayTimeManager::GetPlayTime(u64 program_id) const { + std::lock_guard lock{database_mutex}; auto it = database.find(program_id); if (it != database.end()) { return it->second; @@ -161,6 +167,7 @@ u64 PlayTimeManager::GetPlayTime(u64 program_id) const { } void PlayTimeManager::SetPlayTime(u64 program_id, u64 play_time) { + std::lock_guard lock{database_mutex}; if (program_id == 0) { return; } @@ -173,6 +180,16 @@ void PlayTimeManager::ResetProgramPlayTime(u64 program_id) { Save(); } +u64 PlayTimeManager::GetDuration() { + using std::chrono::seconds; + using std::chrono::steady_clock; + + const auto now = steady_clock::now(); + const auto duration = std::chrono::duration_cast(now - last_timestamp); + last_timestamp = now; + return static_cast(duration.count()); +} + QString ReadablePlayTime(qulonglong time_seconds) { if (time_seconds == 0) { return {}; diff --git a/src/citron/play_time_manager.h b/src/citron/play_time_manager.h index db770ec996..ec37a9a300 100644 --- a/src/citron/play_time_manager.h +++ b/src/citron/play_time_manager.h @@ -6,7 +6,9 @@ #include +#include #include +#include #include "common/common_funcs.h" #include "common/common_types.h" @@ -36,15 +38,20 @@ public: void SetProgramId(u64 program_id); void Start(); void Stop(); + u64 GetProgramId() const; private: void AutoTimestamp(std::stop_token stop_token); void Save(); PlayTimeDatabase database; + mutable std::mutex database_mutex; u64 running_program_id; std::jthread play_time_thread; + std::chrono::steady_clock::time_point last_timestamp; Service::Account::ProfileManager& manager; + + u64 GetDuration(); }; QString ReadablePlayTime(qulonglong time_seconds); diff --git a/src/citron/ui/game_tree_view.cpp b/src/citron/ui/game_tree_view.cpp index 358f346290..3fcd617bb0 100644 --- a/src/citron/ui/game_tree_view.cpp +++ b/src/citron/ui/game_tree_view.cpp @@ -15,6 +15,8 @@ GameTreeView::GameTreeView(QWidget* parent) : QTreeView(parent) { header()->setStretchLastSection(true); header()->setSectionsMovable(true); ApplyTheme(); + + connect(this, &QTreeView::activated, this, &GameTreeView::itemActivated); } void GameTreeView::ApplyTheme() { @@ -67,8 +69,6 @@ void GameTreeView::onActivated() { void GameTreeView::onCancelled() {} void GameTreeView::mouseDoubleClickEvent(QMouseEvent* event) { - QModelIndex index = indexAt(event->pos()); - if (index.isValid()) emit itemActivated(index); QTreeView::mouseDoubleClickEvent(event); } diff --git a/src/citron/uisettings.h b/src/citron/uisettings.h index 9c876172d9..cc85e92378 100644 --- a/src/citron/uisettings.h +++ b/src/citron/uisettings.h @@ -147,6 +147,10 @@ namespace UISettings { Setting check_for_updates_on_start{linkage, true, "check_for_updates_on_start", Category::Ui}; + // GameBanana Mod Manager + Setting always_ask_manual_extraction{linkage, false, "always_ask_manual_extraction", Category::Ui}; + Setting disable_backup_archives{linkage, false, "disable_backup_archives", Category::Ui}; + // User might not want backups. Allow them to disable/re-enable accordingly. Setting updater_enable_backups{linkage, true, "updater/enableBackups", Category::Ui}; diff --git a/src/common/host_memory.cpp b/src/common/host_memory.cpp index f11ca245ff..b4b759fb66 100644 --- a/src/common/host_memory.cpp +++ b/src/common/host_memory.cpp @@ -490,17 +490,22 @@ public: free_manager.AllocateBlock(virtual_base + virtual_offset, length); // Deduce mapping protection flags. - int prot_flags = PROT_NONE; - if (True(perms & MemoryPermission::Read)) - prot_flags |= PROT_READ; - if (True(perms & MemoryPermission::Write)) - prot_flags |= PROT_WRITE; + int flags = PROT_NONE; + if (True(perms & MemoryPermission::Read)) { + flags |= PROT_READ; + } + if (True(perms & MemoryPermission::Write)) { + flags |= PROT_WRITE; + } #ifdef ARCHITECTURE_arm64 - if (True(perms & MemoryPermission::Execute)) - prot_flags |= PROT_EXEC; + if (True(perms & MemoryPermission::Execute)) { + flags |= PROT_EXEC; + } #endif - int ret = mprotect(virtual_base + virtual_offset, length, prot_flags); - ASSERT_MSG(ret == 0, "mprotect failed: {}", strerror(errno)); + + void* ret = mmap(virtual_base + virtual_offset, length, flags, MAP_SHARED | MAP_FIXED, fd, + host_offset); + ASSERT_MSG(ret != MAP_FAILED, "mmap failed: {}", strerror(errno)); } void Unmap(size_t virtual_offset, size_t length) { @@ -514,8 +519,9 @@ public: auto [merged_pointer, merged_size] = free_manager.FreeBlock(virtual_base + virtual_offset, length); - int ret = mprotect(merged_pointer, merged_size, PROT_NONE); - ASSERT_MSG(ret == 0, "mmap failed: {}", strerror(errno)); + void* ret = mmap(merged_pointer, merged_size, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); + ASSERT_MSG(ret != MAP_FAILED, "mmap failed: {}", strerror(errno)); } void Protect(size_t virtual_offset, size_t length, bool read, bool write, bool execute) {