Files
sphaira/sphaira/source/ui/menus/appstore.cpp
ITotalJustice 10f079e881 make progress popup a little nicer, store json timeout is now 1h, initial work on storing update changelog
also removed the ability to cancel an update whilst unzipping the files, as this would result
in a corrupted sphaira.nro install, which we don't want.
2024-12-21 19:44:43 +00:00

1541 lines
54 KiB
C++

#include "ui/menus/appstore.hpp"
#include "ui/sidebar.hpp"
#include "ui/popup_list.hpp"
#include "ui/progress_box.hpp"
#include "ui/option_box.hpp"
#include "download.hpp"
#include "defines.hpp"
#include "log.hpp"
#include "app.hpp"
#include "ui/nvg_util.hpp"
#include "fs.hpp"
#include "yyjson_helper.hpp"
#include "swkbd.hpp"
#include "i18n.hpp"
#include <minIni.h>
#include <string>
#include <cstring>
#include <yyjson.h>
#include <nanovg/stb_image.h>
#include <minizip/unzip.h>
#include <mbedtls/md5.h>
#include <ranges>
#include <utility>
namespace sphaira::ui::menu::appstore {
namespace {
constexpr fs::FsPath REPO_PATH{"/switch/sphaira/cache/appstore/repo.json"};
constexpr fs::FsPath CACHE_PATH{"/switch/sphaira/cache/appstore"};
constexpr auto URL_BASE = "https://switch.cdn.fortheusers.org";
constexpr auto URL_JSON = "https://switch.cdn.fortheusers.org/repo.json";
constexpr auto URL_POST_FEEDBACK = "http://switchbru.com/appstore/feedback";
constexpr auto URL_GET_FEEDACK = "http://switchbru.com/appstore/feedback";
constexpr const char* INI_SECTION = "appstore";
constexpr const char* FILTER_STR[] = {
"All",
"Games",
"Emulators",
"Tools",
"Advanced",
"Themes",
"Legacy",
"Misc",
};
constexpr const char* SORT_STR[] = {
"Updated",
"Downloads",
"Size",
"Alphabetical",
};
constexpr const char* ORDER_STR[] = {
"Desc",
"Asc",
};
auto BuildIconUrl(const Entry& e) -> std::string {
char out[0x100];
std::snprintf(out, sizeof(out), "%s/packages/%s/icon.png", URL_BASE, e.name.c_str());
return out;
}
#if 0
auto BuildInfoUrl(const Entry& e) -> std::string {
char out[0x100];
std::snprintf(out, sizeof(out), "%s/packages/%s/info.json", URL_BASE, e.name.c_str());
return out;
}
#endif
auto BuildBannerUrl(const Entry& e) -> std::string {
char out[0x100];
std::snprintf(out, sizeof(out), "%s/packages/%s/screen.png", URL_BASE, e.name.c_str());
return out;
}
#if 0
auto BuildScreensUrl(const Entry& e, u8 num) -> std::string {
char out[0x100];
std::snprintf(out, sizeof(out), "%s/packages/%s/screen%u.png", URL_BASE, e.name.c_str(), num+1);
return out;
}
#endif
auto BuildMainifestUrl(const Entry& e) -> std::string {
char out[0x100];
std::snprintf(out, sizeof(out), "%s/packages/%s/manifest.install", URL_BASE, e.name.c_str());
return out;
}
auto BuildZipUrl(const Entry& e) -> std::string {
char out[0x100];
std::snprintf(out, sizeof(out), "%s/zips/%s.zip", URL_BASE, e.name.c_str());
return out;
}
auto BuildFeedbackUrl(std::span<u32> ids) -> std::string {
std::string out{"https://wiiubru.com/feedback/messages?ids="};
for (u32 i = 0; i < ids.size(); i++) {
if (i != 0) {
out.push_back(',');
}
out += std::to_string(ids[i]);
}
return out;
}
auto BuildIconCachePath(const Entry& e) -> fs::FsPath {
fs::FsPath out;
std::snprintf(out, sizeof(out), "%s/icons/%s.png", CACHE_PATH, e.name.c_str());
return out;
}
auto BuildBannerCachePath(const Entry& e) -> fs::FsPath {
fs::FsPath out;
std::snprintf(out, sizeof(out), "%s/banners/%s.png", CACHE_PATH, e.name.c_str());
return out;
}
#if 0
auto BuildScreensCachePath(const Entry& e, u8 num) -> fs::FsPath {
fs::FsPath out;
std::snprintf(out, sizeof(out), "%s/screens/%s%u.png", CACHE_PATH, e.name.c_str(), num+1);
return out;
}
#endif
// use appstore path in order to maintain compat with appstore
auto BuildPackageCachePath(const Entry& e) -> fs::FsPath {
return "/switch/appstore/.get/packages/" + e.name;
}
auto BuildInfoCachePath(const Entry& e) -> fs::FsPath {
return BuildPackageCachePath(e) + "/info.json";
}
auto BuildManifestCachePath(const Entry& e) -> fs::FsPath {
return BuildPackageCachePath(e) + "/manifest.install";
}
auto BuildFeedbackCachePath(const Entry& e) -> fs::FsPath {
return BuildPackageCachePath(e) + "/feedback.json";
}
void from_json(yyjson_val* json, Entry& e) {
JSON_OBJ_ITR(
JSON_SET_STR(category);
JSON_SET_STR(binary);
JSON_SET_STR(updated);
JSON_SET_STR(name);
JSON_SET_STR(license);
JSON_SET_STR(title);
JSON_SET_STR(url);
JSON_SET_STR(description);
JSON_SET_STR(author);
JSON_SET_STR(changelog);
JSON_SET_UINT(screens);
JSON_SET_UINT(extracted);
JSON_SET_STR(version);
JSON_SET_UINT(filesize);
JSON_SET_STR(details);
JSON_SET_UINT(app_dls);
JSON_SET_STR(md5);
);
}
void from_json(const fs::FsPath& path, std::vector<appstore::Entry>& e) {
yyjson_read_err err;
JSON_INIT_VEC_FILE(path, nullptr, &err);
JSON_OBJ_ITR(
JSON_SET_ARR_OBJ2(packages, e);
);
}
auto ParseManifest(std::span<const char> view) -> ManifestEntries {
ManifestEntries entries;
// auto view = std::string_view{manifest_data.data(), manifest_data.size()};
for (const auto line : std::views::split(view, '\n')) {
if (line.size() <= 3) {
continue;
}
ManifestEntry entry{};
entry.command = line[0];
std::strncpy(entry.path, line.data() + 3, line.size() - 3);
entries.emplace_back(entry);
}
return entries;
}
auto LoadAndParseManifest(const Entry& e) -> ManifestEntries {
const auto path = BuildManifestCachePath(e);
std::vector<u8> data;
if (R_FAILED(fs::FsNativeSd().read_entire_file(path, data))) {
return {};
}
return ParseManifest(std::span{(const char*)data.data(), data.size()});
}
void EntryLoadImageFile(fs::Fs& fs, const fs::FsPath& path, LazyImage& image) {
// already have the image
if (image.image) {
log_write("warning, tried to load image: %s when already loaded\n", path);
return;
}
auto vg = App::GetVg();
std::vector<u8> image_buf;
if (R_FAILED(fs.read_entire_file(path, image_buf))) {
image.state = ImageDownloadState::Failed;
} else {
int channels_in_file;
auto buf = stbi_load_from_memory(image_buf.data(), image_buf.size(), &image.w, &image.h, &channels_in_file, 4);
if (buf) {
ON_SCOPE_EXIT(stbi_image_free(buf));
std::memcpy(image.first_pixel, buf, sizeof(image.first_pixel));
image.image = nvgCreateImageRGBA(vg, image.w, image.h, 0, buf);
}
}
if (!image.image) {
image.state = ImageDownloadState::Failed;
log_write("failed to load image from file: %s\n", path);
} else {
// log_write("loaded image from file: %s\n", path);
}
}
void EntryLoadImageFile(const fs::FsPath& path, LazyImage& image) {
if (!strncasecmp("romfs:/", path, 7)) {
fs::FsStdio fs;
EntryLoadImageFile(fs, path, image);
} else {
fs::FsNativeSd fs;
EntryLoadImageFile(fs, path, image);
}
}
void DrawIcon(NVGcontext* vg, const LazyImage& l, const LazyImage& d, float x, float y, float w, float h, bool rounded = true, float scale = 1.0) {
const auto& i = l.image ? l : d;
const float iw = (float)i.w / scale;
const float ih = (float)i.h / scale;
float ix = x;
float iy = y;
bool rounded_image = rounded;
if (w > iw) {
ix = x + abs((w - iw) / 2);
} else if (w < iw) {
ix = x - abs((w - iw) / 2);
}
if (h > ih) {
iy = y + abs((h - ih) / 2);
} else if (h < ih) {
iy = y - abs((h - ih) / 2);
}
bool crop = false;
if (iw < w || ih < h) {
rounded_image = false;
gfx::drawRect(vg, x, y, w, h, nvgRGB(i.first_pixel[0], i.first_pixel[1], i.first_pixel[2]), rounded);
}
if (iw > w || ih > h) {
crop = true;
nvgSave(vg);
nvgScissor(vg, x, y, w, h);
}
if (rounded_image) {
gfx::drawImageRounded(vg, ix, iy, iw, ih, i.image);
} else {
gfx::drawImage(vg, ix, iy, iw, ih, i.image);
}
if (crop) {
nvgRestore(vg);
}
}
void DrawIcon(NVGcontext* vg, const LazyImage& l, const LazyImage& d, Vec4 vec, bool rounded = true, float scale = 1.0) {
DrawIcon(vg, l, d, vec.x, vec.y, vec.w, vec.h, rounded, scale);
}
auto ScrollHelperDown(u64& index, u64& start, u64 step, u64 max, u64 size) -> bool {
if (size && index < (size - 1)) {
if (index < (size - step)) {
index = index + step;
App::PlaySoundEffect(SoundEffect_Scroll);
} else {
index = size - 1;
App::PlaySoundEffect(SoundEffect_Scroll);
}
if (index - start >= max) {
log_write("moved down\n");
start += step;
}
return true;
}
return false;
}
auto AppDlToStr(u32 value) -> std::string {
auto str = std::to_string(value);
u32 inc = 3;
for (u32 i = inc; i < str.size(); i += inc) {
str.insert(str.cend() - i , ',');
inc++;
}
return str;
}
void ReadFromInfoJson(Entry& e) {
const auto info_path = BuildInfoCachePath(e);
const auto manifest_path = BuildManifestCachePath(e);
yyjson_read_err err;
auto doc = yyjson_read_file(info_path, YYJSON_READ_NOFLAG, nullptr, &err);
if (doc) {
const auto root = yyjson_doc_get_root(doc);
const auto version = yyjson_obj_get(root, "version");
if (version) {
if (!std::strcmp(yyjson_get_str(version), e.version.c_str())) {
e.status = EntryStatus::Installed;
} else {
e.status = EntryStatus::Update;
log_write("info.json said %s needs update: %s vs %s\n", e.name.c_str(), yyjson_get_str(version), e.version.c_str());
}
}
// log_write("got info for: %s\n", e.name.c_str());
yyjson_doc_free(doc);
}
}
// this ignores ShouldExit() as leaving somthing in a half
// deleted state is a bad idea :)
auto UninstallApp(ProgressBox* pbox, const Entry& entry) -> bool {
const auto manifest = LoadAndParseManifest(entry);
fs::FsNativeSd fs;
if (manifest.empty()) {
if (entry.binary.empty()) {
return false;
}
fs.DeleteFile(entry.binary);
} else {
for (auto& e : manifest) {
pbox->NewTransfer(e.path);
const auto safe_buf = fs::AppendPath("/", e.path);
// this will handle read only files, ie, hbmenu.nro
if (R_FAILED(fs.DeleteFile(safe_buf))) {
log_write("failed to delete file: %s\n", safe_buf);
} else {
log_write("deleted file: %s\n", safe_buf);
// todo: delete empty directories!
// fs::delete_directory(safe_buf);
}
}
}
// remove directory, this will also delete manifest and info
const auto dir = BuildPackageCachePath(entry);
pbox->NewTransfer("Removing "_i18n + dir);
if (R_FAILED(fs.DeleteDirectoryRecursively(dir))) {
log_write("failed to delete folder: %s\n", dir);
} else {
log_write("deleted: %s\n", dir);
}
return true;
}
// this is called by ProgressBox on a seperate thread
// it has 4 main steps
// 1. download the zip
// 2. md5 check the zip
// 3. parse manifest and unzip everything to placeholder
// 4. move everything from placeholder to normal location
auto InstallApp(ProgressBox* pbox, const Entry& entry) -> bool {
static const fs::FsPath zip_out{"/switch/sphaira/cache/appstore/temp.zip"};
constexpr auto chunk_size = 1024 * 512; // 512KiB
fs::FsNativeSd fs;
R_TRY_RESULT(fs.GetFsOpenResult(), false);
// 1. download the zip
if (!pbox->ShouldExit()) {
pbox->NewTransfer("Downloading "_i18n + entry.title);
log_write("starting download\n");
const auto url = BuildZipUrl(entry);
if (!DownloadFile(url, zip_out, "", [pbox](u32 dltotal, u32 dlnow, u32 ultotal, u32 ulnow){
if (pbox->ShouldExit()) {
return false;
}
pbox->UpdateTransfer(dlnow, dltotal);
return true;
})) {
log_write("error with download\n");
// push popup error box
return false;
// return appletEnterFatalSection();
}
}
ON_SCOPE_EXIT(fs.DeleteFile(zip_out));
// 2. md5 check the zip
if (!pbox->ShouldExit()) {
pbox->NewTransfer("Checking MD5"_i18n);
log_write("starting md5 check\n");
FsFile f;
if (R_FAILED(fs.OpenFile(zip_out, FsOpenMode_Read, &f))) {
return false;
}
ON_SCOPE_EXIT(fsFileClose(&f));
s64 size;
if (R_FAILED(fsFileGetSize(&f, &size))) {
return false;
}
mbedtls_md5_context ctx;
mbedtls_md5_init(&ctx);
ON_SCOPE_EXIT(mbedtls_md5_free(&ctx));
if (mbedtls_md5_starts_ret(&ctx)) {
log_write("failed to start ret\n");
}
std::vector<u8> chunk(chunk_size);
s64 offset{};
while (offset < size) {
if (pbox->ShouldExit()) {
return false;
}
u64 bytes_read;
if (R_FAILED(fsFileRead(&f, offset, chunk.data(), chunk.size(), 0, &bytes_read))) {
log_write("failed to read file offset: %zd size: %zd\n", offset, size);
return false;
}
if (mbedtls_md5_update_ret(&ctx, chunk.data(), bytes_read)) {
log_write("failed to update ret\n");
return false;
}
offset += bytes_read;
pbox->UpdateTransfer(offset, size);
}
u8 md5_out[16];
if (mbedtls_md5_finish_ret(&ctx, (u8*)md5_out)) {
return false;
}
// convert md5 to hex string
char md5_str[sizeof(md5_out) * 2 + 1];
for (u32 i = 0; i < sizeof(md5_out); i++) {
std::sprintf(md5_str + i * 2, "%02x", md5_out[i]);
}
if (strncasecmp(md5_str, entry.md5.data(), entry.md5.length())) {
log_write("bad md5: %.*s vs %.*s\n", 32, md5_str, 32, entry.md5.c_str());
return false;
}
}
// 3. extract the zip
if (!pbox->ShouldExit()) {
auto zfile = unzOpen64(zip_out);
if (!zfile) {
log_write("failed to open zip: %s\n", zip_out);
return false;
}
ON_SCOPE_EXIT(unzClose(zfile));
// get manifest
if (UNZ_END_OF_LIST_OF_FILE == unzLocateFile(zfile, "manifest.install", 0)) {
log_write("failed to find manifest.install\n");
return false;
}
ManifestEntries new_manifest;
const auto old_manifest = LoadAndParseManifest(entry);
{
if (UNZ_OK != unzOpenCurrentFile(zfile)) {
log_write("failed to open current file\n");
return false;
}
ON_SCOPE_EXIT(unzCloseCurrentFile(zfile));
unz_file_info64 info;
if (UNZ_OK != unzGetCurrentFileInfo64(zfile, &info, 0, 0, 0, 0, 0, 0)) {
log_write("failed to get current info\n");
return false;
}
std::vector<char> manifest_data(info.uncompressed_size);
if ((int)info.uncompressed_size != unzReadCurrentFile(zfile, manifest_data.data(), manifest_data.size())) {
log_write("failed to read manifest file\n");
return false;
}
new_manifest = ParseManifest(manifest_data);
if (new_manifest.empty()) {
log_write("manifest is empty!\n");
return false;
}
}
const auto unzip_to = [pbox, &fs, zfile](const fs::FsPath& inzip, fs::FsPath output) -> bool {
pbox->NewTransfer(inzip);
if (UNZ_END_OF_LIST_OF_FILE == unzLocateFile(zfile, inzip, 0)) {
log_write("failed to find %s\n", inzip);
return false;
}
if (UNZ_OK != unzOpenCurrentFile(zfile)) {
log_write("failed to open current file\n");
return false;
}
ON_SCOPE_EXIT(unzCloseCurrentFile(zfile));
unz_file_info64 info;
if (UNZ_OK != unzGetCurrentFileInfo64(zfile, &info, 0, 0, 0, 0, 0, 0)) {
log_write("failed to get current info\n");
return false;
}
if (output[0] != '/') {
output = fs::AppendPath("/", output);
}
// create directories
fs.CreateDirectoryRecursivelyWithPath(output, true);
Result rc;
if (R_FAILED(rc = fs.CreateFile(output, info.uncompressed_size, 0, true)) && rc != FsError_ResultPathAlreadyExists) {
log_write("failed to create file: %s 0x%04X\n", output, rc);
return false;
}
FsFile f;
if (R_FAILED(rc = fs.OpenFile(output, FsOpenMode_Write, &f))) {
log_write("failed to open file: %s 0x%04X\n", output, rc);
return false;
}
ON_SCOPE_EXIT(fsFileClose(&f));
if (R_FAILED(rc = fsFileSetSize(&f, info.uncompressed_size))) {
log_write("failed to set file size: %s 0x%04X\n", output, rc);
return false;
}
std::vector<char> buf(chunk_size);
u64 offset{};
while (offset < info.uncompressed_size) {
if (pbox->ShouldExit()) {
return false;
}
const auto bytes_read = unzReadCurrentFile(zfile, buf.data(), buf.size());
if (bytes_read <= 0) {
log_write("failed to read zip file: %s\n", inzip);
return false;
}
if (R_FAILED(rc = fsFileWrite(&f, offset, buf.data(), bytes_read, FsWriteOption_None))) {
log_write("failed to write file: %s 0x%04X\n", output, rc);
return false;
}
pbox->UpdateTransfer(offset, info.uncompressed_size);
offset += bytes_read;
}
return true;
};
// unzip manifest and info
if (!unzip_to("info.json", BuildInfoCachePath(entry))) {
return false;
}
if (!unzip_to("manifest.install", BuildManifestCachePath(entry))) {
return false;
}
for (auto& new_entry : new_manifest) {
if (pbox->ShouldExit()) {
return false;
}
switch (new_entry.command) {
case 'E': // both are the same?
case 'U':
break;
case 'G': { // checks if file exists, if not, extract
if (fs.FileExists(fs::AppendPath("/", new_entry.path))) {
continue;
}
} break;
default:
log_write("bad command: %c\n", new_entry.command);
continue;
}
if (!unzip_to(new_entry.path, new_entry.path)) {
return false;
}
}
// finally finally, remove files no longer in the manifest
for (auto& old_entry : old_manifest) {
bool found = false;
for (auto& new_entry : new_manifest) {
if (!std::strcmp(old_entry.path, new_entry.path)) {
found = true;
break;
}
}
if (!found) {
const auto safe_buf = fs::AppendPath("/", old_entry.path);
// std::strcat(safe_buf, old_entry.path);
if (R_FAILED(fs.DeleteFile(safe_buf, true))) {
log_write("failed to delete: %s\n", safe_buf);
} else {
log_write("deleted file: %s\n", safe_buf);
}
}
}
}
log_write("finished install :)\n");
return true;
}
} // namespace
EntryMenu::EntryMenu(Entry& entry, const LazyImage& default_icon, Menu& menu)
: MenuBase{entry.title}
, m_entry{entry}
, m_default_icon{default_icon}
, m_menu{menu} {
this->SetActions(
std::make_pair(Button::DPAD_DOWN | Button::RS_DOWN, Action{[this](){
if (m_index < (m_options.size() - 1)) {
SetIndex(m_index + 1);
App::PlaySoundEffect(SoundEffect_Focus);
}
}}),
std::make_pair(Button::DPAD_UP | Button::RS_UP, Action{[this](){
if (m_index != 0) {
SetIndex(m_index - 1);
App::PlaySoundEffect(SoundEffect_Focus);
}
}}),
std::make_pair(Button::X, Action{"Options"_i18n, [this](){
auto sidebar = std::make_shared<Sidebar>("Options"_i18n, Sidebar::Side::RIGHT);
sidebar->Add(std::make_shared<SidebarEntryCallback>("More by Author"_i18n, [this](){
m_menu.SetAuthor();
SetPop();
}, true));
sidebar->Add(std::make_shared<SidebarEntryCallback>("Leave Feedback"_i18n, [this](){
std::string out;
if (R_SUCCEEDED(swkbd::ShowText(out)) && !out.empty()) {
const auto post = "name=" "switch_user" "&package=" + m_entry.name + "&message=" + out;
const auto file = BuildFeedbackCachePath(m_entry);
DownloadFileAsync(URL_POST_FEEDBACK, file, post, [](std::vector<u8>& data, bool success){
if (success) {
log_write("got feedback!\n");
} else {
log_write("failed to send feedback :(");
}
});
}
}, true));
App::Push(sidebar);
}}),
// std::make_pair(Button::A, Action{m_entry.status == EntryStatus::Update ? "Update" : "Install", [this](){
// App::Push(std::make_shared<ProgressBox>("App Install", [this](auto pbox){
// InstallApp(pbox, m_entry);
// }, 2));
// }}),
std::make_pair(Button::B, Action{"Back"_i18n, [this](){
SetPop();
}})
);
// SidebarEntryCallback
// if (!m_entries_current.empty() && !GetEntry().url.empty()) {
// options->Add(std::make_shared<SidebarEntryCallback>("Show Release Page"))
// }
SetTitleSubHeading("by " + m_entry.author);
// const char* very_long = "total@fedora:~/dev/switch/sphaira$ nxlink build/MinSizeRel/*.nro total@fedora:~/dev/switch/sphaira$ nxlink build/MinSizeRel/*.nro total@fedora:~/dev/switch/sphaira$ nxlink build/MinSizeRel/*.nro total@fedora:~/dev/switch/sphaira$ nxlink build/MinSizeRel/*.nro";
m_details = std::make_shared<ScrollableText>(m_entry.details, 0, 374, 250, 768, 18);
m_changelog = std::make_shared<ScrollableText>(m_entry.changelog, 0, 374, 250, 768, 18);
m_show_changlog ^= 1;
ShowChangelogAction();
const auto path = BuildBannerCachePath(m_entry);
const auto url = BuildBannerUrl(m_entry);
if (fs::FsNativeSd().FileExists(path)) {
EntryLoadImageFile(path, m_banner);
}
// race condition if we pop the widget before the download completes
if (!m_banner.image) {
DownloadFileAsync(url, path, "", [this, path](std::vector<u8>& data, bool success){
if (success) {
EntryLoadImageFile(path, m_banner);
}
}, nullptr, DownloadPriority::High);
}
// ignore screen shots, most apps don't have any sadly.
#if 0
m_screens.resize(m_entry.screens);
for (u32 i = 0; i < m_screens.size(); i++) {
path = BuildScreensCachePath(m_entry.name, i);
url = BuildScreensUrl(m_entry.name, i);
if (fs::file_exists(path.c_str())) {
EntryLoadImageFile(path, m_screens[i]);
} else {
DownloadFileAsync(url.c_str(), path.c_str(), [this, i, path](std::vector<u8>& data, bool success){
EntryLoadImageFile(path, m_screens[i]);
}, nullptr, DownloadPriority::High);
}
}
#endif
SetSubHeading(m_entry.binary);
SetSubHeading(m_entry.description);
UpdateOptions();
}
EntryMenu::~EntryMenu() {
}
void EntryMenu::Update(Controller* controller, TouchInfo* touch) {
MenuBase::Update(controller, touch);
m_detail_changelog->Update(controller, touch);
}
void EntryMenu::Draw(NVGcontext* vg, Theme* theme) {
MenuBase::Draw(vg, theme);
constexpr Vec4 line_vec(30, 86, 1220, 646);
constexpr Vec4 banner_vec(70, line_vec.y + 20, 848.f, 208.f);
constexpr Vec4 icon_vec(968, line_vec.y + 30, 256, 150);
// nvgSave(vg);
// nvgScissor(vg, line_vec.x, line_vec.y, line_vec.w - line_vec.x, line_vec.h - line_vec.y); // clip
// ON_SCOPE_EXIT(nvgRestore(vg));
DrawIcon(vg, m_banner, m_entry.image.image ? m_entry.image : m_default_icon, banner_vec, false);
DrawIcon(vg, m_entry.image, m_default_icon, icon_vec);
// gfx::drawImage(vg, icon_vec, m_entry.image.image);
constexpr float text_start_x = icon_vec.x;// - 10;
float text_start_y = 218 + line_vec.y;
const float text_inc_y = 32;
const float font_size = 20;
// gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "%s", m_entry.name.c_str());
// gfx::drawTextBox(vg, text_start_x - 20, text_start_y, font_size, icon_vec.w + 20*2, theme->elements[ThemeEntryID_TEXT].colour, m_entry.description.c_str(), NVG_ALIGN_CENTER);
// text_start_y += text_inc_y * 2.0;
// gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "author: %s", m_entry.author.c_str());
// text_start_y += text_inc_y;
gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "version: %s"_i18n.c_str(), m_entry.version.c_str());
text_start_y += text_inc_y;
gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "updated: %s"_i18n.c_str(), m_entry.updated.c_str());
text_start_y += text_inc_y;
gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "category: %s"_i18n.c_str(), m_entry.category.c_str());
text_start_y += text_inc_y;
// gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "license: %s", m_entry.license.c_str());
// text_start_y += text_inc_y;
// gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "title: %s", m_entry.title.c_str());
// text_start_y += text_inc_y;
// gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "filesize: %.2f MiB", (double)m_entry.filesize / 1024.0);
// text_start_y += text_inc_y;
gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "extracted: %.2f MiB"_i18n.c_str(), (double)m_entry.extracted / 1024.0);
text_start_y += text_inc_y;
gfx::drawTextArgs(vg, text_start_x, text_start_y, font_size, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->elements[ThemeEntryID_TEXT].colour, "app_dls: %s"_i18n.c_str(), AppDlToStr(m_entry.app_dls).c_str());
text_start_y += text_inc_y;
// for (const auto& option : m_options) {
const auto& text_col = theme->elements[ThemeEntryID_TEXT].colour;
constexpr float mm = 0;//20;
constexpr Vec4 block{968.f + mm, 110.f, 256.f - mm*2, 60.f};
constexpr float text_xoffset{15.f};
const float x = block.x;
float y = 1.f + text_start_y + (text_inc_y * 3) ;
const float h = block.h;
const float w = block.w;
for (s32 i = m_options.size() - 1; i >= 0; i--) {
const auto& option = m_options[i];
auto text_id = ThemeEntryID_TEXT;
if (m_index == i) {
text_id = ThemeEntryID_TEXT_SELECTED;
gfx::drawRectOutline(vg, 4.f, theme->elements[ThemeEntryID_SELECTED_OVERLAY].colour, x, y, w, h, theme->elements[ThemeEntryID_SELECTED].colour);
} else {
// if (i == m_index_offset) {
// gfx::drawRect(vg, x, y, w, 1.f, text_col);
// }
// gfx::drawRect(vg, x, y + h, w, 1.f, text_col);
}
gfx::drawTextArgs(vg, x + w / 2, y + h / 2, 22, NVG_ALIGN_MIDDLE | NVG_ALIGN_CENTER, theme->elements[ThemeEntryID_TEXT].colour, option.display_text.c_str());
y -= block.h + 18;
}
m_detail_changelog->Draw(vg, theme);
}
void EntryMenu::ShowChangelogAction() {
std::function<void()> func = std::bind(&EntryMenu::ShowChangelogAction, this);
m_show_changlog ^= 1;
if (m_show_changlog) {
SetAction(Button::L, Action{"Details"_i18n, func});
m_detail_changelog = m_changelog;
} else {
SetAction(Button::L, Action{"Changelog"_i18n, func});
m_detail_changelog = m_details;
}
}
void EntryMenu::UpdateOptions() {
const auto launch = [this](){
nro_launch(m_entry.binary);
};
const auto install = [this](){
App::Push(std::make_shared<ProgressBox>("Installing "_i18n + m_entry.title, [this](auto pbox){
return InstallApp(pbox, m_entry);
}, [this](bool success){
if (success) {
m_entry.status = EntryStatus::Installed;
m_menu.SetDirty();
UpdateOptions();
}
}, 2));
};
const auto uninstall = [this](){
App::Push(std::make_shared<ProgressBox>("Uninstalling "_i18n + m_entry.title, [this](auto pbox){
return UninstallApp(pbox, m_entry);
}, [this](bool success){
if (success) {
m_entry.status = EntryStatus::Get;
m_menu.SetDirty();
UpdateOptions();
}
}, 2));
};
const Option install_option{"Install"_i18n, install};
const Option update_option{"Update"_i18n, install};
const Option launch_option{"Launch"_i18n, launch};
const Option remove_option{"Remove"_i18n, "Completely remove "_i18n + m_entry.title + '?', uninstall};
m_options.clear();
switch (m_entry.status) {
case EntryStatus::Get:
m_options.emplace_back(install_option);
break;
case EntryStatus::Installed:
if (!m_entry.binary.empty() && m_entry.binary != "none") {
m_options.emplace_back(launch_option);
}
m_options.emplace_back(remove_option);
break;
case EntryStatus::Local:
if (!m_entry.binary.empty() && m_entry.binary != "none") {
m_options.emplace_back(launch_option);
}
m_options.emplace_back(update_option);
break;
case EntryStatus::Update:
m_options.emplace_back(update_option);
m_options.emplace_back(remove_option);
break;
}
SetIndex(0);
}
void EntryMenu::SetIndex(std::size_t index) {
m_index = index;
const auto option = m_options[m_index];
if (option.confirm_text.empty()) {
SetAction(Button::A, Action{option.display_text, option.func});
} else {
SetAction(Button::A, Action{option.display_text, [this, option](){
App::Push(std::make_shared<OptionBox>(option.confirm_text, "No"_i18n, "Yes"_i18n, 1, [this, option](auto op_index){
if (op_index && *op_index) {
option.func();
}
}));
}});
}
}
auto toLower(const std::string& str) -> std::string {
std::string lower;
std::transform(str.cbegin(), str.cend(), std::back_inserter(lower), tolower);
return lower;
}
Menu::Menu(const std::vector<NroEntry>& nro_entries) : MenuBase{"AppStore"_i18n}, m_nro_entries{nro_entries} {
fs::FsNativeSd fs;
fs.CreateDirectoryRecursively("/switch/sphaira/cache/appstore/icons");
fs.CreateDirectoryRecursively("/switch/sphaira/cache/appstore/banners");
fs.CreateDirectoryRecursively("/switch/sphaira/cache/appstore/screens");
// m_span = m_entries;
this->SetActions(
std::make_pair(Button::RIGHT, Action{[this](){
if (m_entries_current.empty()) {
return;
}
if (m_index < (m_entries_current.size() - 1) && (m_index + 1) % 3 != 0) {
SetIndex(m_index + 1);
App::PlaySoundEffect(SoundEffect_Scroll);
log_write("moved right\n");
}
}}),
std::make_pair(Button::LEFT, Action{[this](){
if (m_entries_current.empty()) {
return;
}
if (m_index != 0 && (m_index % 3) != 0) {
SetIndex(m_index - 1);
App::PlaySoundEffect(SoundEffect_Scroll);
log_write("moved left\n");
}
}}),
std::make_pair(Button::DOWN, Action{[this](){
if (ScrollHelperDown(m_index, m_start, 3, 9, m_entries_current.size())) {
SetIndex(m_index);
}
}}),
std::make_pair(Button::UP, Action{[this](){
if (m_entries_current.empty()) {
return;
}
if (m_index >= 3) {
SetIndex(m_index - 3);
App::PlaySoundEffect(SoundEffect_Scroll);
if (m_index < m_start ) {
// log_write("moved up\n");
m_start -= 3;
}
}
}}),
std::make_pair(Button::R2, Action{(u8)ActionType::HELD, [this](){
if (ScrollHelperDown(m_index, m_start, 9, 9, m_entries_current.size())) {
SetIndex(m_index);
}
}}),
std::make_pair(Button::L2, Action{(u8)ActionType::HELD, [this](){
if (m_entries.empty()) {
return;
}
if (m_index >= 9) {
SetIndex(m_index - 9);
App::PlaySoundEffect(SoundEffect_Scroll);
while (m_index < m_start) {
// log_write("moved up\n");
m_start -= 3;
}
}
}}),
std::make_pair(Button::A, Action{"Info"_i18n, [this](){
if (m_entries_current.empty()) {
// log_write("pushing A when empty: size: %zu count: %zu\n", repo_json.size(), m_entries_current.size());
return;
}
App::Push(std::make_shared<EntryMenu>(m_entries[m_entries_current[m_index]], m_default_image, *this));
}}),
std::make_pair(Button::X, Action{"Options"_i18n, [this](){
auto options = std::make_shared<Sidebar>("AppStore Options"_i18n, Sidebar::Side::RIGHT);
ON_SCOPE_EXIT(App::Push(options));
SidebarEntryArray::Items filter_items;
filter_items.push_back("All"_i18n);
filter_items.push_back("Games"_i18n);
filter_items.push_back("Emulators"_i18n);
filter_items.push_back("Tools"_i18n);
filter_items.push_back("Advanced"_i18n);
filter_items.push_back("Themes"_i18n);
filter_items.push_back("Legacy"_i18n);
filter_items.push_back("Misc"_i18n);
SidebarEntryArray::Items sort_items;
sort_items.push_back("Updated"_i18n);
sort_items.push_back("Downloads"_i18n);
sort_items.push_back("Size"_i18n);
sort_items.push_back("Alphabetical"_i18n);
SidebarEntryArray::Items order_items;
order_items.push_back("Decending"_i18n);
order_items.push_back("Ascending"_i18n);
options->Add(std::make_shared<SidebarEntryArray>("Filter"_i18n, filter_items, [this, filter_items](std::size_t& index_out){
SetFilter((Filter)index_out);
}, (std::size_t)m_filter));
options->Add(std::make_shared<SidebarEntryArray>("Sort"_i18n, sort_items, [this, sort_items](std::size_t& index_out){
SetSort((SortType)index_out);
}, (std::size_t)m_sort));
options->Add(std::make_shared<SidebarEntryArray>("Order"_i18n, order_items, [this, order_items](std::size_t& index_out){
SetOrder((OrderType)index_out);
}, (std::size_t)m_order));
options->Add(std::make_shared<SidebarEntryCallback>("Search"_i18n, [this](){
std::string out;
if (R_SUCCEEDED(swkbd::ShowText(out)) && !out.empty()) {
SetSearch(out);
log_write("got %s\n", out.c_str());
}
}));
}})
);
m_repo_download_state = ImageDownloadState::Progress;
#if 0
DownloadMemoryAsync(URL_JSON, [this](std::vector<u8>& data, bool success){
if (success) {
repo_json = data;
repo_json.push_back('\0');
m_repo_download_state = ImageDownloadState::Done;
if (HasFocus()) {
ScanHomebrew();
}
} else {
m_repo_download_state = ImageDownloadState::Failed;
}
});
#else
FsTimeStampRaw time_stamp{};
u64 current_time{};
bool download_file = false;
if (R_SUCCEEDED(fs.GetFsOpenResult())) {
fs.GetFileTimeStampRaw(REPO_PATH, &time_stamp);
timeGetCurrentTime(TimeType_Default, &current_time);
}
// this fails if we don't have the file or on fw < 3.0.0
if (!time_stamp.is_valid) {
download_file = true;
} else {
// check the date, if older than 1hour, then fetch new file
// this relaxes the spam to their server, don't want to fetch repo
// every time the user opens the app!
const auto time_file = time_stamp.created;
const auto time_cur = current_time;
const auto day = 60 * 60;
if (time_file > time_cur || time_cur - time_file >= day) {
log_write("repo.json expired, downloading new! time_file: %zu time_cur: %zu\n", time_file, time_cur);
download_file = true;
} else {
log_write("repo.json not expired! time_file: %zu time_cur: %zu\n", time_file, time_cur);
}
}
// todo: remove me soon
// download_file = true;
if (download_file) {
DownloadFileAsync(URL_JSON, REPO_PATH, "", [this](std::vector<u8>& data, bool success){
if (success) {
m_repo_download_state = ImageDownloadState::Done;
if (HasFocus()) {
ScanHomebrew();
}
} else {
m_repo_download_state = ImageDownloadState::Failed;
}
});
} else {
m_repo_download_state = ImageDownloadState::Done;
}
#endif
m_filter = (Filter)ini_getl(INI_SECTION, "filter", m_filter, App::CONFIG_PATH);
m_sort = (SortType)ini_getl(INI_SECTION, "sort", m_sort, App::CONFIG_PATH);
m_order = (OrderType)ini_getl(INI_SECTION, "order", m_order, App::CONFIG_PATH);
Sort();
}
Menu::~Menu() {
}
void Menu::Update(Controller* controller, TouchInfo* touch) {
MenuBase::Update(controller, touch);
}
void Menu::Draw(NVGcontext* vg, Theme* theme) {
MenuBase::Draw(vg, theme);
if (m_entries.empty()) {
gfx::drawTextArgs(vg, SCREEN_WIDTH / 2.f, SCREEN_HEIGHT / 2.f, 36.f, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, gfx::Colour::YELLOW, "Loading..."_i18n.c_str());
return;
}
if (m_entries_current.empty()) {
gfx::drawTextArgs(vg, SCREEN_WIDTH / 2.f, SCREEN_HEIGHT / 2.f, 36.f, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, gfx::Colour::YELLOW, "Empty!"_i18n.c_str());
return;
}
const u64 SCROLL = m_start;
const u64 max_entry_display = 9;
const u64 nro_total = m_entries_current.size();
const u64 cursor_pos = m_index;
// only draw scrollbar if needed
if (nro_total > max_entry_display) {
const auto scrollbar_size = 500.f;
const auto sb_h = 3.f / (float)nro_total * scrollbar_size;
const auto sb_y = SCROLL / 3.f;
gfx::drawRect(vg, SCREEN_WIDTH - 50, 100, 10, scrollbar_size, theme->elements[ThemeEntryID_GRID].colour);
gfx::drawRect(vg, SCREEN_WIDTH - 50+2, 102 + sb_h * sb_y, 10-4, sb_h + (sb_h * 2) - 4, theme->elements[ThemeEntryID_TEXT_SELECTED].colour);
}
for (u64 i = 0, pos = SCROLL, y = 110, w = 370, h = 155; pos < nro_total && i < max_entry_display; y += h + 10) {
for (u64 j = 0, x = 75; j < 3 && pos < nro_total && i < max_entry_display; j++, i++, pos++, x += w + 10) {
const auto index = m_entries_current[pos];
auto& e = m_entries[index];
// lazy load image
if (!e.image.image) {
switch (e.image.state) {
case ImageDownloadState::None: {
const auto path = BuildIconCachePath(e);
if (fs::FsNativeSd().FileExists(path)) {
EntryLoadImageFile(path, e.image);
} else {
const auto url = BuildIconUrl(e);
e.image.state = ImageDownloadState::Progress;
DownloadFileAsync(url, path, "", [this, index](std::vector<u8>& data, bool success) {
if (success) {
m_entries[index].image.state = ImageDownloadState::Done;
} else {
m_entries[index].image.state = ImageDownloadState::Failed;
log_write("failed to download image\n");
}
}, nullptr, DownloadPriority::High);
}
} break;
case ImageDownloadState::Progress: {
} break;
case ImageDownloadState::Done: {
EntryLoadImageFile(BuildIconCachePath(e), e.image);
} break;
case ImageDownloadState::Failed: {
} break;
}
}
auto text_id = ThemeEntryID_TEXT;
if (pos == cursor_pos) {
text_id = ThemeEntryID_TEXT_SELECTED;
gfx::drawRectOutline(vg, 4.f, theme->elements[ThemeEntryID_SELECTED_OVERLAY].colour, x, y, w, h, theme->elements[ThemeEntryID_SELECTED].colour);
} else {
DrawElement(x, y, w, h, ThemeEntryID_GRID);
}
constexpr double image_scale = 256.0 / 115.0;
// const float image_size = 256 / image_scale;
// const float image_size_h = 150 / image_scale;
DrawIcon(vg, e.image, m_default_image, x + 20, y + 20, 115, 115, true, image_scale);
// gfx::drawImage(vg, x + 20, y + 20, image_size, image_size_h, e.image.image ? e.image.image : m_default_image);
nvgSave(vg);
nvgScissor(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->elements[text_id].colour, e.title.c_str());
gfx::drawTextArgs(vg, x + 148, y + 80, font_size, NVG_ALIGN_LEFT, theme->elements[text_id].colour, e.author.c_str());
gfx::drawTextArgs(vg, x + 148, y + 115, font_size, NVG_ALIGN_LEFT, theme->elements[text_id].colour, e.version.c_str());
}
nvgRestore(vg);
float i_size = 22;
switch (e.status) {
case EntryStatus::Get:
gfx::drawImageRounded(vg, x + w - 30.f, y + 110, i_size, i_size, m_get.image);
break;
case EntryStatus::Installed:
gfx::drawImageRounded(vg, x + w - 30.f, y + 110, i_size, i_size, m_installed.image);
break;
case EntryStatus::Local:
gfx::drawImageRounded(vg, x + w - 30.f, y + 110, i_size, i_size, m_local.image);
break;
case EntryStatus::Update:
gfx::drawImageRounded(vg, x + w - 30.f, y + 110, i_size, i_size, m_update.image);
break;
}
}
}
}
void Menu::OnFocusGained() {
MenuBase::OnFocusGained();
// log_write("saying we got focus base: size: %zu count: %zu\n", repo_json.size(), m_entries.size());
if (!m_default_image.image) {
if (R_SUCCEEDED(romfsInit())) {
ON_SCOPE_EXIT(romfsExit());
EntryLoadImageFile("romfs:/default.png", m_default_image);
EntryLoadImageFile("romfs:/UPDATE.png", m_update);
EntryLoadImageFile("romfs:/GET.png", m_get);
EntryLoadImageFile("romfs:/LOCAL.png", m_local);
EntryLoadImageFile("romfs:/INSTALLED.png", m_installed);
}
}
if (m_entries.empty()) {
// log_write("got focus with empty size: size: %zu count: %zu\n", repo_json.size(), m_entries.size());
if (m_repo_download_state == ImageDownloadState::Done) {
// log_write("is done: size: %zu count: %zu\n", repo_json.size(), m_entries.size());
ScanHomebrew();
}
} else {
if (m_dirty) {
m_dirty = false;
const auto& current_entry = m_entries[m_entries_current[m_index]];
// m_start = 0;
// m_index = 0;
log_write("\nold index: %zu start: %zu\n", m_index, m_start);
// old index: 19 start: 12
Sort();
for (u32 i = 0; i < m_entries_current.size(); i++) {
if (current_entry.name == m_entries[m_entries_current[i]].name) {
SetIndex(i);
if (i >= 9) {
m_start = (i - 9) / 3 * 3 + 3;
} else {
m_start = 0;
}
log_write("\nnew index: %zu start: %zu\n", m_index, m_start);
break;
}
}
}
}
}
void Menu::SetIndex(std::size_t index) {
m_index = index;
if (!m_index) {
m_start = 0;
}
this->SetSubHeading(std::to_string(m_index + 1) + " / " + std::to_string(m_entries_current.size()));
}
void Menu::ScanHomebrew() {
from_json(REPO_PATH, m_entries);
fs::FsNativeSd fs;
if (R_FAILED(fs.GetFsOpenResult())) {
log_write("failed to open sd card in appstore scan\n");
return;
}
// pre-allocate the max size, can shrink later if needed
for (auto& index : m_entries_index) {
index.reserve(m_entries.size());
}
for (u32 i = 0; i < m_entries.size(); i++) {
auto& e = m_entries[i];
m_entries_index[Filter_All].push_back(i);
if (e.category == std::string_view{"game"}) {
m_entries_index[Filter_Games].push_back(i);
} else if (e.category == std::string_view{"emu"}) {
m_entries_index[Filter_Emulators].push_back(i);
} else if (e.category == std::string_view{"tool"}) {
m_entries_index[Filter_Tools].push_back(i);
} else if (e.category == std::string_view{"advanced"}) {
m_entries_index[Filter_Advanced].push_back(i);
} else if (e.category == std::string_view{"theme"}) {
m_entries_index[Filter_Themes].push_back(i);
} else if (e.category == std::string_view{"legacy"}) {
m_entries_index[Filter_Legacy].push_back(i);
} else {
m_entries_index[Filter_Misc].push_back(i);
}
// fwiw, this is how N stores update info
e.updated_num = std::atoi(e.updated.c_str()); // day
e.updated_num += std::atoi(e.updated.c_str() + 3) * 100; // month
e.updated_num += std::atoi(e.updated.c_str() + 6) * 100 * 100; // year
e.status = EntryStatus::Get;
// if binary is present, check for it, if not avalible, report as not installed
// if there is not a binary path, then we have to trust the info.json
// this can result in applications being shown as installed even though they
// are deleted, this includes sys-modules.
if (e.binary.empty() || e.binary == "none") {
ReadFromInfoJson(e);
} else {
if (fs.FileExists(e.binary)) {
// first check the info.json
ReadFromInfoJson(e);
// if we get here, this means that we have the file, but not the .info file
// report the file as locally installed to match hb-appstore.
if (e.status == EntryStatus::Get) {
e.status = EntryStatus::Local;
}
}
}
e.image.state = ImageDownloadState::None;
e.image.image = 0; // images are lazy loaded
}
for (auto& index : m_entries_index) {
index.shrink_to_fit();
}
SetFilter(Filter_All);
SetIndex(0);
}
void Menu::Sort() {
// log_write("doing sort: size: %zu count: %zu\n", repo_json.size(), m_entries.size());
// returns true if lhs should be before rhs
const auto sorter = [this](EntryMini _lhs, EntryMini _rhs) -> bool {
const auto& lhs = m_entries[_lhs];
const auto& rhs = m_entries[_rhs];
// fallback to name compare if the updated num is the same
if (lhs.status == EntryStatus::Update && !(rhs.status == EntryStatus::Update)) {
return true;
} else if (!(lhs.status == EntryStatus::Update) && rhs.status == EntryStatus::Update) {
return false;
} else if (lhs.status == EntryStatus::Installed && !(rhs.status == EntryStatus::Installed)) {
return true;
} else if (!(lhs.status == EntryStatus::Installed) && rhs.status == EntryStatus::Installed) {
return false;
} else if (lhs.status == EntryStatus::Local && !(rhs.status == EntryStatus::Local)) {
return true;
} else if (!(lhs.status == EntryStatus::Local) && rhs.status == EntryStatus::Local) {
return false;
} else {
switch (m_sort) {
case SortType_Updated: {
if (lhs.updated_num == rhs.updated_num) {
return strcasecmp(lhs.name.c_str(), rhs.name.c_str()) < 0;
} else if (m_order == OrderType_Decending) {
return lhs.updated_num > rhs.updated_num;
} else {
return lhs.updated_num < rhs.updated_num;
}
} break;
case SortType_Downloads: {
if (lhs.app_dls == rhs.app_dls) {
return strcasecmp(lhs.name.c_str(), rhs.name.c_str()) < 0;
} else if (m_order == OrderType_Decending) {
return lhs.app_dls > rhs.app_dls;
} else {
return lhs.app_dls < rhs.app_dls;
}
} break;
case SortType_Size: {
if (lhs.extracted == rhs.extracted) {
return strcasecmp(lhs.name.c_str(), rhs.name.c_str()) < 0;
} else if (m_order == OrderType_Decending) {
return lhs.extracted > rhs.extracted;
} else {
return lhs.extracted < rhs.extracted;
}
} break;
case SortType_Alphabetical: {
if (m_order == OrderType_Decending) {
return strcasecmp(lhs.name.c_str(), rhs.name.c_str()) < 0;
} else {
return strcasecmp(lhs.name.c_str(), rhs.name.c_str()) > 0;
}
} break;
}
std::unreachable();
}
};
char subheader[128]{};
std::snprintf(subheader, sizeof(subheader), "Filter: %s | Sort: %s | Order: %s"_i18n.c_str(), i18n::get(FILTER_STR[m_filter]).c_str(), i18n::get(SORT_STR[m_sort]).c_str(), i18n::get(ORDER_STR[m_order]).c_str());
SetTitleSubHeading(subheader);
std::sort(m_entries_current.begin(), m_entries_current.end(), sorter);
}
void Menu::SetFilter(Filter filter) {
m_is_search = false;
m_is_author = false;
RemoveAction(Button::B);
m_filter = filter;
m_entries_current = m_entries_index[m_filter];
ini_putl(INI_SECTION, "filter", m_filter, App::CONFIG_PATH);
SetIndex(0);
Sort();
}
void Menu::SetSort(SortType sort) {
m_sort = sort;
ini_putl(INI_SECTION, "sort", m_sort, App::CONFIG_PATH);
SetIndex(0);
Sort();
}
void Menu::SetOrder(OrderType order) {
m_order = order;
ini_putl(INI_SECTION, "order", m_order, App::CONFIG_PATH);
SetIndex(0);
Sort();
}
void Menu::SetSearch(const std::string& term) {
if (!m_is_search) {
m_entry_search_jump_back = m_index;
}
m_search_term = term;
m_entries_index_search.clear();
const auto query = toLower(m_search_term);
const auto npos = std::string::npos;
for (u64 i = 0; i < m_entries.size(); i++) {
const auto& e = m_entries[i];
if (toLower(e.title).find(query) != npos || toLower(e.author).find(query) != npos || toLower(e.details).find(query) != npos || toLower(e.description).find(query) != npos) {
m_entries_index_search.emplace_back(i);
}
}
SetAction(Button::B, Action{"Back"_i18n, [this](){
SetFilter(m_filter);
SetIndex(m_entry_search_jump_back);
if (m_entry_search_jump_back >= 9) {
m_start = (m_entry_search_jump_back - 9) / 3 * 3 + 3;
} else {
m_start = 0;
}
}});
m_is_search = true;
m_entries_current = m_entries_index_search;
SetIndex(0);
Sort();
}
void Menu::SetAuthor() {
if (!m_is_author) {
m_entry_author_jump_back = m_index;
}
m_author_term = m_entries[m_entries_current[m_index]].author;
m_entries_index_author.clear();
for (u64 i = 0; i < m_entries.size(); i++) {
const auto& e = m_entries[i];
if (e.author.find(m_author_term) != std::string::npos) {
m_entries_index_author.emplace_back(i);
}
}
SetAction(Button::B, Action{"Back"_i18n, [this](){
if (m_is_search) {
SetSearch(m_search_term);
} else {
SetFilter(m_filter);
}
SetIndex(m_entry_author_jump_back);
if (m_entry_author_jump_back >= 9) {
m_start = (m_entry_author_jump_back - 9) / 3 * 3 + 3;
} else {
m_start = 0;
}
}});
m_is_author = true;
m_entries_current = m_entries_index_author;
SetIndex(0);
Sort();
}
LazyImage::~LazyImage() {
if (image) {
nvgDeleteImage(App::GetVg(), image);
}
}
} // namespace sphaira::ui::menu::appstore