From d8a752207281689c12113dd51d3626f3577bd369 Mon Sep 17 00:00:00 2001 From: kanjieater Date: Fri, 8 May 2026 08:50:08 -0500 Subject: [PATCH] Add game selection UI, Japanese font support, and HTTPS compatibility --- client/Makefile | 2 +- client/source/gui/Gui.cpp | 24 +-- client/source/gui/MainScreen.cpp | 3 +- client/source/gui/TitleSelectionScreen.cpp | 173 +++++++++++++++++++++ client/source/gui/TitleSelectionScreen.hpp | 35 +++++ client/source/http.cpp | 10 +- client/source/ini.hpp | 20 +++ client/source/title.cpp | 105 ++++++------- 8 files changed, 291 insertions(+), 81 deletions(-) create mode 100644 client/source/gui/TitleSelectionScreen.cpp create mode 100644 client/source/gui/TitleSelectionScreen.hpp diff --git a/client/Makefile b/client/Makefile index 6433d93..517254d 100644 --- a/client/Makefile +++ b/client/Makefile @@ -11,7 +11,7 @@ include $(DEVKITPRO)/libnx/switch_rules APP_TITLE=micro NX Save Sync APP_AUTHOR=prodeveloper0 -APP_VERSION=1.0.0 +APP_VERSION=1.0.1 APP_TITLEID=uNSS #--------------------------------------------------------------------------------- diff --git a/client/source/gui/Gui.cpp b/client/source/gui/Gui.cpp index 3d1c721..90def64 100644 --- a/client/source/gui/Gui.cpp +++ b/client/source/gui/Gui.cpp @@ -9,33 +9,21 @@ namespace gui bool Renderer::loadSystemFont() { - PlFontData koFont; + PlFontData sysFont; Result rc = plInitialize(PlServiceType_User); if (R_FAILED(rc)) return false; - // 한글 폰트 로드 (영문/숫자도 포함) - rc = plGetSharedFontByType(&koFont, PlSharedFontType_KO); + rc = plGetSharedFontByType(&sysFont, PlSharedFontType_Standard); if (R_FAILED(rc)) { - // fallback: Standard 폰트 - PlFontData stdFont; - rc = plGetSharedFontByType(&stdFont, PlSharedFontType_Standard); - if (R_FAILED(rc)) - { - plExit(); - return false; - } - this->fontDataSize = stdFont.size; - this->fontData = malloc(stdFont.size); - memcpy(this->fontData, stdFont.address, stdFont.size); plExit(); - return true; + return false; } - this->fontDataSize = koFont.size; - this->fontData = malloc(koFont.size); - memcpy(this->fontData, koFont.address, koFont.size); + this->fontDataSize = sysFont.size; + this->fontData = malloc(sysFont.size); + memcpy(this->fontData, sysFont.address, sysFont.size); plExit(); return true; diff --git a/client/source/gui/MainScreen.cpp b/client/source/gui/MainScreen.cpp index 71f95f5..9fca135 100644 --- a/client/source/gui/MainScreen.cpp +++ b/client/source/gui/MainScreen.cpp @@ -1,3 +1,4 @@ +#include "TitleSelectionScreen.hpp" #include "MainScreen.hpp" #include "ProgressScreen.hpp" #include "AccountScreen.hpp" @@ -87,10 +88,10 @@ void MainScreen::rebuildMenu() menuItems.push_back({"Push to Server", [this]() { startPush(); }, remoteEnabled}); menuItems.push_back({"Pull from Server", [this]() { startPull(); }, true}); + menuItems.push_back({"Select Games", [this]() { App::instance().pushScreen(new TitleSelectionScreen(config, account.uid)); }, true}); } } - void MainScreen::update(u64 kDown) { // 첫 프레임: 계정 해석 diff --git a/client/source/gui/TitleSelectionScreen.cpp b/client/source/gui/TitleSelectionScreen.cpp new file mode 100644 index 0000000..234761d --- /dev/null +++ b/client/source/gui/TitleSelectionScreen.cpp @@ -0,0 +1,173 @@ +#include "TitleSelectionScreen.hpp" +#include "../utils.hpp" +#include "../account.hpp" +#include +#include // Required for std::sort + +namespace gui +{ + +TitleSelectionScreen::TitleSelectionScreen(Config& config, AccountUid uid) : config(config), uid(uid) +{ + loadTitles(); +} + +void TitleSelectionScreen::loadTitles() +{ + std::set excludedIds; + std::string currentExclusions = config["title"]["excludedTitleIds"].value; + + // Parse current exclusions + size_t start = 0; + while (start < currentExclusions.size()) { + size_t pos = currentExclusions.find(',', start); + std::string hex = (pos == std::string::npos) ? currentExclusions.substr(start) : currentExclusions.substr(start, pos - start); + + hex.erase(0, hex.find_first_not_of(" \t\r\n")); + hex.erase(hex.find_last_not_of(" \t\r\n") + 1); + + if (!hex.empty()) { + excludedIds.insert(fromHex(hex)); + } + if (pos == std::string::npos) break; + start = pos + 1; + } + + // FIX: Gather ALL installed titles AND titles with save data to match Push/Pull logic + std::vector allInstalledIds; + std::vector createdSaveIds; + probeAllTitles(uid, allInstalledIds); // <-- Change here + probeSaveDataCreatedTitles(uid, createdSaveIds); + + // Combine and deduplicate + std::set combinedIds(allInstalledIds.begin(), allInstalledIds.end()); + combinedIds.insert(createdSaveIds.begin(), createdSaveIds.end()); + + for (u64 id : combinedIds) { + TitleItem item; + item.id = id; + + // Invert logic: If it is IN the exclusion list, it is NOT selected. + item.selected = (excludedIds.count(id) == 0); + + if (getTitleName(id, item.name) != 0) { + item.name = "Unknown Title (" + toHex(id) + ")"; + } + titles.push_back(item); + } + + // FIX: Sort alphabetically to make finding games easier + std::sort(titles.begin(), titles.end(), [](const TitleItem& a, const TitleItem& b) { + return a.name < b.name; + }); +} + +void TitleSelectionScreen::saveSelection() +{ + std::set finalExcludedIds; + + // 1. Keep previously excluded IDs that might not be installed anymore + // (prevents them from being wiped when you uninstall a game) + std::string currentExclusions = config["title"]["excludedTitleIds"].value; + size_t start = 0; + while (start < currentExclusions.size()) { + size_t pos = currentExclusions.find(',', start); + std::string hex = (pos == std::string::npos) ? currentExclusions.substr(start) : currentExclusions.substr(start, pos - start); + hex.erase(0, hex.find_first_not_of(" \t\r\n")); + hex.erase(hex.find_last_not_of(" \t\r\n") + 1); + if (!hex.empty()) finalExcludedIds.insert(fromHex(hex)); + if (pos == std::string::npos) break; + start = pos + 1; + } + + // 2. Add unselected items to exclusion, remove selected items from exclusion + for (const auto& item : titles) { + if (!item.selected) { + finalExcludedIds.insert(item.id); + } else { + finalExcludedIds.erase(item.id); + } + } + + // 3. Build string + std::string newExclusions = ""; + for (u64 id : finalExcludedIds) { + if (!newExclusions.empty()) newExclusions += ", "; + newExclusions += toHex(id); + } + + config["title"]["excludedTitleIds"] = newExclusions; + config.save("sdmc:/uNSS/config.ini"); +} + +void TitleSelectionScreen::update(u64 kDown) +{ + if (titles.empty()) { + if (kDown & HidNpadButton_B) App::instance().popScreen(); + return; + } + + int maxVisible = 10; + + if (kDown & HidNpadButton_AnyUp) { + selectedIndex--; + if (selectedIndex < 0) selectedIndex = titles.size() - 1; + } + if (kDown & HidNpadButton_AnyDown) { + selectedIndex++; + if (selectedIndex >= (int)titles.size()) selectedIndex = 0; + } + + if (selectedIndex < scrollOffset) scrollOffset = selectedIndex; + if (selectedIndex >= scrollOffset + maxVisible) scrollOffset = selectedIndex - maxVisible + 1; + + if (kDown & HidNpadButton_A) { + titles[selectedIndex].selected = !titles[selectedIndex].selected; + } + + if (kDown & HidNpadButton_X) { + for (auto& item : titles) item.selected = true; + } + + if (kDown & HidNpadButton_Y) { + for (auto& item : titles) item.selected = false; + } + + if (kDown & HidNpadButton_B) { + saveSelection(); + App::instance().popScreen(); + } +} + +void TitleSelectionScreen::render(Renderer& r) +{ + int x = 80; + int y = 60; + + r.drawText("Select Games to Sync", x, y, 32, COLOR_ACCENT); + y += 50; + r.drawRect(x, y, r.screenWidth() - x * 2, 2, COLOR_ACCENT); + y += 20; + + if (titles.empty()) { + r.drawText("No save data found.", x, y, 24, COLOR_DIM); + } else { + int maxVisible = 10; + int lineHeight = 40; + + for (int i = scrollOffset; i < (int)titles.size() && i < scrollOffset + maxVisible; i++) { + Color textColor = (i == selectedIndex) ? COLOR_HIGHLIGHT : COLOR_TEXT; + + std::string checkbox = titles[i].selected ? "[X] " : "[ ] "; + r.drawText(checkbox + titles[i].name, x, y, 24, textColor); + + y += lineHeight; + } + } + + int fy = r.screenHeight() - 50; + r.drawRect(x, fy - 10, r.screenWidth() - x * 2, 2, {80, 80, 80, 255}); + r.drawText("A: Toggle X: Select All Y: Deselect All B: Save & Back", x, fy, 18, COLOR_DIM); +} + +} // namespace gui \ No newline at end of file diff --git a/client/source/gui/TitleSelectionScreen.hpp b/client/source/gui/TitleSelectionScreen.hpp new file mode 100644 index 0000000..65f55d7 --- /dev/null +++ b/client/source/gui/TitleSelectionScreen.hpp @@ -0,0 +1,35 @@ +#pragma once +#include "Gui.hpp" +#include "../title.hpp" +#include "../ini.hpp" +#include +#include + +namespace gui +{ + +struct TitleItem { + u64 id; + std::string name; + bool selected; // UI uses "selected" (whitelist) +}; + +class TitleSelectionScreen : public Screen +{ +public: + TitleSelectionScreen(Config& config, AccountUid uid); + void update(u64 kDown) override; + void render(Renderer& r) override; + +private: + Config& config; + AccountUid uid; + std::vector titles; + int selectedIndex = 0; + int scrollOffset = 0; + + void loadTitles(); + void saveSelection(); +}; + +} // namespace gui \ No newline at end of file diff --git a/client/source/http.cpp b/client/source/http.cpp index 1987bc9..480845b 100644 --- a/client/source/http.cpp +++ b/client/source/http.cpp @@ -79,7 +79,7 @@ size_t HTTPClient::readCallback(void* ptr, size_t size, size_t nmemb, void* user HTTPClient* client = static_cast(userp); if (!client || !client->onSend) { - return size * nmemb; + return 0; } size_t actualSize = 0; @@ -112,7 +112,9 @@ int HTTPClient::perform() curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); - + + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "uNSS/1.0"); // HTTP 메서드 설정 if (method == "GET") { @@ -121,6 +123,10 @@ int HTTPClient::perform() else if (method == "POST") { curl_easy_setopt(curl, CURLOPT_POST, 1L); + if (!onSend) + { + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L); + } } // 비동기 모드 설정 diff --git a/client/source/ini.hpp b/client/source/ini.hpp index 5077a59..e27afcf 100644 --- a/client/source/ini.hpp +++ b/client/source/ini.hpp @@ -239,6 +239,26 @@ class Config_ return result; } +public: + bool save(const std::string& filename) + { + std::ofstream ofs(filename); + if(!ofs.is_open()) return false; + + for(auto const& section : config) + { + ofs << "[" << section.first << "]\n"; + for(auto const& prop : section.second.properties) + { + // Quote the value if it has spaces or special chars, otherwise write direct + ofs << prop.first << "=\"" << prop.second.value << "\"\n"; + } + ofs << "\n"; + } + ofs.close(); + return true; + } + public: Property& operator [] (const std::string& s) { diff --git a/client/source/title.cpp b/client/source/title.cpp index 8dba710..b335134 100644 --- a/client/source/title.cpp +++ b/client/source/title.cpp @@ -15,48 +15,57 @@ int getTitleName(const u64 titleID, std::string& titleName) int getTitleName(const u64 titleID, std::string& titleName, int language) { - const Defer defer( - [&]() - { - nsInitialize(); - }, - [&]() - { - nsExit(); - } - ); + const Defer defer([&]() { nsInitialize(); }, [&]() { nsExit(); }); NsApplicationControlData controlData = {0x00,}; size_t actualSize; Result rc = nsGetApplicationControlData(NsApplicationControlSource_Storage, titleID, &controlData, sizeof(controlData), &actualSize); - - if (R_SUCCEEDED(rc)) + + // If the game is deleted or is homebrew, the OS literally does not have the name anymore. + if (R_FAILED(rc)) { - if (language == -1) + return -1; + } + + NacpLanguageEntry *langentry = NULL; + + if (language == -1) + { + // Try to get the system preferred language first + if (R_FAILED(nacpGetLanguageEntry(&controlData.nacp, &langentry))) { - // Use device preferred language - NacpLanguageEntry *langentry; - rc = nacpGetLanguageEntry(&controlData.nacp, &langentry); - if (R_SUCCEEDED(rc)) - { - char buf[0x201] = {0x00, }; - strncpy(buf, langentry->name, 0x200); - titleName = buf; - } + langentry = NULL; } - else + } + else + { + langentry = &controlData.nacp.lang[language]; + } + + + if (langentry == NULL || langentry->name[0] == '\0') + { + for (int i = 0; i < 16; i++) { - // Use specified language - char buf[0x201] = {0x00, }; - strncpy(buf, controlData.nacp.lang[language].name, 0x200); - titleName = buf; + if (controlData.nacp.lang[i].name[0] != '\0') + { + langentry = &controlData.nacp.lang[i]; + break; + } } + } - return 0; + + if (langentry != NULL && langentry->name[0] != '\0') + { + char buf[0x201] = {0x00, }; + strncpy(buf, langentry->name, 0x200); + titleName = buf; + return 0; // Success } - return -1; + return -1; // Completely failed } @@ -156,14 +165,18 @@ static std::vector splitBy(const std::string& str, const std::strin return tokens; } - void filterExcludedTitles(std::vector& titleIDs, const std::string& excludedTitleIds, const std::string& excludedTitleNames) { + // Note: We leave the excludedTitleNames parameter to avoid breaking the header signature, + // but we completely ignore it in the implementation. + std::set excludedIds; if (!excludedTitleIds.empty()) { - for (const auto& hex : splitBy(excludedTitleIds, ",")) + for (auto hex : splitBy(excludedTitleIds, ",")) { + hex.erase(0, hex.find_first_not_of(" \t\r\n")); + hex.erase(hex.find_last_not_of(" \t\r\n") + 1); if (!hex.empty()) { excludedIds.insert(fromHex(hex)); @@ -171,39 +184,13 @@ void filterExcludedTitles(std::vector& titleIDs, const std::string& exclude } } - std::set excludedNames; - if (!excludedTitleNames.empty()) - { - for (const auto& name : splitBy(excludedTitleNames, "||")) - { - if (!name.empty()) - { - excludedNames.insert(name); - } - } - } - + // Remove any ID that exists in our exclusion set titleIDs.erase( std::remove_if(titleIDs.begin(), titleIDs.end(), [&](u64 titleID) { - if (excludedIds.count(titleID)) - { - return true; - } - - if (!excludedNames.empty()) - { - std::string titleName; - if (getTitleName(titleID, titleName) == 0 && excludedNames.count(titleName)) - { - return true; - } - } - - return false; + return excludedIds.count(titleID) > 0; }), titleIDs.end() ); } -