diff --git a/src/Eden/Interface/CMakeLists.txt b/src/Eden/Interface/CMakeLists.txt index f5ef364bb3..069afd34ea 100644 --- a/src/Eden/Interface/CMakeLists.txt +++ b/src/Eden/Interface/CMakeLists.txt @@ -4,21 +4,16 @@ # SPDX-FileCopyrightText: Copyright 2025 crueter # SPDX-License-Identifier: GPL-3.0-or-later -find_package(Qt6 REQUIRED COMPONENTS Core) - -qt_add_library(EdenInterface STATIC) -qt_add_qml_module(EdenInterface +EdenModule( + NAME Interface URI Eden.Interface - NO_PLUGIN SOURCES SettingsInterface.h SettingsInterface.cpp QMLSetting.h QMLSetting.cpp MetaObjectHelper.h QMLConfig.h - SOURCES TitleManager.h TitleManager.cpp + TitleManager.h TitleManager.cpp + LIBRARIES + Qt6::Quick + Qt6::Core ) - -target_link_libraries(EdenInterface PUBLIC Qt6::Quick) -target_link_libraries(EdenInterface PRIVATE Qt6::Core) - -add_library(Eden::Interface ALIAS EdenInterface) diff --git a/src/Eden/Main/GameCarouselCard.qml b/src/Eden/Main/GameCarouselCard.qml index ef0e7e30bd..3e630d4453 100644 --- a/src/Eden/Main/GameCarouselCard.qml +++ b/src/Eden/Main/GameCarouselCard.qml @@ -25,7 +25,10 @@ Item { id: image fillMode: Image.PreserveAspectFit - source: "file://" + model.path + source: "image://games/" + model.name + + sourceSize.width: width + sourceSize.height: height clip: true diff --git a/src/Eden/Main/GameGridCard.qml b/src/Eden/Main/GameGridCard.qml index 3e379c1d11..a20336c0b8 100644 --- a/src/Eden/Main/GameGridCard.qml +++ b/src/Eden/Main/GameGridCard.qml @@ -14,8 +14,7 @@ Rectangle { Image { id: image - fillMode: Image.PreserveAspectFit - source: "file://" + model.path + source: "image://games/" + model.name clip: true @@ -28,6 +27,9 @@ Rectangle { margins: 10 } + sourceSize.width: width + sourceSize.height: height + height: parent.height MouseArea { diff --git a/src/Eden/Main/GameList.qml b/src/Eden/Main/GameList.qml index dedcb33f4d..a657b989a8 100644 --- a/src/Eden/Main/GameList.qml +++ b/src/Eden/Main/GameList.qml @@ -61,16 +61,6 @@ Rectangle { // repeat: true // onTriggered: gamepad.pollEvents() // } - FolderDialog { - id: openDir - folder: StandardPaths.writableLocation(StandardPaths.HomeLocation) - onAccepted: { - button.visible = false - view.anchors.bottom = root.bottom - EdenGameList.addDir(folder) - } - } - Item { id: view @@ -102,26 +92,4 @@ Rectangle { // } // } } - - Button { - id: button - font.pixelSize: 25 - - anchors { - left: parent.left - right: parent.right - - bottom: parent.bottom - - margins: 8 - } - - text: "Add Directory" - onClicked: openDir.open() - - background: Rectangle { - color: button.pressed ? Constants.accentPressed : Constants.accent - radius: 5 - } - } } diff --git a/src/Eden/Models/CMakeLists.txt b/src/Eden/Models/CMakeLists.txt index f71aa78fe4..5aad3758f0 100644 --- a/src/Eden/Models/CMakeLists.txt +++ b/src/Eden/Models/CMakeLists.txt @@ -4,14 +4,19 @@ # SPDX-FileCopyrightText: Copyright 2025 crueter # SPDX-License-Identifier: GPL-3.0-or-later +find_package(Qt6 REQUIRED COMPONENTS Core) + qt_add_library(EdenModels STATIC GameListModel.h GameListModel.cpp SettingsModel.h SettingsModel.cpp + GameListWorker.h GameListWorker.cpp + GameIconProvider.h GameIconProvider.cpp ) target_link_libraries(EdenModels PRIVATE Qt6::Gui ) +target_link_libraries(EdenModels PRIVATE Qt6::Core Qt6::Quick) add_library(Eden::Models ALIAS EdenModels) diff --git a/src/Eden/Models/GameIconProvider.cpp b/src/Eden/Models/GameIconProvider.cpp new file mode 100644 index 0000000000..f64ccf1efd --- /dev/null +++ b/src/Eden/Models/GameIconProvider.cpp @@ -0,0 +1,45 @@ +#include +#include "GameIconProvider.h" +#include "qt_common/uisettings.h" + +/** + * Gets the default icon (for games without valid title metadata) + * @param size The desired width and height of the default icon. + * @return QPixmap default icon + */ +static QPixmap GetDefaultIcon(const QSize &size) +{ + QPixmap icon(size.width(), size.height()); + icon.fill(Qt::transparent); + return icon; +} + +GameIconProvider::GameIconProvider() + : QQuickImageProvider(QQuickImageProvider::Pixmap) +{} + +QPixmap GameIconProvider::requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) +{ + const u32 default_size = UISettings::values.game_icon_size.GetValue(); + QSize trueSize = QSize(default_size, default_size); + if (requestedSize.isValid()) { + trueSize = requestedSize; + } + + QPixmap pixmap = m_pixmaps.value(id, GetDefaultIcon(trueSize)); + + if (size) + *size = QSize(trueSize.width(), trueSize.height()); + + return pixmap; +} + +void GameIconProvider::addPixmap(const QString &key, const QPixmap pixmap) +{ + m_pixmaps.insert(key, pixmap); +} + +void GameIconProvider::clear() +{ + m_pixmaps.clear(); +} diff --git a/src/Eden/Models/GameIconProvider.h b/src/Eden/Models/GameIconProvider.h new file mode 100644 index 0000000000..10dbcc948d --- /dev/null +++ b/src/Eden/Models/GameIconProvider.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +class GameIconProvider : public QQuickImageProvider +{ +public: + GameIconProvider(); + + QPixmap requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) override; + + void addPixmap(const QString &key, const QPixmap pixmap); + void clear(); +private: + QMap m_pixmaps; +}; diff --git a/src/Eden/Models/GameListModel.cpp b/src/Eden/Models/GameListModel.cpp index 61c51ac08f..413e9b9f4c 100644 --- a/src/Eden/Models/GameListModel.cpp +++ b/src/Eden/Models/GameListModel.cpp @@ -1,16 +1,40 @@ #include "GameListModel.h" #include +#include +#include -const QStringList GameListModel::ValidSuffixes{"jpg", "png", "webp", "jpeg"}; +#include "GameIconProvider.h" +#include "GameListWorker.h" +#include "common/logging/filter.h" +#include "core/hle/service/filesystem/filesystem.h" +#include "hid_core/hid_core.h" +#include "qt_common/qt_common.h" +#include "qt_common/qt_meta.h" -GameListModel::GameListModel(QObject *parent) { +GameListModel::GameListModel(QObject *parent, QQmlEngine *engine) { QHash rez = QStandardItemModel::roleNames(); rez.insert(GLMRoleTypes::NAME, "name"); rez.insert(GLMRoleTypes::PATH, "path"); rez.insert(GLMRoleTypes::FILESIZE, "size"); + rez.insert(GLMRoleTypes::ICON, "icon"); QStandardItemModel::setItemRoleNames(rez); + + QtCommon::Meta::RegisterMetaTypes(); + QtCommon::system->HIDCore().ReloadInputDevices(); + QtCommon::system->SetContentProvider(std::make_unique()); + QtCommon::system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, + QtCommon::provider.get()); + QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs); + + m_provider = new GameIconProvider; + engine->addImageProvider(QStringLiteral("games"), m_provider); + + watcher = new QFileSystemWatcher(this); + connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameListModel::RefreshGameDirectory); + + populateAsync(UISettings::values.game_dirs); } QVariant GameListModel::data(const QModelIndex &index, int role) const @@ -25,43 +49,92 @@ QVariant GameListModel::data(const QModelIndex &index, int role) const return QStandardItemModel::data(index, role); } -void GameListModel::addDir(const QString &toAdd) +// void GameListModel::addDir(const QString &toAdd) +// { +// QString name = toAdd; +// #ifdef Q_OS_WINDOWS +// name.replace("file:///", ""); +// #else +// name.replace("file://", ""); +// #endif + +// UISettings::GameDir game_dir{name.toStdString(), false, true}; +// if (!UISettings::values.game_dirs.contains(game_dir)) { +// UISettings::values.game_dirs.append(game_dir); +// populateAsync(UISettings::values.game_dirs); +// } else { +// LOG_WARNING(Frontend, "Selected directory is already in the game list"); +// } + +// QtCommon::system->ApplySettings(); + +// // TODO +// // config->SaveAllValues(); +// } + +void GameListModel::RefreshGameDirectory() { - QString name = toAdd; -#ifdef Q_OS_WINDOWS - name.replace("file:///", ""); -#else - name.replace("file://", ""); -#endif - - m_dirs << name; - reload(); + if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) { + LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); + populateAsync(UISettings::values.game_dirs); + } } -void GameListModel::removeDir(const QString &toRemove) -{ - m_dirs.removeAll(toRemove); - reload(); +void GameListModel::addEntry(QStandardItem *entry, const UISettings::GameDir &parent_dir) { + // TODO: Directory grouping + QString text = entry->data(GLMRoleTypes::NAME).toString(); + QPixmap pixmap = entry->data(GLMRoleTypes::ICON).value(); + + qDebug() << "Adding pixmap" << text; + m_provider->addPixmap(text, pixmap); + invisibleRootItem()->appendRow(entry); } -void GameListModel::reload() -{ - clear(); - for (const QString &dir : std::as_const(m_dirs)) { - qDebug() << dir; - for (const auto &entry : QDirListing(dir, QDirListing::IteratorFlag::FilesOnly)) { - if (ValidSuffixes.contains(entry.completeSuffix().toLower())) { - QString path = entry.absoluteFilePath(); - QString name = entry.baseName(); - qreal size = entry.size(); - QString sizeString = QLocale::system().formattedDataSize(size); - - QStandardItem *game = new QStandardItem(name); - game->setData(path, GLMRoleTypes::PATH); - game->setData(sizeString, GLMRoleTypes::FILESIZE); - - invisibleRootItem()->appendRow(game); - } - } +// TODO +void GameListModel::addDirEntry(const UISettings::GameDir &dir) {} + +// TODO +void GameListModel::donePopulating(QStringList watch_list) { + // emit ShowList(!empt()); + + // Clear out the old directories to watch for changes and add the new ones + auto watch_dirs = watcher->directories(); + if (!watch_dirs.isEmpty()) { + watcher->removePaths(watch_dirs); + } + // Workaround: Add the watch paths in chunks to allow the gui to refresh + // This prevents the UI from stalling when a large number of watch paths are added + // Also artificially caps the watcher to a certain number of directories + constexpr int LIMIT_WATCH_DIRECTORIES = 5000; + constexpr int SLICE_SIZE = 25; + int len = (std::min)(static_cast(watch_list.size()), LIMIT_WATCH_DIRECTORIES); + for (int i = 0; i < len; i += SLICE_SIZE) { + watcher->addPaths(watch_list.mid(i, i + SLICE_SIZE)); + QGuiApplication::processEvents(); } } + +// TODO: Disable view +void GameListModel::populateAsync(QVector &game_dirs) { + // Cancel any existing worker. + current_worker.reset(); + + /// clear image provider + m_provider->clear(); + + // Delete any rows that might already exist if we're repopulating + removeRows(0, rowCount()); + + current_worker = std::make_unique(game_dirs); + + // Get events from the worker as data becomes available + connect(current_worker.get(), &GameListWorker::DataAvailable, this, &GameListModel::WorkerEvent, + Qt::QueuedConnection); + + QThreadPool::globalInstance()->start(current_worker.get()); +} + +// Worker-related slots +void GameListModel::WorkerEvent() { + current_worker->ProcessEvents(this); +} diff --git a/src/Eden/Models/GameListModel.h b/src/Eden/Models/GameListModel.h index fb6ce62955..2ed6c54b6b 100644 --- a/src/Eden/Models/GameListModel.h +++ b/src/Eden/Models/GameListModel.h @@ -1,8 +1,11 @@ #ifndef GAMELISTMODEL_H #define GAMELISTMODEL_H +#include #include +#include #include +#include "qt_common/uisettings.h" typedef struct Game { QString absPath; @@ -10,6 +13,9 @@ typedef struct Game { QString fileSize; } Game; +class GameListWorker; +class GameIconProvider; + class GameListModel : public QStandardItemModel { Q_OBJECT @@ -17,23 +23,31 @@ public: enum GLMRoleTypes { NAME = Qt::UserRole + 1, PATH, - FILESIZE + FILESIZE, + ICON }; - GameListModel(QObject *parent = nullptr); + GameListModel(QObject *parent, QQmlEngine *engine); QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - Q_INVOKABLE void addDir(const QString &toAdd); - Q_INVOKABLE void removeDir(const QString &toRemove); + void addEntry(QStandardItem *entry, const UISettings::GameDir &parent_dir); + void addDirEntry(const UISettings::GameDir &dir); + void donePopulating(QStringList watch_list); + void populateAsync(QVector &game_dirs); + + void RefreshGameDirectory(); - static const QStringList ValidSuffixes; +private slots: + void WorkerEvent(); private: QStringList m_dirs; QList m_data; + QFileSystemWatcher *watcher = nullptr; + std::unique_ptr current_worker; - void reload(); + GameIconProvider *m_provider; }; #endif // GAMELISTMODEL_H diff --git a/src/Eden/Models/GameListWorker.cpp b/src/Eden/Models/GameListWorker.cpp new file mode 100644 index 0000000000..b5697885b3 --- /dev/null +++ b/src/Eden/Models/GameListWorker.cpp @@ -0,0 +1,512 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include + +#include "GameListModel.h" +#include "common/logging/types.h" + +#include +#include +#include +#include +#include + +#include "GameListWorker.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "core/core.h" +#include "core/file_sys/card_image.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.h" +#include "core/file_sys/fs_filesystem.h" +#include "core/file_sys/nca_metadata.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/submission_package.h" +#include "core/loader/loader.h" +#include "qt_common/qt_common.h" +#include "qt_common/uisettings.h" + +namespace { + +/** + * Gets the default icon (for games without valid title metadata) + * @param size The desired width and height of the default icon. + * @return QPixmap default icon + */ +static QPixmap GetDefaultIcon(u32 size) { + QPixmap icon(size, size); + icon.fill(Qt::transparent); + return icon; +} + +QString GetGameListCachedObject(const std::string& filename, + const std::string& ext, + const std::function& generator) +{ + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto path = Common::FS::PathToUTF8String( + Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "game_list" + / fmt::format("{}.{}", filename, ext)); + + void(Common::FS::CreateParentDirs(path)); + + if (!Common::FS::Exists(path)) { + const auto str = generator(); + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::WriteOnly)) { + file.write(str.toUtf8()); + } + + return str; + } + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::ReadOnly)) { + return QString::fromUtf8(file.readAll()); + } + + return generator(); +} + +std::pair, std::string> GetGameListCachedObject( + const std::string& filename, + const std::string& ext, + const std::function, std::string>()>& generator) +{ + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto game_list_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) + / "game_list"; + const auto jpeg_name = fmt::format("{}.jpeg", filename); + const auto app_name = fmt::format("{}.appname.txt", filename); + + const auto path1 = Common::FS::PathToUTF8String(game_list_dir / jpeg_name); + const auto path2 = Common::FS::PathToUTF8String(game_list_dir / app_name); + + void(Common::FS::CreateParentDirs(path1)); + + if (!Common::FS::Exists(path1) || !Common::FS::Exists(path2)) { + const auto [icon, nacp] = generator(); + + QFile file1{QString::fromStdString(path1)}; + if (!file1.open(QFile::WriteOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file."); + return generator(); + } + + if (!file1.resize(icon.size())) { + LOG_ERROR(Frontend, "Failed to resize cache file to necessary size."); + return generator(); + } + + if (file1.write(reinterpret_cast(icon.data()), icon.size()) + != s64(icon.size())) { + LOG_ERROR(Frontend, "Failed to write data to cache file."); + return generator(); + } + + QFile file2{QString::fromStdString(path2)}; + if (file2.open(QFile::WriteOnly)) { + file2.write(nacp.data(), nacp.size()); + } + + return std::make_pair(icon, nacp); + } + + QFile file1(QString::fromStdString(path1)); + QFile file2(QString::fromStdString(path2)); + + if (!file1.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + if (!file2.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + std::vector vec(file1.size()); + if (file1.read(reinterpret_cast(vec.data()), vec.size()) + != static_cast(vec.size())) { + return generator(); + } + + const auto data = file2.readAll(); + return std::make_pair(vec, data.toStdString()); +} + +void GetMetadataFromControlNCA(const FileSys::PatchManager& patch_manager, + const FileSys::NCA& nca, + std::vector& icon, + std::string& name) +{ + std::tie(icon, name) = GetGameListCachedObject( + fmt::format("{:016X}", patch_manager.GetTitleID()), {}, [&patch_manager, &nca] { + const auto [nacp, icon_f] = patch_manager.ParseControlNCA(nca); + return std::make_pair(icon_f->ReadAllBytes(), nacp->GetApplicationName()); + }); +} + +bool HasSupportedFileExtension(const std::string& file_name) +{ + const QFileInfo file = QFileInfo(QString::fromStdString(file_name)); + return QtCommon::supported_file_extensions.contains(file.suffix(), Qt::CaseInsensitive); +} + +bool IsExtractedNCAMain(const std::string& file_name) +{ + return QFileInfo(QString::fromStdString(file_name)).fileName() == QStringLiteral("main"); +} + +// QString FormatGameName(const std::string& physical_name) +// { +// const QString physical_name_as_qstring = QString::fromStdString(physical_name); +// const QFileInfo file_info(physical_name_as_qstring); + +// if (IsExtractedNCAMain(physical_name)) { +// return file_info.dir().path(); +// } + +// return physical_name_as_qstring; +// } + +QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager, + Loader::AppLoader& loader, + bool updatable = true) +{ + QString out; + FileSys::VirtualFile update_raw; + loader.ReadUpdateRaw(update_raw); + for (const auto& patch : patch_manager.GetPatches(update_raw)) { + const bool is_update = patch.name == "Update"; + if (!updatable && is_update) { + continue; + } + + const QString type = QString::fromStdString(patch.enabled ? patch.name + : "[D] " + patch.name); + + if (patch.version.empty()) { + out.append(QStringLiteral("%1\n").arg(type)); + } else { + auto ver = patch.version; + + // Display container name for packed updates + if (is_update && ver == "PACKED") { + ver = Loader::GetFileTypeString(loader.GetFileType()); + } + + out.append(QStringLiteral("%1 (%2)\n").arg(type, QString::fromStdString(ver))); + } + } + + out.chop(1); + return out; +} + +QStandardItem* MakeGameListEntry(const std::string& path, + const std::string& name, + const std::size_t size, + const std::vector& icon, + Loader::AppLoader& loader, + u64 program_id, + const FileSys::PatchManager& patch) +{ + auto const file_type = loader.GetFileType(); + auto const file_type_string = QString::fromStdString(Loader::GetFileTypeString(file_type)); + + QString patch_versions = GetGameListCachedObject( + fmt::format("{:016X}", patch.GetTitleID()), "pv.txt", [&patch, &loader] { + return FormatPatchNameVersions(patch, loader, loader.IsRomFSUpdatable()); + }); + + QStandardItem* item = new QStandardItem(QString::fromStdString(name)); + enum GLMRoleTypes { NAME = Qt::UserRole + 1, PATH, FILESIZE }; + + item->setData(QString::fromStdString(name), GameListModel::NAME); + item->setData(QString::fromStdString(path), GameListModel::PATH); + + const u32 pic_size = UISettings::values.game_icon_size.GetValue(); + + QPixmap picture; + if (!picture.loadFromData(icon.data(), static_cast(icon.size()))) { + picture = GetDefaultIcon(pic_size); + } + picture = picture.scaled(pic_size, pic_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + item->setData(picture, GameListModel::ICON); + // item->setData(QVariant::fromValue(size), GameListModel::FILESIZE); + + return item; +} + +} // Anonymous namespace + +GameListWorker::GameListWorker(QVector& game_dirs_) + : game_dirs{game_dirs_} +{ + // We want the game list to manage our lifetime. + setAutoDelete(false); +} + +GameListWorker::~GameListWorker() +{ + this->disconnect(); + stop_requested.store(true); + processing_completed.Wait(); +} + +void GameListWorker::ProcessEvents(GameListModel* game_list) +{ + while (true) { + std::function func; + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // If we can't pop a function, return. + if (queued_events.empty()) { + return; + } + + // Pop a function. + func = std::move(queued_events.back()); + queued_events.pop_back(); + } + + // Run the function. + func(game_list); + } +} + +template +void GameListWorker::RecordEvent(F&& func) +{ + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // Add the function into the front of the queue. + queued_events.emplace_front(std::move(func)); + } + + // Data now available. + emit DataAvailable(); +} + +void GameListWorker::AddTitlesToGameList(UISettings::GameDir& parent_dir) +{ + using namespace FileSys; + + const auto& cache = QtCommon::system->GetContentProviderUnion(); + + auto installed_games = cache.ListEntriesFilterOrigin(std::nullopt, + TitleType::Application, + ContentRecordType::Program); + + for (const auto& [slot, game] : installed_games) { + if (slot == ContentProviderUnionSlot::FrontendManual) { + continue; + } + + const auto file = cache.GetEntryUnparsed(game.title_id, game.type); + std::unique_ptr loader = Loader::GetLoader(*QtCommon::system, file); + if (!loader) { + continue; + } + + std::vector icon; + std::string name; + u64 program_id = 0; + const auto result = loader->ReadProgramId(program_id); + + if (result != Loader::ResultStatus::Success) { + continue; + } + + const PatchManager patch{program_id, + QtCommon::system->GetFileSystemController(), + QtCommon::system->GetContentProvider()}; + LOG_INFO(Frontend, "PatchManager initiated for id {:X}", program_id); + const auto control = cache.GetEntry(game.title_id, ContentRecordType::Control); + if (control != nullptr) { + GetMetadataFromControlNCA(patch, *control, icon, name); + } + + auto entry = MakeGameListEntry(file->GetFullPath(), + name, + file->GetSize(), + icon, + *loader, + program_id, + patch); + RecordEvent([=](GameListModel* game_list) { game_list->addEntry(entry, parent_dir); }); + } +} + +void GameListWorker::ScanFileSystem(ScanTarget target, + const std::string& dir_path, + bool deep_scan, + UISettings::GameDir& parent_dir) +{ + const auto callback = [this, target, parent_dir](const std::filesystem::path& path) -> bool { + if (stop_requested) { + // Breaks the callback loop. + return false; + } + + const auto physical_name = Common::FS::PathToUTF8String(path); + const auto is_dir = Common::FS::IsDir(path); + + if (!is_dir + && (HasSupportedFileExtension(physical_name) || IsExtractedNCAMain(physical_name))) { + const auto file = QtCommon::vfs->OpenFile(physical_name, FileSys::OpenMode::Read); + if (!file) { + return true; + } + + auto loader = Loader::GetLoader(*QtCommon::system, file); + if (!loader) { + return true; + } + + const auto file_type = loader->GetFileType(); + if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error) { + return true; + } + + u64 program_id = 0; + const auto res2 = loader->ReadProgramId(program_id); + + if (target == ScanTarget::FillManualContentProvider) { + if (res2 == Loader::ResultStatus::Success && file_type == Loader::FileType::NCA) { + QtCommon::provider->AddEntry(FileSys::TitleType::Application, + FileSys::GetCRTypeFromNCAType( + FileSys::NCA{file}.GetType()), + program_id, + file); + } else if (res2 == Loader::ResultStatus::Success + && (file_type == Loader::FileType::XCI + || file_type == Loader::FileType::NSP)) { + const auto nsp = file_type == Loader::FileType::NSP + ? std::make_shared(file) + : FileSys::XCI{file}.GetSecurePartitionNSP(); + for (const auto& title : nsp->GetNCAs()) { + for (const auto& entry : title.second) { + QtCommon::provider->AddEntry(entry.first.first, + entry.first.second, + title.first, + entry.second->GetBaseFile()); + } + } + } + } else { + std::vector program_ids; + loader->ReadProgramIds(program_ids); + + if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 + && (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { + for (const auto id : program_ids) { + loader = Loader::GetLoader(*QtCommon::system, file, id); + if (!loader) { + continue; + } + + std::vector icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{id, + QtCommon::system->GetFileSystemController(), + QtCommon::system->GetContentProvider()}; + + auto entry = MakeGameListEntry(physical_name, + name, + Common::FS::GetSize(physical_name), + icon, + *loader, + id, + patch); + + RecordEvent([=](GameListModel* game_list) { + game_list->addEntry(entry, parent_dir); + }); + } + } else { + std::vector icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{program_id, + QtCommon::system->GetFileSystemController(), + QtCommon::system->GetContentProvider()}; + + auto entry = MakeGameListEntry(physical_name, + name, + Common::FS::GetSize(physical_name), + icon, + *loader, + program_id, + patch); + + RecordEvent( + [=](GameListModel* game_list) { game_list->addEntry(entry, parent_dir); }); + } + } + } else if (is_dir) { + watch_list.append(QString::fromStdString(physical_name)); + } + + return true; + }; + + if (deep_scan) { + Common::FS::IterateDirEntriesRecursively(dir_path, + callback, + Common::FS::DirEntryFilter::All); + } else { + Common::FS::IterateDirEntries(dir_path, callback, Common::FS::DirEntryFilter::File); + } +} + +void GameListWorker::run() +{ + watch_list.clear(); + QtCommon::provider->ClearAllEntries(); + + const auto DirEntryReady = [&](UISettings::GameDir& game_list_dir) { + RecordEvent([=](GameListModel* game_list) { game_list->addDirEntry(game_list_dir); }); + }; + + for (UISettings::GameDir& game_dir : game_dirs) { + if (stop_requested) { + break; + } + + watch_list.append(QString::fromStdString(game_dir.path)); + DirEntryReady(game_dir); + ScanFileSystem(ScanTarget::FillManualContentProvider, game_dir.path, game_dir.deep_scan, + game_dir); + ScanFileSystem(ScanTarget::PopulateGameList, game_dir.path, game_dir.deep_scan, + game_dir); + } + + RecordEvent([this](GameListModel* game_list) { game_list->donePopulating(watch_list); }); + processing_completed.Set(); +} diff --git a/src/Eden/Models/GameListWorker.h b/src/Eden/Models/GameListWorker.h new file mode 100644 index 0000000000..20b3594415 --- /dev/null +++ b/src/Eden/Models/GameListWorker.h @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "common/thread.h" +#include "core/file_sys/registered_cache.h" +#include "qt_common/uisettings.h" + +namespace Core { class System; } + +class GameListModel; +class QStandardItem; + +namespace FileSys { +class NCA; +class VfsFilesystem; +} // namespace FileSys + +/** + * Asynchronous worker object for populating the game list. + * Communicates with other threads through Qt's signal/slot system. + */ +class GameListWorker : public QObject, public QRunnable { + Q_OBJECT + +public: + explicit GameListWorker(QVector& game_dirs_); + ~GameListWorker() override; + + /// Starts the processing of directory tree information. + void run() override; + +public: + /** + * Synchronously processes any events queued by the worker. + * + * AddDirEntry is called on the game list for every discovered directory. + * AddEntry is called on the game list for every discovered program. + * DonePopulating is called on the game list when processing completes. + */ + void ProcessEvents(GameListModel* game_list); + +signals: + void DataAvailable(); + +private: + template + void RecordEvent(F&& func); + +private: + void AddTitlesToGameList(UISettings::GameDir& parent_dir); + + enum class ScanTarget { + FillManualContentProvider, + PopulateGameList, + }; + + void ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + UISettings::GameDir& parent_dir); + + QVector& game_dirs; + + QStringList watch_list; + + std::mutex lock; + std::condition_variable cv; + std::deque> queued_events; + std::atomic_bool stop_requested = false; + Common::Event processing_completed; +}; diff --git a/src/Eden/Native/main.cpp b/src/Eden/Native/main.cpp index 5dd0446fec..44f07e9fb7 100644 --- a/src/Eden/Native/main.cpp +++ b/src/Eden/Native/main.cpp @@ -6,8 +6,10 @@ #include "Interface/TitleManager.h" #include "Models/GameListModel.h" #include "core/core.h" +#include "qt_common/qt_common.h" #include +#include int main(int argc, char *argv[]) { @@ -20,14 +22,17 @@ int main(int argc, char *argv[]) QApplication::setDesktopFileName(QStringLiteral("org.eden-emu.eden")); QGuiApplication::setWindowIcon(QIcon(":/icons/eden.svg")); + /// QtCommon + QtCommon::Init(new QWidget); + /// Settings, etc Settings::SetConfiguringGlobal(true); QMLConfig *config = new QMLConfig; - // // TODO: Save all values on launch and per game etc - // app.connect(&app, &QCoreApplication::aboutToQuit, &app, [config]() { - // config->save(); - // }); + // TODO: Save all values on launch and per game etc + app.connect(&app, &QCoreApplication::aboutToQuit, &app, [config]() { + config->save(); + }); /// Expose Enums @@ -44,7 +49,7 @@ int main(int argc, char *argv[]) qmlRegisterUncreatableMetaObject(SettingsCategories::staticMetaObject, "Eden.Interface", 1, 0, "SettingsCategories", QString()); // Directory List - GameListModel *gameListModel = new GameListModel(&app); + GameListModel *gameListModel = new GameListModel(&app, &engine); ctx->setContextProperty(QStringLiteral("EdenGameList"), gameListModel); // Settings Interface diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index 94599532b3..1513fb688b 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -331,8 +331,6 @@ target_link_options(video_core PRIVATE ${FFmpeg_LDFLAGS}) add_dependencies(video_core host_shaders) target_include_directories(video_core PRIVATE ${HOST_SHADERS_INCLUDE}) -target_link_libraries(video_core PRIVATE sirit::sirit) - # Header-only stuff needed by all dependent targets target_link_libraries(video_core PUBLIC Vulkan::UtilityHeaders GPUOpen::VulkanMemoryAllocator)