From 0f3b7da0b21586e2c916d63b48a8e3e52f3b130d Mon Sep 17 00:00:00 2001 From: ITotalJustice <47043333+ITotalJustice@users.noreply.github.com> Date: Wed, 30 Apr 2025 22:45:52 +0100 Subject: [PATCH] fix memleak when deleting homebrew, add game menu. --- sphaira/CMakeLists.txt | 1 + sphaira/include/ui/menus/game_menu.hpp | 62 ++++++ sphaira/include/ui/menus/homebrew.hpp | 16 +- sphaira/source/app.cpp | 5 + sphaira/source/ui/menus/game_menu.cpp | 287 +++++++++++++++++++++++++ sphaira/source/ui/menus/homebrew.cpp | 23 +- 6 files changed, 382 insertions(+), 12 deletions(-) create mode 100644 sphaira/include/ui/menus/game_menu.hpp create mode 100644 sphaira/source/ui/menus/game_menu.cpp diff --git a/sphaira/CMakeLists.txt b/sphaira/CMakeLists.txt index f10843f..e4ca6bc 100644 --- a/sphaira/CMakeLists.txt +++ b/sphaira/CMakeLists.txt @@ -49,6 +49,7 @@ add_executable(sphaira source/ui/menus/usb_menu.cpp source/ui/menus/ftp_menu.cpp source/ui/menus/gc_menu.cpp + source/ui/menus/game_menu.cpp source/ui/error_box.cpp source/ui/notification.cpp diff --git a/sphaira/include/ui/menus/game_menu.hpp b/sphaira/include/ui/menus/game_menu.hpp new file mode 100644 index 0000000..e46954b --- /dev/null +++ b/sphaira/include/ui/menus/game_menu.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "ui/menus/menu_base.hpp" +#include "ui/list.hpp" +#include "fs.hpp" +#include "option.hpp" +#include + +namespace sphaira::ui::menu::game { + +enum class NacpLoadStatus { + // not yet attempted to be loaded. + None, + // loaded, ready to parse. + Loaded, + // failed to load, do not attempt to load again! + Error, +}; + +struct Entry { + u64 app_id{}; + char display_version[0x10]{}; + NacpLanguageEntry lang{}; + int image{}; + + std::unique_ptr control{}; + u64 control_size{}; + NacpLoadStatus status{NacpLoadStatus::None}; + + auto GetName() const -> const char* { + return lang.name; + } + + auto GetAuthor() const -> const char* { + return lang.author; + } + + auto GetDisplayVersion() const -> const char* { + return display_version; + } +}; + +struct Menu final : MenuBase { + Menu(); + ~Menu(); + + void Update(Controller* controller, TouchInfo* touch) override; + void Draw(NVGcontext* vg, Theme* theme) override; + void OnFocusGained() override; + +private: + void SetIndex(s64 index); + void ScanHomebrew(); + void FreeEntries(); + +private: + std::vector m_entries{}; + s64 m_index{}; // where i am in the array + std::unique_ptr m_list{}; +}; + +} // namespace sphaira::ui::menu::game diff --git a/sphaira/include/ui/menus/homebrew.hpp b/sphaira/include/ui/menus/homebrew.hpp index 5215e0e..bfc9891 100644 --- a/sphaira/include/ui/menus/homebrew.hpp +++ b/sphaira/include/ui/menus/homebrew.hpp @@ -30,23 +30,25 @@ struct Menu final : MenuBase { void Draw(NVGcontext* vg, Theme* theme) override; void OnFocusGained() override; + auto GetHomebrewList() const -> const std::vector& { + return m_entries; + } + + static Result InstallHomebrew(const fs::FsPath& path, const NacpStruct& nacp, const std::vector& icon); + static Result InstallHomebrewFromPath(const fs::FsPath& path); + +private: void SetIndex(s64 index); void InstallHomebrew(); void ScanHomebrew(); void Sort(); void SortAndFindLastFile(); - - auto GetHomebrewList() const -> const std::vector& { - return m_entries; - } + void FreeEntries(); auto IsStarEnabled() -> bool { return m_sort.Get() >= SortType_UpdatedStar; } - static Result InstallHomebrew(const fs::FsPath& path, const NacpStruct& nacp, const std::vector& icon); - static Result InstallHomebrewFromPath(const fs::FsPath& path); - private: static constexpr inline const char* INI_SECTION = "homebrew"; diff --git a/sphaira/source/app.cpp b/sphaira/source/app.cpp index 91f9f77..1dfce6b 100644 --- a/sphaira/source/app.cpp +++ b/sphaira/source/app.cpp @@ -13,6 +13,7 @@ #include "ui/menus/usb_menu.hpp" #include "ui/menus/ftp_menu.hpp" #include "ui/menus/gc_menu.hpp" +#include "ui/menus/game_menu.hpp" #include "app.hpp" #include "log.hpp" @@ -1518,6 +1519,10 @@ void App::DisplayMiscOptions(bool left_side) { auto options = std::make_shared("Misc Options"_i18n, left_side ? ui::Sidebar::Side::LEFT : ui::Sidebar::Side::RIGHT); ON_SCOPE_EXIT(App::Push(options)); + options->Add(std::make_shared("Games"_i18n, [](){ + App::Push(std::make_shared()); + })); + options->Add(std::make_shared("Themezer"_i18n, [](){ App::Push(std::make_shared()); })); diff --git a/sphaira/source/ui/menus/game_menu.cpp b/sphaira/source/ui/menus/game_menu.cpp new file mode 100644 index 0000000..d56161d --- /dev/null +++ b/sphaira/source/ui/menus/game_menu.cpp @@ -0,0 +1,287 @@ +#include "app.hpp" +#include "log.hpp" +#include "fs.hpp" +#include "ui/menus/game_menu.hpp" +#include "ui/sidebar.hpp" +#include "ui/error_box.hpp" +#include "ui/option_box.hpp" +#include "ui/progress_box.hpp" +#include "ui/nvg_util.hpp" +#include "defines.hpp" +#include "i18n.hpp" + +#include +#include + +namespace sphaira::ui::menu::game { +namespace { + +Result Notify(Result rc, const std::string& error_message) { + if (R_FAILED(rc)) { + App::Push(std::make_shared(rc, + i18n::get(error_message.c_str()) + )); + } else { + App::Notify("Success"); + } + + return rc; +} + +// also sets the status to error. +void FakeNacpEntry(Entry& e) { + e.status = NacpLoadStatus::Error; + // fake the nacp entry + std::strcpy(e.lang.name, "Corrupted"); + std::strcpy(e.lang.author, "Corrupted"); + std::strcpy(e.display_version, "0.0.0"); + e.control.reset(); +} + +bool LoadControlImage(Entry& e) { + if (!e.image && e.control) { + const auto jpeg_size = e.control_size - sizeof(NacpStruct); + e.image = nvgCreateImageMem(App::GetVg(), 0, e.control->icon, jpeg_size); + e.control.reset(); + return true; + } + + return false; +} + +void LoadControlEntry(Entry& e, bool force_image_load = false) { + if (e.status == NacpLoadStatus::None) { + e.control = std::make_unique(); + if (R_FAILED(nsGetApplicationControlData(NsApplicationControlSource_Storage, e.app_id, e.control.get(), sizeof(NsApplicationControlData), &e.control_size))) { + FakeNacpEntry(e); + } else { + NacpLanguageEntry* lang{}; + if (R_FAILED(nsGetApplicationDesiredLanguage(&e.control->nacp, &lang)) || !lang) { + FakeNacpEntry(e); + } else { + e.lang = *lang; + std::memcpy(e.display_version, e.control->nacp.display_version, sizeof(e.display_version)); + e.status = NacpLoadStatus::Loaded; + + if (force_image_load) { + LoadControlImage(e); + } + } + } + } +} + +void FreeEntry(NVGcontext* vg, Entry& e) { + nvgDeleteImage(vg, e.image); + e.image = 0; +} + +void LaunchEntry(const Entry& e) { + const auto rc = appletRequestLaunchApplication(e.app_id, nullptr); + Notify(rc, "Failed to launch application"); +} + +} // namespace + +Menu::Menu() : MenuBase{"Games"_i18n} { + this->SetActions( + std::make_pair(Button::B, Action{"Back"_i18n, [this](){ + SetPop(); + }}), + std::make_pair(Button::A, Action{"Launch"_i18n, [this](){ + LaunchEntry(m_entries[m_index]); + }}), + std::make_pair(Button::X, Action{"Options"_i18n, [this](){ + auto options = std::make_shared("Homebrew Options"_i18n, Sidebar::Side::RIGHT); + ON_SCOPE_EXIT(App::Push(options)); + + if (m_entries.size()) { + #if 0 + options->Add(std::make_shared("Info"_i18n, [this](){ + + })); + #endif + + options->Add(std::make_shared("Launch random game"_i18n, [this](){ + const auto random_index = randomGet64() % std::size(m_entries); + auto& e = m_entries[random_index]; + LoadControlEntry(e, true); + + App::Push(std::make_shared( + "Launch "_i18n + e.GetName(), + "Back"_i18n, "Launch"_i18n, 1, [this, &e](auto op_index){ + if (op_index && *op_index) { + LaunchEntry(e); + } + }, e.image + )); + })); + + options->Add(std::make_shared("Delete"_i18n, [this](){ + const auto buf = "Are you sure you want to delete "_i18n + m_entries[m_index].GetName() + "?"; + App::Push(std::make_shared( + buf, + "Back"_i18n, "Delete"_i18n, 0, [this](auto op_index){ + if (op_index && *op_index) { + const auto rc = nsDeleteApplicationCompletely(m_entries[m_index].app_id); + if (R_SUCCEEDED(Notify(rc, "Failed to delete application"))) { + FreeEntry(App::GetVg(), m_entries[m_index]); + m_entries.erase(m_entries.begin() + m_index); + SetIndex(m_index ? m_index - 1 : 0); + } + } + }, m_entries[m_index].image + )); + }, true)); + + #if 0 + options->Add(std::make_shared("Enable auto delete"_i18n, [this](){ + const auto rc = nsEnableApplicationAutoDelete(m_entries[m_index].app_id); + Notify(rc, "Failed to enable auto delete"); + })); + + options->Add(std::make_shared("Disable auto delete"_i18n, [this](){ + const auto rc = nsDisableApplicationAutoDelete(m_entries[m_index].app_id); + Notify(rc, "Failed to disable auto delete"); + })); + + options->Add(std::make_shared("Withdraw update request"_i18n, [this](){ + const auto rc = nsWithdrawApplicationUpdateRequest(m_entries[m_index].app_id); + Notify(rc, "Failed to withdraw update request"); + })); + #endif + } + }}) + ); + + const Vec4 v{75, 110, 370, 155}; + const Vec2 pad{10, 10}; + m_list = std::make_unique(3, 9, m_pos, v, pad); + nsInitialize(); +} + +Menu::~Menu() { + FreeEntries(); + nsExit(); +} + +void Menu::Update(Controller* controller, TouchInfo* touch) { + MenuBase::Update(controller, touch); + m_list->OnUpdate(controller, touch, m_index, m_entries.size(), [this](bool touch, auto i) { + if (touch && m_index == i) { + FireAction(Button::A); + } else { + App::PlaySoundEffect(SoundEffect_Focus); + SetIndex(i); + } + }); +} + +void Menu::Draw(NVGcontext* vg, Theme* theme) { + MenuBase::Draw(vg, theme); + + // max images per frame, in order to not hit io / gpu too hard. + const int image_load_max = 2; + int image_load_count = 0; + + m_list->Draw(vg, theme, m_entries.size(), [this, &image_load_count](auto* vg, auto* theme, auto v, auto pos) { + const auto& [x, y, w, h] = v; + auto& e = m_entries[pos]; + + if (e.status == NacpLoadStatus::None) { + LoadControlEntry(e); + } + + // lazy load image + if (image_load_count < image_load_max) { + if (LoadControlImage(e)) { + image_load_count++; + } + } + + auto text_id = ThemeEntryID_TEXT; + if (pos == m_index) { + text_id = ThemeEntryID_TEXT_SELECTED; + gfx::drawRectOutline(vg, theme, 4.f, v); + } else { + DrawElement(v, ThemeEntryID_GRID); + } + + const float image_size = 115; + gfx::drawImage(vg, x + 20, y + 20, image_size, image_size, e.image ? e.image : App::GetDefaultImage(), 5); + + nvgSave(vg); + nvgIntersectScissor(vg, x, y, w - 30.f, h); // clip + { + const float font_size = 18; + gfx::drawTextArgs(vg, x + 148, y + 45, font_size, NVG_ALIGN_LEFT, theme->GetColour(text_id), e.GetName()); + gfx::drawTextArgs(vg, x + 148, y + 80, font_size, NVG_ALIGN_LEFT, theme->GetColour(text_id), e.GetAuthor()); + gfx::drawTextArgs(vg, x + 148, y + 115, font_size, NVG_ALIGN_LEFT, theme->GetColour(text_id), e.GetDisplayVersion()); + } + nvgRestore(vg); + }); +} + +void Menu::OnFocusGained() { + MenuBase::OnFocusGained(); + if (m_entries.empty()) { + ScanHomebrew(); + } +} + +void Menu::SetIndex(s64 index) { + m_index = index; + if (!m_index) { + m_list->SetYoff(0); + } + + // todo: set subheadering. + char title_id[33]; + std::snprintf(title_id, sizeof(title_id), "%016lX", m_entries[m_index].app_id); + SetTitleSubHeading(title_id); + this->SetSubHeading(std::to_string(m_index + 1) + " / " + std::to_string(m_entries.size())); +} + +void Menu::ScanHomebrew() { + constexpr auto ENTRY_CHUNK_COUNT = 1000; + TimeStamp ts; + + FreeEntries(); + m_entries.reserve(ENTRY_CHUNK_COUNT); + + std::vector record_list(ENTRY_CHUNK_COUNT); + s32 offset{}; + while (true) { + s32 record_count{}; + if (R_FAILED(nsListApplicationRecord(record_list.data(), record_list.size(), offset, &record_count))) { + log_write("failed to list application records at offset: %d\n", offset); + } + + // finished parsing all entries. + if (!record_count) { + break; + } + + for (s32 i = 0; i < record_count; i++) { + // log_write("ID: %016lx got type: %u\n", record_list[i].application_id, record_list[i].type); + m_entries.emplace_back(record_list[i].application_id); + } + + offset += record_count; + } + + log_write("games found: %zu time_taken: %.2f seconds %zu ms %zu ns\n", m_entries.size(), ts.GetSecondsD(), ts.GetMs(), ts.GetNs()); + SetIndex(0); +} + +void Menu::FreeEntries() { + auto vg = App::GetVg(); + + for (auto&p : m_entries) { + FreeEntry(vg, p); + } + + m_entries.clear(); +} + +} // namespace sphaira::ui::menu::game diff --git a/sphaira/source/ui/menus/homebrew.cpp b/sphaira/source/ui/menus/homebrew.cpp index ff6555f..c031970 100644 --- a/sphaira/source/ui/menus/homebrew.cpp +++ b/sphaira/source/ui/menus/homebrew.cpp @@ -24,6 +24,11 @@ auto GenerateStarPath(const fs::FsPath& nro_path) -> fs::FsPath { return out; } +void FreeEntry(NVGcontext* vg, NroEntry& e) { + nvgDeleteImage(vg, e.image); + e.image = 0; +} + } // namespace Menu::Menu() : MenuBase{"Homebrew"_i18n} { @@ -80,6 +85,7 @@ Menu::Menu() : MenuBase{"Homebrew"_i18n} { "Back"_i18n, "Delete"_i18n, 1, [this](auto op_index){ if (op_index && *op_index) { if (R_SUCCEEDED(fs::FsNativeSd().DeleteFile(m_entries[m_index].path))) { + FreeEntry(App::GetVg(), m_entries[m_index]); m_entries.erase(m_entries.begin() + m_index); SetIndex(m_index ? m_index - 1 : 0); } @@ -114,11 +120,7 @@ Menu::Menu() : MenuBase{"Homebrew"_i18n} { } Menu::~Menu() { - auto vg = App::GetVg(); - - for (auto&p : m_entries) { - nvgDeleteImage(vg, p.image); - } + FreeEntries(); } void Menu::Update(Controller* controller, TouchInfo* touch) { @@ -238,6 +240,7 @@ void Menu::InstallHomebrew() { void Menu::ScanHomebrew() { TimeStamp ts; + FreeEntries(); nro_scan("/switch", m_entries, m_hide_sphaira.Get()); log_write("nros found: %zu time_taken: %.2f\n", m_entries.size(), ts.GetSecondsD()); @@ -394,6 +397,16 @@ void Menu::SortAndFindLastFile() { } } +void Menu::FreeEntries() { + auto vg = App::GetVg(); + + for (auto&p : m_entries) { + FreeEntry(vg, p); + } + + m_entries.clear(); +} + Result Menu::InstallHomebrew(const fs::FsPath& path, const NacpStruct& nacp, const std::vector& icon) { OwoConfig config{}; config.nro_path = path.toString();