Merge pull request 'feat(compatibility): Add automated compatibility reporting and remote list syncing' (#106) from feat/compat-list-overhaul into main

Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/106
This commit is contained in:
Collecting
2026-01-21 08:45:48 +01:00
2 changed files with 129 additions and 16 deletions

View File

@@ -26,6 +26,7 @@
#include <QStyle>
#include <QThreadPool>
#include <QToolButton>
#include <QUrlQuery>
#include <QtConcurrent/QtConcurrent>
#include <QFutureWatcher>
#include <fmt/format.h>
@@ -727,11 +728,13 @@ play_time_manager{play_time_manager_}, system{system_} {
online_status_timer = new QTimer(this);
connect(online_status_timer, &QTimer::timeout, this, &GameList::UpdateOnlineStatus);
online_status_timer->start(5000); // Your refresh interval
online_status_timer->start(5000);
// Configure the new timer for debouncing configuration changes
// Configure the timer for debouncing configuration changes
config_update_timer.setSingleShot(true);
connect(&config_update_timer, &QTimer::timeout, this, &GameList::UpdateOnlineStatus);
network_manager = new QNetworkAccessManager(this);
}
void GameList::OnConfigurationChanged() {
@@ -962,6 +965,11 @@ void GameList::DonePopulating(const QStringList& watch_list) {
LOG_INFO(Frontend, "Mirroring: Startup sync skipped (Reason: UI Busy or Game is Emulating).");
}
// Automatically refresh compatibility data from GitHub if enabled
if (UISettings::values.show_compat) {
RefreshCompatibilityList();
}
emit PopulatingCompleted();
}
@@ -976,9 +984,13 @@ void GameList::PopupContextMenu(const QPoint& menu_location) {
const auto selected = item.sibling(item.row(), 0);
QMenu context_menu;
switch (selected.data(GameListItem::TypeRole).value<GameListItemType>()) {
case GameListItemType::Game:
AddGamePopup(context_menu, selected.data(GameListItemPath::ProgramIdRole).toULongLong(), selected.data(GameListItemPath::FullPathRole).toString().toStdString());
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);
break;
}
case GameListItemType::CustomDir:
AddPermDirPopup(context_menu, selected);
AddCustomDirPopup(context_menu, selected);
@@ -1001,7 +1013,7 @@ void GameList::PopupContextMenu(const QPoint& menu_location) {
}
}
void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path) {
void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path, const QString& game_name) {
QAction* favorite = context_menu.addAction(tr("Favorite"));
context_menu.addSeparator();
QAction* start_game = context_menu.addAction(tr("Start Game"));
@@ -1032,6 +1044,7 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
QAction* dump_romfs_sdmc = dump_romfs_menu->addAction(tr("Dump RomFS to SDMC"));
QAction* verify_integrity = context_menu.addAction(tr("Verify Integrity"));
QAction* copy_tid = context_menu.addAction(tr("Copy Title ID to Clipboard"));
QAction* submit_compat_report = context_menu.addAction(tr("Submit Compatibility Report"));
QAction* navigate_to_gamedb_entry = context_menu.addAction(tr("Navigate to GameDB entry"));
#if !defined(__APPLE__)
QMenu* shortcut_menu = context_menu.addMenu(tr("Create Shortcut"));
@@ -1238,6 +1251,34 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
connect(dump_romfs_sdmc, &QAction::triggered, [this, program_id, path]() { emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::SDMC); });
connect(verify_integrity, &QAction::triggered, [this, path]() { emit VerifyIntegrityRequested(path); });
connect(copy_tid, &QAction::triggered, [this, program_id]() { emit CopyTIDRequested(program_id); });
// Logic for GitHub Reporting
connect(submit_compat_report, &QAction::triggered, [this, program_id, game_name]() {
// 1. Show the warning message
const auto reply = QMessageBox::question(this, tr("GitHub Account Required"),
tr("In order to submit a compatibility report, you must have a GitHub account.\n\n"
"If you do not have one, this feature will not work. Would you like to proceed?"),
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) {
return;
}
// 2. Build the minimal URL
const QString clean_tid = QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char('0')).toUpper();
QUrl url(QStringLiteral("https://github.com/CollectingW/Citron-Compatability/issues/new"));
QUrlQuery query;
query.addQueryItem(QStringLiteral("template"), QStringLiteral("compat.yml"));
query.addQueryItem(QStringLiteral("title"), game_name);
query.addQueryItem(QStringLiteral("title_id"), clean_tid);
url.setQuery(query);
// 3. Open the browser
QDesktopServices::openUrl(url);
});
connect(navigate_to_gamedb_entry, &QAction::triggered, [this, program_id]() { emit NavigateToGamedbEntryRequested(program_id, compatibility_list); });
#if !defined(__APPLE__)
connect(create_desktop_shortcut, &QAction::triggered, [this, program_id, path]() { emit CreateShortcut(program_id, path, GameListShortcutTarget::Desktop); });
@@ -1304,37 +1345,57 @@ void GameList::AddFavoritesPopup(QMenu& context_menu) {
}
void GameList::LoadCompatibilityList() {
QFile compat_list{QStringLiteral(":compatibility_list/compatibility_list.json")};
// Clear existing entries to allow for a clean refresh
compatibility_list.clear();
// Look for a downloaded list in the config directory first
const auto config_dir = QString::fromStdString(Common::FS::GetCitronPathString(Common::FS::CitronPath::ConfigDir));
const QString local_path = QDir(config_dir).filePath(QStringLiteral("compatibility_list.json"));
QFile compat_list;
if (QFile::exists(local_path)) {
compat_list.setFileName(local_path);
LOG_INFO(Frontend, "Loading compatibility list from: {}", local_path.toStdString());
} else {
// Fallback to the internal baked-in resource
compat_list.setFileName(QStringLiteral(":compatibility_list/compatibility_list.json"));
LOG_INFO(Frontend, "No local compatibility list found, using internal resource.");
}
if (!compat_list.open(QFile::ReadOnly | QFile::Text)) {
LOG_ERROR(Frontend, "Unable to open game compatibility list");
return;
}
if (compat_list.size() == 0) {
LOG_WARNING(Frontend, "Game compatibility list is empty");
return;
}
const QByteArray content = compat_list.readAll();
if (content.isEmpty()) {
LOG_ERROR(Frontend, "Unable to completely read game compatibility list");
LOG_ERROR(Frontend, "Game compatibility list is empty or unreadable");
return;
}
const QJsonDocument json = QJsonDocument::fromJson(content);
const QJsonArray arr = json.array();
for (const QJsonValue value : arr) {
const QJsonObject game = value.toObject();
const QString compatibility_key = QStringLiteral("compatibility");
if (!game.contains(compatibility_key) || !game[compatibility_key].isDouble()) {
continue;
}
// Match the legacy parser logic
if (!game.contains(compatibility_key)) continue;
const int compatibility = game[compatibility_key].toInt();
const QString directory = game[QStringLiteral("directory")].toString();
const QJsonArray ids = game[QStringLiteral("releases")].toArray();
for (const QJsonValue id_ref : ids) {
const QJsonObject id_object = id_ref.toObject();
const QString id = id_object[QStringLiteral("id")].toString();
compatibility_list.emplace(id.toUpper().toStdString(), std::make_pair(QString::number(compatibility), directory));
if (id.isEmpty()) continue;
compatibility_list.insert_or_assign(id.toUpper().toStdString(),
std::make_pair(QString::number(compatibility), directory));
}
}
LOG_INFO(Frontend, "Loaded {} compatibility entries.", compatibility_list.size());
}
void GameList::changeEvent(QEvent* event) {
@@ -1713,3 +1774,50 @@ void GameList::UpdateProgressBarColor() {
).arg(accent.name()));
}
}
void GameList::RefreshCompatibilityList() {
const QUrl url(QStringLiteral("https://raw.githubusercontent.com/CollectingW/Citron-Compatability/refs/heads/main/compatibility_list.json"));
QNetworkRequest request(url);
QNetworkReply* reply = network_manager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
if (reply->error() == QNetworkReply::NoError) {
const QByteArray json_data = reply->readAll();
const auto config_dir = QString::fromStdString(Common::FS::GetCitronPathString(Common::FS::CitronPath::ConfigDir));
const QString local_path = QDir(config_dir).filePath(QStringLiteral("compatibility_list.json"));
QFile file(local_path);
if (file.open(QFile::WriteOnly)) {
file.write(json_data);
file.close();
LOG_INFO(Frontend, "Successfully updated compatibility list from GitHub.");
LoadCompatibilityList();
// Refresh the UI by replacing the old compatibility items with new ones
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) {
QStandardItem* game_item = folder->child(j, 0);
if (!game_item || game_item->data(GameListItem::TypeRole).value<GameListItemType>() != GameListItemType::Game) {
continue;
}
u64 program_id = game_item->data(GameListItemPath::ProgramIdRole).toULongLong();
auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id);
if (it != compatibility_list.end()) {
folder->setChild(j, COLUMN_COMPATIBILITY, new GameListItemCompat(it->second.first));
}
}
}
}
} else {
LOG_ERROR(Frontend, "Failed to download compatibility list: {}", reply->errorString().toStdString());
}
reply->deleteLater();
});
}

View File

@@ -23,6 +23,8 @@
#include <QVBoxLayout>
#include <QVector>
#include <QWidget>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include "common/common_types.h"
#include "core/core.h"
@@ -178,7 +180,7 @@ 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);
void AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path, const QString& game_name);
void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected);
void AddPermDirPopup(QMenu& context_menu, QModelIndex selected);
void AddFavoritesPopup(QMenu& context_menu);
@@ -210,6 +212,9 @@ private:
QTimer* online_status_timer;
QTimer config_update_timer;
QNetworkAccessManager* network_manager = nullptr;
void RefreshCompatibilityList();
friend class GameListSearchField;
const PlayTime::PlayTimeManager& play_time_manager;