diff --git a/sphaira/CMakeLists.txt b/sphaira/CMakeLists.txt index 015a4be..24710f4 100644 --- a/sphaira/CMakeLists.txt +++ b/sphaira/CMakeLists.txt @@ -40,6 +40,7 @@ add_executable(sphaira source/ui/menus/irs_menu.cpp source/ui/menus/main_menu.cpp source/ui/menus/menu_base.cpp + source/ui/menus/save_menu.cpp source/ui/menus/themezer.cpp source/ui/menus/ghdl.cpp source/ui/menus/usb_menu.cpp @@ -79,6 +80,7 @@ add_executable(sphaira source/i18n.cpp source/ftpsrv_helper.cpp source/threaded_file_transfer.cpp + source/minizip_helper.cpp source/usb/base.cpp source/usb/usbds.cpp @@ -198,8 +200,8 @@ FetchContent_Declare(libusbhsfs ) FetchContent_Declare(libnxtc - GIT_REPOSITORY https://github.com/DarkMatterCore/libnxtc.git - GIT_TAG v0.0.2 + GIT_REPOSITORY https://github.com/ITotalJustice/libnxtc.git + GIT_TAG 0d369b8 ) FetchContent_Declare(nvjpg diff --git a/sphaira/include/fs.hpp b/sphaira/include/fs.hpp index ec79e9e..99d9388 100644 --- a/sphaira/include/fs.hpp +++ b/sphaira/include/fs.hpp @@ -527,27 +527,37 @@ struct FsNativeSd final : FsNative { #endif struct FsNativeBis final : FsNative { - FsNativeBis(FsBisPartitionId id, const FsPath& string, bool ignore_read_only = true) : FsNative{ignore_read_only} { + FsNativeBis(FsBisPartitionId id, const FsPath& string) { m_open_result = fsOpenBisFileSystem(&m_fs, id, string); } }; struct FsNativeImage final : FsNative { - FsNativeImage(FsImageDirectoryId id, bool ignore_read_only = true) : FsNative{ignore_read_only} { + FsNativeImage(FsImageDirectoryId id) { m_open_result = fsOpenImageDirectoryFileSystem(&m_fs, id); } }; struct FsNativeContentStorage final : FsNative { - FsNativeContentStorage(FsContentStorageId id, bool ignore_read_only = true) : FsNative{ignore_read_only} { + FsNativeContentStorage(FsContentStorageId id) { m_open_result = fsOpenContentStorageFileSystem(&m_fs, id); } }; struct FsNativeGameCard final : FsNative { - FsNativeGameCard(const FsGameCardHandle* handle, FsGameCardPartition partition, bool ignore_read_only = true) : FsNative{ignore_read_only} { + FsNativeGameCard(const FsGameCardHandle* handle, FsGameCardPartition partition) { m_open_result = fsOpenGameCardFileSystem(&m_fs, handle, partition); } }; +struct FsNativeSave final : FsNative { + FsNativeSave(FsSaveDataSpaceId save_data_space_id, const FsSaveDataAttribute *attr, bool read_only) { + if (read_only) { + m_open_result = fsOpenReadOnlySaveDataFileSystem(&m_fs, save_data_space_id, attr); + } else { + m_open_result = fsOpenSaveDataFileSystem(&m_fs, save_data_space_id, attr); + } + } +}; + } // namespace fs diff --git a/sphaira/include/minizip_helper.hpp b/sphaira/include/minizip_helper.hpp new file mode 100644 index 0000000..0b4fc2e --- /dev/null +++ b/sphaira/include/minizip_helper.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + +namespace sphaira::mz { + +struct MzMem { + std::vector buf; + size_t offset; +}; + +struct MzSpan { + std::span buf; + size_t offset; +}; + +void FileFuncMem(MzMem* mem, zlib_filefunc64_def* funcs); +void FileFuncSpan(MzSpan* span, zlib_filefunc64_def* funcs); +void FileFuncStdio(zlib_filefunc64_def* funcs); + +} // namespace sphaira::mz diff --git a/sphaira/include/threaded_file_transfer.hpp b/sphaira/include/threaded_file_transfer.hpp index acd4e49..99b505a 100644 --- a/sphaira/include/threaded_file_transfer.hpp +++ b/sphaira/include/threaded_file_transfer.hpp @@ -43,7 +43,7 @@ Result TransferPull(ui::ProgressBox* pbox, s64 size, ReadCallback rfunc, StartCa Result TransferUnzip(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs::FsPath& path, s64 size, u32 crc32 = 0); // same as above but for zipping files. -Result TransferZip(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs::FsPath& path); +Result TransferZip(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs::FsPath& path, u32* crc32 = nullptr); // passes the name inside the zip an final output path. using UnzipAllFilter = std::function; diff --git a/sphaira/include/ui/menus/filebrowser.hpp b/sphaira/include/ui/menus/filebrowser.hpp index c76f451..3f32cd1 100644 --- a/sphaira/include/ui/menus/filebrowser.hpp +++ b/sphaira/include/ui/menus/filebrowser.hpp @@ -181,6 +181,9 @@ struct FsView final : Widget { void SetSide(ViewSide side); + static auto get_collection(fs::Fs* fs, const fs::FsPath& path, const fs::FsPath& parent_name, FsDirCollection& out, bool inc_file, bool inc_dir, bool inc_size) -> Result; + static auto get_collections(fs::Fs* fs, const fs::FsPath& path, const fs::FsPath& parent_name, FsDirCollections& out, bool inc_size = false) -> Result; + private: void SetIndex(s64 index); void InstallForwarder(); @@ -249,9 +252,6 @@ private: void OnRenameCallback(); auto CheckIfUpdateFolder() -> Result; - static auto get_collection(fs::Fs* fs, const fs::FsPath& path, const fs::FsPath& parent_name, FsDirCollection& out, bool inc_file, bool inc_dir, bool inc_size) -> Result; - static auto get_collections(fs::Fs* fs, const fs::FsPath& path, const fs::FsPath& parent_name, FsDirCollections& out, bool inc_size = false) -> Result; - auto get_collection(const fs::FsPath& path, const fs::FsPath& parent_name, FsDirCollection& out, bool inc_file, bool inc_dir, bool inc_size) -> Result; auto get_collections(const fs::FsPath& path, const fs::FsPath& parent_name, FsDirCollections& out, bool inc_size = false) -> Result; diff --git a/sphaira/include/ui/menus/game_menu.hpp b/sphaira/include/ui/menus/game_menu.hpp index 77db936..81c0ea5 100644 --- a/sphaira/include/ui/menus/game_menu.hpp +++ b/sphaira/include/ui/menus/game_menu.hpp @@ -6,6 +6,7 @@ #include "option.hpp" #include #include +#include namespace sphaira::ui::menu::game { diff --git a/sphaira/include/ui/menus/save_menu.hpp b/sphaira/include/ui/menus/save_menu.hpp new file mode 100644 index 0000000..08032fe --- /dev/null +++ b/sphaira/include/ui/menus/save_menu.hpp @@ -0,0 +1,156 @@ +#pragma once + +#include "ui/menus/grid_menu_base.hpp" +#include "ui/list.hpp" +#include "fs.hpp" +#include "option.hpp" +#include +#include +#include + +namespace sphaira::ui::menu::save { + +enum class NacpLoadStatus { + // not yet attempted to be loaded. + None, + // started loading. + Progress, + // loaded, ready to parse. + Loaded, + // failed to load, do not attempt to load again! + Error, +}; + +struct Entry final : FsSaveDataInfo { + NacpLanguageEntry lang{}; + int image{}; + bool selected{}; + + std::shared_ptr control{}; + u64 jpeg_size{}; + NacpLoadStatus status{NacpLoadStatus::None}; + + auto GetName() const -> const char* { + return lang.name; + } + + auto GetAuthor() const -> const char* { + return lang.author; + } +}; + +struct AccountEntry { + AccountUid uid; + AccountProfile profile; + AccountProfileBase base; +}; + +struct ThreadResultData { + u64 id{}; + std::shared_ptr control{}; + u64 jpeg_size{}; + NacpLanguageEntry lang{}; + NacpLoadStatus status{NacpLoadStatus::None}; +}; + +struct ThreadData { + ThreadData(); + + auto IsRunning() const -> bool; + void Run(); + void Close(); + void Push(u64 id); + void Push(std::span entries); + void Pop(std::vector& out); + +private: + UEvent m_uevent{}; + Mutex m_mutex_id{}; + Mutex m_mutex_result{}; + + // app_ids pushed to the queue, signal uevent when pushed. + std::vector m_ids{}; + // control data pushed to the queue. + std::vector m_result{}; + + std::atomic_bool m_running{}; +}; + +enum SortType { + SortType_Updated, +}; + +enum OrderType { + OrderType_Descending, + OrderType_Ascending, +}; + +using LayoutType = grid::LayoutType; + +struct Menu final : grid::Menu { + Menu(u32 flags); + ~Menu(); + + auto GetShortTitle() const -> const char* override { return "Saves"; }; + 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 Sort(); + void SortAndFindLastFile(bool scan); + void FreeEntries(); + void OnLayoutChange(); + + auto GetSelectedEntries() const { + std::vector out; + for (auto& e : m_entries) { + if (e.selected) { + out.emplace_back(e); + } + } + + if (!m_entries.empty() && out.empty()) { + out.emplace_back(m_entries[m_index]); + } + + return out; + } + + void ClearSelection() { + for (auto& e : m_entries) { + e.selected = false; + } + + m_selected_count = 0; + } + + void BackupSaves(std::vector>& entries); + void RestoreSave(); + +private: + static constexpr inline const char* INI_SECTION = "saves"; + + std::vector m_entries{}; + s64 m_index{}; // where i am in the array + s64 m_selected_count{}; + std::unique_ptr m_list{}; + bool m_is_reversed{}; + bool m_dirty{}; + + std::vector m_accounts{}; + s64 m_account_index{}; + + ThreadData m_thread_data{}; + Thread m_thread{}; + + option::OptionLong m_sort{INI_SECTION, "sort", SortType::SortType_Updated}; + option::OptionLong m_order{INI_SECTION, "order", OrderType::OrderType_Descending}; + option::OptionLong m_layout{INI_SECTION, "layout", LayoutType::LayoutType_Grid}; + option::OptionBool m_auto_backup_on_restore{INI_SECTION, "auto_backup_on_restore", true}; + option::OptionBool m_compress_save_backup{INI_SECTION, "compress_save_backup", false}; +}; + +} // namespace sphaira::ui::menu::save diff --git a/sphaira/include/ui/progress_box.hpp b/sphaira/include/ui/progress_box.hpp index 774a6d3..b6e3ee6 100644 --- a/sphaira/include/ui/progress_box.hpp +++ b/sphaira/include/ui/progress_box.hpp @@ -25,6 +25,7 @@ struct ProgressBox final : Widget { auto Update(Controller* controller, TouchInfo* touch) -> void override; auto Draw(NVGcontext* vg, Theme* theme) -> void override; + auto SetActionName(const std::string& action) -> ProgressBox&; auto SetTitle(const std::string& title) -> ProgressBox&; auto NewTransfer(const std::string& transfer) -> ProgressBox&; auto UpdateTransfer(s64 offset, s64 size) -> ProgressBox&; diff --git a/sphaira/source/minizip_helper.cpp b/sphaira/source/minizip_helper.cpp new file mode 100644 index 0000000..0c0fac3 --- /dev/null +++ b/sphaira/source/minizip_helper.cpp @@ -0,0 +1,210 @@ +#include "minizip_helper.hpp" +#include +#include +#include +#include + +namespace sphaira::mz { +namespace { + +voidpf minizip_open_file_func_mem(voidpf opaque, const void* filename, int mode) { + return opaque; +} + +ZPOS64_T minizip_tell_file_func_mem(voidpf opaque, voidpf stream) { + auto mem = static_cast(opaque); + return mem->offset; +} + +long minizip_seek_file_func_mem(voidpf opaque, voidpf stream, ZPOS64_T offset, int origin) { + auto mem = static_cast(opaque); + size_t new_offset = 0; + + switch (origin) { + case ZLIB_FILEFUNC_SEEK_SET: new_offset = offset; break; + case ZLIB_FILEFUNC_SEEK_CUR: new_offset = mem->offset + offset; break; + case ZLIB_FILEFUNC_SEEK_END: new_offset = (mem->buf.size() - 1) + offset; break; + default: return -1; + } + + if (new_offset > mem->buf.size()) { + return -1; + } + + mem->offset = new_offset; + return 0; +} + +uLong minizip_read_file_func_mem(voidpf opaque, voidpf stream, void* buf, uLong size) { + auto mem = static_cast(opaque); + + size = std::min(size, mem->buf.size() - mem->offset); + std::memcpy(buf, mem->buf.data() + mem->offset, size); + mem->offset += size; + + return size; +} + +uLong minizip_write_file_func_mem(voidpf opaque, voidpf stream, const void* buf, uLong size) { + auto mem = static_cast(opaque); + + // give it more memory + if (mem->buf.capacity() < mem->offset + size) { + mem->buf.reserve(mem->buf.capacity() + 1024*1024*64); + } + + if (mem->buf.size() < mem->offset + size) { + mem->buf.resize(mem->offset + size); + } + + std::memcpy(mem->buf.data() + mem->offset, buf, size); + mem->offset += size; + + return size; +} + +int minizip_close_file_func_mem(voidpf opaque, voidpf stream) { + return 0; +} + +constexpr zlib_filefunc64_def zlib_filefunc_mem = { + .zopen64_file = minizip_open_file_func_mem, + .zread_file = minizip_read_file_func_mem, + .zwrite_file = minizip_write_file_func_mem, + .ztell64_file = minizip_tell_file_func_mem, + .zseek64_file = minizip_seek_file_func_mem, + .zclose_file = minizip_close_file_func_mem, +}; + +voidpf minizip_open_file_func_span(voidpf opaque, const void* filename, int mode) { + return opaque; +} + +ZPOS64_T minizip_tell_file_func_span(voidpf opaque, voidpf stream) { + auto mem = static_cast(opaque); + return mem->offset; +} + +long minizip_seek_file_func_span(voidpf opaque, voidpf stream, ZPOS64_T offset, int origin) { + auto mem = static_cast(opaque); + size_t new_offset = 0; + + switch (origin) { + case ZLIB_FILEFUNC_SEEK_SET: new_offset = offset; break; + case ZLIB_FILEFUNC_SEEK_CUR: new_offset = mem->offset + offset; break; + case ZLIB_FILEFUNC_SEEK_END: new_offset = (mem->buf.size() - 1) + offset; break; + default: return -1; + } + + if (new_offset > mem->buf.size()) { + return -1; + } + + mem->offset = new_offset; + return 0; +} + +uLong minizip_read_file_func_span(voidpf opaque, voidpf stream, void* buf, uLong size) { + auto mem = static_cast(opaque); + + size = std::min(size, mem->buf.size() - mem->offset); + std::memcpy(buf, mem->buf.data() + mem->offset, size); + mem->offset += size; + + return size; +} + +int minizip_close_file_func_span(voidpf opaque, voidpf stream) { + return 0; +} + +constexpr zlib_filefunc64_def zlib_filefunc_span = { + .zopen64_file = minizip_open_file_func_span, + .zread_file = minizip_read_file_func_span, + .ztell64_file = minizip_tell_file_func_span, + .zseek64_file = minizip_seek_file_func_span, + .zclose_file = minizip_close_file_func_span, +}; + +voidpf minizip_open_file_func_stdio(voidpf opaque, const void* filename, int mode) { + const char* mode_fopen = NULL; + if ((mode & ZLIB_FILEFUNC_MODE_READWRITEFILTER) == ZLIB_FILEFUNC_MODE_READ) { + mode_fopen = "rb"; + } else if (mode & ZLIB_FILEFUNC_MODE_EXISTING) { + mode_fopen = "r+b"; + } else if (mode & ZLIB_FILEFUNC_MODE_CREATE) { + mode_fopen = "wb"; + } else { + return NULL; + } + + auto f = std::fopen((const char*)filename, mode_fopen); + if (f) { + std::setvbuf(f, nullptr, _IOFBF, 1024 * 512); + } + return f; +} + +ZPOS64_T minizip_tell_file_func_stdio(voidpf opaque, voidpf stream) { + auto file = static_cast(stream); + return std::ftell(file); +} + +long minizip_seek_file_func_stdio(voidpf opaque, voidpf stream, ZPOS64_T offset, int origin) { + auto file = static_cast(stream); + return std::fseek(file, offset, origin); +} + +uLong minizip_read_file_func_stdio(voidpf opaque, voidpf stream, void* buf, uLong size) { + auto file = static_cast(stream); + return std::fread(buf, 1, size, file); +} + +uLong minizip_write_file_func_stdio(voidpf opaque, voidpf stream, const void* buf, uLong size) { + auto file = static_cast(stream); + return std::fwrite(buf, 1, size, file); +} + +int minizip_close_file_func_stdio(voidpf opaque, voidpf stream) { + auto file = static_cast(stream); + if (file) { + return std::fclose(file); + } + return 0; +} + +int minizip_error_file_func_stdio(voidpf opaque, voidpf stream) { + auto file = static_cast(stream); + if (file) { + return std::ferror(file); + } + return 0; +} + +constexpr zlib_filefunc64_def zlib_filefunc_stdio = { + .zopen64_file = minizip_open_file_func_stdio, + .zread_file = minizip_read_file_func_stdio, + .zwrite_file = minizip_write_file_func_stdio, + .ztell64_file = minizip_tell_file_func_stdio, + .zseek64_file = minizip_seek_file_func_stdio, + .zclose_file = minizip_close_file_func_stdio, + .zerror_file = minizip_error_file_func_stdio, +}; + +} // namespace + +void FileFuncMem(MzMem* mem, zlib_filefunc64_def* funcs) { + *funcs = zlib_filefunc_mem; + funcs->opaque = mem; +} + +void FileFuncSpan(MzSpan* span, zlib_filefunc64_def* funcs) { + *funcs = zlib_filefunc_span; + funcs->opaque = span; +} + +void FileFuncStdio(zlib_filefunc64_def* funcs) { + *funcs = zlib_filefunc_stdio; +} + +} // namespace sphaira::mz diff --git a/sphaira/source/threaded_file_transfer.cpp b/sphaira/source/threaded_file_transfer.cpp index 5daf596..931f8b5 100644 --- a/sphaira/source/threaded_file_transfer.cpp +++ b/sphaira/source/threaded_file_transfer.cpp @@ -2,6 +2,7 @@ #include "log.hpp" #include "defines.hpp" #include "app.hpp" +#include "minizip_helper.hpp" #include #include @@ -456,7 +457,7 @@ Result TransferUnzip(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs::F [&](void* data, s64 off, s64 size, u64* bytes_read) -> Result { const auto result = unzReadCurrentFile(zfile, data, size); if (result <= 0) { - // log_write("failed to read zip file: %s\n", inzip.c_str()); + log_write("failed to read zip file: %s %d\n", path.s, result); R_THROW(0x1); } @@ -474,21 +475,29 @@ Result TransferUnzip(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs::F )); // validate crc32 (if set in the info). - R_UNLESS(!crc32 || crc32 == crc32_out, 0x1); + R_UNLESS(!crc32 || crc32 == crc32_out, 0x8); R_SUCCEED(); } -Result TransferZip(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs::FsPath& path) { +Result TransferZip(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs::FsPath& path, u32* crc32) { fs::File f; R_TRY(fs->OpenFile(path, FsOpenMode_Read, &f)); s64 file_size; R_TRY(f.GetSize(&file_size)); + if (crc32) { + *crc32 = 0; + } + return thread::TransferInternal(pbox, file_size, [&](void* data, s64 off, s64 size, u64* bytes_read) -> Result { - return f.Read(off, data, size, FsReadOption_None, bytes_read); + const auto rc = f.Read(off, data, size, FsReadOption_None, bytes_read); + if (R_SUCCEEDED(rc) && crc32) { + *crc32 = crc32CalculateWithSeed(*crc32, data, *bytes_read); + } + return rc; }, [&](const void* data, s64 off, s64 size) -> Result { if (ZIP_OK != zipWriteInFileInZip(zfile, data, size)) { @@ -559,7 +568,10 @@ Result TransferUnzipAll(ui::ProgressBox* pbox, void* zfile, fs::Fs* fs, const fs } Result TransferUnzipAll(ui::ProgressBox* pbox, const fs::FsPath& zip_out, fs::Fs* fs, const fs::FsPath& base_path, UnzipAllFilter filter) { - auto zfile = unzOpen64(zip_out); + zlib_filefunc64_def file_func; + mz::FileFuncStdio(&file_func); + + auto zfile = unzOpen2_64(zip_out, &file_func); R_UNLESS(zfile, 0x1); ON_SCOPE_EXIT(unzClose(zfile)); diff --git a/sphaira/source/ui/menus/appstore.cpp b/sphaira/source/ui/menus/appstore.cpp index d018b86..c23dace 100644 --- a/sphaira/source/ui/menus/appstore.cpp +++ b/sphaira/source/ui/menus/appstore.cpp @@ -17,6 +17,7 @@ #include "threaded_file_transfer.hpp" #include "nro.hpp" #include "web.hpp" +#include "minizip_helper.hpp" #include #include @@ -77,62 +78,6 @@ constexpr const char* ORDER_STR[] = { "Asc", }; -struct MzMem { - const void* buf; - size_t size; - size_t offset; -}; - -ZPOS64_T minizip_tell_file_func(voidpf opaque, voidpf stream) { - auto mem = static_cast(opaque); - return mem->offset; -} - -long minizip_seek_file_func(voidpf opaque, voidpf stream, ZPOS64_T offset, int origin) { - auto mem = static_cast(opaque); - size_t new_offset = 0; - - switch (origin) { - case ZLIB_FILEFUNC_SEEK_SET: new_offset = offset; break; - case ZLIB_FILEFUNC_SEEK_CUR: new_offset = mem->offset + offset; break; - case ZLIB_FILEFUNC_SEEK_END: new_offset = (mem->size - 1) + offset; break; - default: return -1; - } - - if (new_offset > mem->size) { - return -1; - } - - mem->offset = new_offset; - return 0; -} - -voidpf minizip_open_file_func(voidpf opaque, const void* filename, int mode) { - return opaque; -} - -uLong minizip_read_file_func(voidpf opaque, voidpf stream, void* buf, uLong size) { - auto mem = static_cast(opaque); - - size = std::min(size, mem->size - mem->offset); - std::memcpy(buf, (const u8*)mem->buf + mem->offset, size); - mem->offset += size; - - return size; -} - -int minizip_close_file_func(voidpf opaque, voidpf stream) { - return 0; -} - -constexpr zlib_filefunc64_def zlib_filefunc = { - .zopen64_file = minizip_open_file_func, - .zread_file = minizip_read_file_func, - .ztell64_file = minizip_tell_file_func, - .zseek64_file = minizip_seek_file_func, - .zclose_file = minizip_close_file_func, -}; - 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()); @@ -474,20 +419,17 @@ auto InstallApp(ProgressBox* pbox, const Entry& entry) -> Result { } } - struct MzMem mem{}; - mem.buf = api_result.data.data(); - mem.size = api_result.data.size(); - auto file_func = zlib_filefunc; - file_func.opaque = &mem; - - zlib_filefunc64_def* file_func_ptr{}; + mz::MzSpan mz_span{api_result.data}; + zlib_filefunc64_def file_func; if (!file_download) { - file_func_ptr = &file_func; + mz::FileFuncSpan(&mz_span, &file_func); + } else { + mz::FileFuncStdio(&file_func); } // 3. extract the zip if (!pbox->ShouldExit()) { - auto zfile = unzOpen2_64(zip_out, file_func_ptr); + auto zfile = unzOpen2_64(zip_out, &file_func); R_UNLESS(zfile, 0x1); ON_SCOPE_EXIT(unzClose(zfile)); diff --git a/sphaira/source/ui/menus/filebrowser.cpp b/sphaira/source/ui/menus/filebrowser.cpp index 3253008..9fe5e36 100644 --- a/sphaira/source/ui/menus/filebrowser.cpp +++ b/sphaira/source/ui/menus/filebrowser.cpp @@ -21,6 +21,7 @@ #include "hasher.hpp" #include "location.hpp" #include "threaded_file_transfer.hpp" +#include "minizip_helper.hpp" #include "yati/yati.hpp" #include "yati/source/file.hpp" @@ -435,7 +436,6 @@ FsView::~FsView() { } void FsView::Update(Controller* controller, TouchInfo* touch) { - Widget::Update(controller, touch); m_list->OnUpdate(controller, touch, m_index, m_entries_current.size(), [this](bool touch, auto i) { if (touch && m_index == i) { FireAction(Button::A); @@ -790,7 +790,10 @@ void FsView::ZipFiles(fs::FsPath zip_out) { zip_info.tmz_date.tm_mon = tm->tm_mon; zip_info.tmz_date.tm_year = tm->tm_year; - auto zfile = zipOpen(zip_out, APPEND_STATUS_CREATE); + zlib_filefunc64_def file_func; + mz::FileFuncStdio(&file_func); + + auto zfile = zipOpen2_64(zip_out, APPEND_STATUS_CREATE, nullptr, &file_func); R_UNLESS(zfile, 0x1); ON_SCOPE_EXIT(zipClose(zfile, "sphaira v" APP_VERSION_HASH)); @@ -1854,6 +1857,13 @@ Menu::~Menu() { } void Menu::Update(Controller* controller, TouchInfo* touch) { + // workaround the buttons not being display properly. + // basically, inherit all actions from the view, draw them, + // then restore state after. + const auto actions_copy = GetActions(); + ON_SCOPE_EXIT(m_actions = actions_copy); + m_actions.insert_range(view->GetActions()); + MenuBase::Update(controller, touch); view->Update(controller, touch); } diff --git a/sphaira/source/ui/menus/game_menu.cpp b/sphaira/source/ui/menus/game_menu.cpp index 3399b7e..ee559dc 100644 --- a/sphaira/source/ui/menus/game_menu.cpp +++ b/sphaira/source/ui/menus/game_menu.cpp @@ -998,6 +998,11 @@ void Menu::Update(Controller* controller, TouchInfo* touch) { void Menu::Draw(NVGcontext* vg, Theme* theme) { MenuBase::Draw(vg, theme); + if (m_entries.empty()) { + gfx::drawTextArgs(vg, GetX() + GetW() / 2.f, GetY() + GetH() / 2.f, 36.f, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, theme->GetColour(ThemeEntryID_TEXT_INFO), "Empty..."_i18n.c_str()); + return; + } + // max images per frame, in order to not hit io / gpu too hard. const int image_load_max = 2; int image_load_count = 0; @@ -1096,7 +1101,7 @@ void Menu::ScanHomebrew() { u64 unk_x11;// = e.unk_x11; memcpy(&unk_x0a, e.unk_x0a, sizeof(e.unk_x0a)); memcpy(&unk_x11, e.unk_x11, sizeof(e.unk_x11)); - log_write("ID: %016lx got type: %u unk_x09: %u unk_x0a: %zu unk_x10: %u unk_x11: %zu\n", e.application_id, e.type, + log_write("ID: %016lx got type: %u unk_x09: %u unk_x0a: %zu unk_x10: %u unk_x11: %zu\n", e.app_id, e.type, unk_x09, unk_x0a, unk_x10, diff --git a/sphaira/source/ui/menus/gc_menu.cpp b/sphaira/source/ui/menus/gc_menu.cpp index 06fa0d1..98a8a65 100644 --- a/sphaira/source/ui/menus/gc_menu.cpp +++ b/sphaira/source/ui/menus/gc_menu.cpp @@ -529,7 +529,7 @@ Result Menu::GcMount() { // the fs, same as mounting storage. for (u32 i = 0; i < REMOUNT_ATTEMPT_MAX; i++) { R_TRY(fsDeviceOperatorGetGameCardHandle(std::addressof(m_dev_op), std::addressof(m_handle))); - m_fs = std::make_unique(std::addressof(m_handle), FsGameCardPartition_Secure, false); + m_fs = std::make_unique(std::addressof(m_handle), FsGameCardPartition_Secure); if (R_SUCCEEDED(m_fs->GetFsOpenResult())) { break; } diff --git a/sphaira/source/ui/menus/main_menu.cpp b/sphaira/source/ui/menus/main_menu.cpp index 0d8a2db..fe421d8 100644 --- a/sphaira/source/ui/menus/main_menu.cpp +++ b/sphaira/source/ui/menus/main_menu.cpp @@ -15,6 +15,7 @@ #include "ui/menus/ftp_menu.hpp" #include "ui/menus/gc_menu.hpp" #include "ui/menus/game_menu.hpp" +#include "ui/menus/save_menu.hpp" #include "ui/menus/appstore.hpp" #include "app.hpp" @@ -49,6 +50,7 @@ const MiscMenuEntry MISC_MENU_ENTRIES[] = { { .name = "Appstore", .title = "Appstore", .func = MiscMenuFuncGenerator, .flag = MiscMenuFlag_Shortcut }, { .name = "Games", .title = "Games", .func = MiscMenuFuncGenerator, .flag = MiscMenuFlag_Shortcut }, { .name = "FileBrowser", .title = "FileBrowser", .func = MiscMenuFuncGenerator, .flag = MiscMenuFlag_Shortcut }, + { .name = "Saves", .title = "Saves", .func = MiscMenuFuncGenerator, .flag = MiscMenuFlag_Shortcut }, { .name = "Themezer", .title = "Themezer", .func = MiscMenuFuncGenerator, .flag = MiscMenuFlag_Shortcut }, { .name = "GitHub", .title = "GitHub", .func = MiscMenuFuncGenerator, .flag = MiscMenuFlag_Shortcut }, { .name = "FTP", .title = "FTP Install", .func = MiscMenuFuncGenerator, .flag = MiscMenuFlag_Install }, diff --git a/sphaira/source/ui/menus/save_menu.cpp b/sphaira/source/ui/menus/save_menu.cpp new file mode 100644 index 0000000..fb97c92 --- /dev/null +++ b/sphaira/source/ui/menus/save_menu.cpp @@ -0,0 +1,1274 @@ +#include "app.hpp" +#include "log.hpp" +#include "fs.hpp" +#include "download.hpp" +#include "defines.hpp" +#include "i18n.hpp" +#include "location.hpp" +#include "image.hpp" +#include "threaded_file_transfer.hpp" +#include "minizip_helper.hpp" + +#include "ui/menus/save_menu.hpp" +#include "ui/menus/filebrowser.hpp" + +#include "ui/sidebar.hpp" +#include "ui/error_box.hpp" +#include "ui/option_box.hpp" +#include "ui/progress_box.hpp" +#include "ui/popup_list.hpp" +#include "ui/nvg_util.hpp" + +#include "yati/nx/ncm.hpp" +#include "yati/nx/nca.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace sphaira::ui::menu::save { +namespace { + +constexpr int THREAD_PRIO = PRIO_PREEMPTIVE; +constexpr int THREAD_CORE = 1; + +constexpr u32 NX_SAVE_META_MAGIC = 0x4A4B5356; // JKSV +constexpr u32 NX_SAVE_META_VERSION = 1; +constexpr const char* NX_SAVE_META_NAME = ".nx_save_meta.bin"; + +// https://github.com/J-D-K/JKSV/issues/264#issuecomment-2618962807 +struct NXSaveMeta { + u32 magic{}; // NX_SAVE_META_MAGIC + u32 version{}; // NX_SAVE_META_VERSION + FsSaveDataAttribute attr{}; // FsSaveDataExtraData::attr + u64 owner_id{}; // FsSaveDataExtraData::owner_id + u64 timestamp{}; // FsSaveDataExtraData::timestamp + u32 flags{}; // FsSaveDataExtraData::flags + u32 unk_x54{}; // FsSaveDataExtraData::unk_x54 + s64 data_size{}; // FsSaveDataExtraData::data_size + s64 journal_size{}; // FsSaveDataExtraData::journal_size + u64 commit_id{}; // FsSaveDataExtraData::commit_id + u64 raw_size{}; // FsSaveDataInfo::size +}; +static_assert(sizeof(NXSaveMeta) == 128); + +// taken from nxtc +constexpr u8 g_nacpLangTable[SetLanguage_Total] = { + [SetLanguage_JA] = 2, + [SetLanguage_ENUS] = 0, + [SetLanguage_FR] = 3, + [SetLanguage_DE] = 4, + [SetLanguage_IT] = 7, + [SetLanguage_ES] = 6, + [SetLanguage_ZHCN] = 14, + [SetLanguage_KO] = 12, + [SetLanguage_NL] = 8, + [SetLanguage_PT] = 10, + [SetLanguage_RU] = 11, + [SetLanguage_ZHTW] = 13, + [SetLanguage_ENGB] = 1, + [SetLanguage_FRCA] = 9, + [SetLanguage_ES419] = 5, + [SetLanguage_ZHHANS] = 14, + [SetLanguage_ZHHANT] = 13, + [SetLanguage_PTBR] = 15 +}; + +auto GetNacpLangEntryIndex() -> u8 { + SetLanguage lang{SetLanguage_ENUS}; + nxtcGetCacheLanguage(&lang); + return g_nacpLangTable[lang]; +} + +constexpr u32 ContentMetaTypeToContentFlag(u8 meta_type) { + if (meta_type & 0x80) { + return 1 << (meta_type - 0x80); + } + + return 0; +} + +enum ContentFlag { + ContentFlag_Application = ContentMetaTypeToContentFlag(NcmContentMetaType_Application), + ContentFlag_Patch = ContentMetaTypeToContentFlag(NcmContentMetaType_Patch), + ContentFlag_AddOnContent = ContentMetaTypeToContentFlag(NcmContentMetaType_AddOnContent), + ContentFlag_DataPatch = ContentMetaTypeToContentFlag(NcmContentMetaType_DataPatch), + ContentFlag_All = ContentFlag_Application | ContentFlag_Patch | ContentFlag_AddOnContent | ContentFlag_DataPatch, +}; + +struct NcmEntry { + const NcmStorageId storage_id; + NcmContentStorage cs{}; + NcmContentMetaDatabase db{}; + + void Open() { + if (R_FAILED(ncmOpenContentMetaDatabase(std::addressof(db), storage_id))) { + log_write("\tncmOpenContentMetaDatabase() failed. storage_id: %u\n", storage_id); + } else { + log_write("\tncmOpenContentMetaDatabase() success. storage_id: %u\n", storage_id); + } + + if (R_FAILED(ncmOpenContentStorage(std::addressof(cs), storage_id))) { + log_write("\tncmOpenContentStorage() failed. storage_id: %u\n", storage_id); + } else { + log_write("\tncmOpenContentStorage() success. storage_id: %u\n", storage_id); + } + } + + void Close() { + ncmContentMetaDatabaseClose(std::addressof(db)); + ncmContentStorageClose(std::addressof(cs)); + + db = {}; + cs = {}; + } +}; + +constinit NcmEntry ncm_entries[] = { + // on memory, will become invalid on the gamecard being inserted / removed. + { NcmStorageId_GameCard }, + // normal (save), will remain valid. + { NcmStorageId_BuiltInUser }, + { NcmStorageId_SdCard }, +}; + +auto& GetNcmEntry(u8 storage_id) { + auto it = std::ranges::find_if(ncm_entries, [storage_id](auto& e){ + return storage_id == e.storage_id; + }); + + if (it == std::end(ncm_entries)) { + log_write("unable to find valid ncm entry: %u\n", storage_id); + return ncm_entries[0]; + } + + return *it; +} + +auto& GetNcmCs(u8 storage_id) { + return GetNcmEntry(storage_id).cs; +} + +auto& GetNcmDb(u8 storage_id) { + return GetNcmEntry(storage_id).db; +} + +using MetaEntries = std::vector; + +Result GetMetaEntries(u64 id, MetaEntries& out, u32 flags = ContentFlag_All) { + for (s32 i = 0; ; i++) { + s32 count; + NsApplicationContentMetaStatus status; + R_TRY(nsListApplicationContentMetaStatus(id, i, &status, 1, &count)); + + if (!count) { + break; + } + + if (flags & ContentMetaTypeToContentFlag(status.meta_type)) { + out.emplace_back(status); + } + } + + R_SUCCEED(); +} + +// also sets the status to error. +void FakeNacpEntry(ThreadResultData& e) { + e.status = NacpLoadStatus::Error; + // fake the nacp entry + std::strcpy(e.lang.name, "Corrupted"); + std::strcpy(e.lang.author, "Corrupted"); + e.control.reset(); +} + +bool LoadControlImage(Entry& e) { + if (!e.image && e.control) { + ON_SCOPE_EXIT(e.control.reset()); + + TimeStamp ts; + const auto image = ImageLoadFromMemory({e.control->icon, e.jpeg_size}, ImageFlag_JPEG); + if (!image.data.empty()) { + e.image = nvgCreateImageRGBA(App::GetVg(), image.w, image.h, 0, image.data.data()); + log_write("\t[image load] time taken: %.2fs %zums\n", ts.GetSecondsD(), ts.GetMs()); + return true; + } + } + + return false; +} + +Result GetControlPathFromStatus(const NsApplicationContentMetaStatus& status, u64* out_program_id, fs::FsPath* out_path) { + const auto& ee = status; + if (ee.storageID != NcmStorageId_SdCard && ee.storageID != NcmStorageId_BuiltInUser && ee.storageID != NcmStorageId_GameCard) { + return 0x1; + } + + auto& db = GetNcmDb(ee.storageID); + auto& cs = GetNcmCs(ee.storageID); + + NcmContentMetaKey key; + R_TRY(ncmContentMetaDatabaseGetLatestContentMetaKey(&db, &key, ee.application_id)); + + NcmContentId content_id; + R_TRY(ncmContentMetaDatabaseGetContentIdByType(&db, &content_id, &key, NcmContentType_Control)); + + R_TRY(ncmContentStorageGetProgramId(&cs, out_program_id, &content_id, FsContentAttributes_All)); + + R_TRY(ncmContentStorageGetPath(&cs, out_path->s, sizeof(*out_path), &content_id)); + R_SUCCEED(); +} + +Result LoadControlManual(u64 id, ThreadResultData& data) { + TimeStamp ts; + + MetaEntries entries; + R_TRY(GetMetaEntries(id, entries)); + R_UNLESS(!entries.empty(), 0x1); + + u64 program_id; + fs::FsPath path; + R_TRY(GetControlPathFromStatus(entries.back(), &program_id, &path)); + + std::vector icon; + R_TRY(nca::ParseControl(path, program_id, &data.control->nacp.lang[GetNacpLangEntryIndex()], sizeof(NacpLanguageEntry), &icon)); + std::memcpy(data.control->icon, icon.data(), icon.size()); + + data.jpeg_size = icon.size(); + log_write("\t\t[manual control] time taken: %.2fs %zums\n", ts.GetSecondsD(), ts.GetMs()); + + R_SUCCEED(); +} + +auto LoadControlEntry(u64 id) -> ThreadResultData { + ThreadResultData data{}; + data.id = id; + data.control = std::make_shared(); + data.status = NacpLoadStatus::Error; + + bool manual_load = true; + if (hosversionBefore(20,0,0)) { + TimeStamp ts; + u64 actual_size; + if (R_SUCCEEDED(nsGetApplicationControlData(NsApplicationControlSource_CacheOnly, id, data.control.get(), sizeof(NsApplicationControlData), &actual_size))) { + manual_load = false; + data.jpeg_size = actual_size - sizeof(NacpStruct); + log_write("\t\t[ns control cache] time taken: %.2fs %zums\n", ts.GetSecondsD(), ts.GetMs()); + } + } + + if (manual_load) { + manual_load = R_SUCCEEDED(LoadControlManual(id, data)); + } + + Result rc{}; + if (!manual_load) { + TimeStamp ts; + u64 actual_size; + if (R_SUCCEEDED(rc = nsGetApplicationControlData(NsApplicationControlSource_Storage, id, data.control.get(), sizeof(NsApplicationControlData), &actual_size))) { + data.jpeg_size = actual_size - sizeof(NacpStruct); + log_write("\t\t[ns control storage] time taken: %.2fs %zums\n", ts.GetSecondsD(), ts.GetMs()); + } + } + + if (R_SUCCEEDED(rc)) { + data.lang = data.control->nacp.lang[GetNacpLangEntryIndex()]; + data.status = NacpLoadStatus::Loaded; + } + + if (R_FAILED(rc)) { + FakeNacpEntry(data); + } + + return data; +} + +void LoadResultIntoEntry(Entry& e, const ThreadResultData& result) { + e.status = result.status; + e.control = result.control; + e.jpeg_size= result.jpeg_size; + e.lang = result.lang; + e.status = result.status; +} + +void LoadControlEntry(Entry& e, bool force_image_load = false) { + if (e.status == NacpLoadStatus::None) { + const auto result = LoadControlEntry(e.application_id); + LoadResultIntoEntry(e, result); + } + + if (force_image_load && e.status == NacpLoadStatus::Loaded) { + LoadControlImage(e); + } +} + +// taken from nxdumptool. +void utilsReplaceIllegalCharacters(char *str, bool ascii_only) +{ + static const char g_illegalFileSystemChars[] = "\\/:*?\"<>|"; + + size_t str_size = 0, cur_pos = 0; + + if (!str || !(str_size = strlen(str))) return; + + u8 *ptr1 = (u8*)str, *ptr2 = ptr1; + ssize_t units = 0; + u32 code = 0; + bool repl = false; + + while(cur_pos < str_size) + { + units = decode_utf8(&code, ptr1); + if (units < 0) break; + + if (code < 0x20 || (!ascii_only && code == 0x7F) || (ascii_only && code >= 0x7F) || \ + (units == 1 && memchr(g_illegalFileSystemChars, (int)code, std::size(g_illegalFileSystemChars)))) + { + if (!repl) + { + *ptr2++ = '_'; + repl = true; + } + } else { + if (ptr2 != ptr1) memmove(ptr2, ptr1, (size_t)units); + ptr2 += units; + repl = false; + } + + ptr1 += units; + cur_pos += (size_t)units; + } + + *ptr2 = '\0'; +} + +struct HashStr { + char str[0x21]; +}; + +HashStr hexIdToStr(auto id) { + HashStr str{}; + const auto id_lower = std::byteswap(*(u64*)id.c); + const auto id_upper = std::byteswap(*(u64*)(id.c + 0x8)); + std::snprintf(str.str, 0x21, "%016lx%016lx", id_lower, id_upper); + return str; +} + +auto BuildSaveName(const Entry& e) -> fs::FsPath { + fs::FsPath name_buf = e.GetName(); + utilsReplaceIllegalCharacters(name_buf, true); + return name_buf; +} + +auto BuildSaveBasePath(const Entry& e) -> fs::FsPath { + const auto name = BuildSaveName(e); + return fs::AppendPath("/dumps/SAVE/", name); +} + +auto BuildSavePath(const AccountEntry& acc, const Entry& e, bool is_auto) -> fs::FsPath { + const auto t = std::time(NULL); + const auto tm = std::localtime(&t); + const auto base = BuildSaveBasePath(e); + + fs::FsPath name_buf; + if (is_auto) { + std::snprintf(name_buf, sizeof(name_buf), "AUTO - %s", acc.base.nickname); + } else { + std::snprintf(name_buf, sizeof(name_buf), "%s", acc.base.nickname); + } + utilsReplaceIllegalCharacters(name_buf, true); + + fs::FsPath path; + std::snprintf(path, sizeof(path), "%s/%s - %u.%02u.%02u @ %02u.%02u.%02u.zip", base.s, name_buf.s, tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min, tm->tm_sec); + return path; +} + +Result RestoreSaveInternal(ProgressBox* pbox, const Entry& e, const fs::FsPath& path) { + pbox->SetTitle(e.GetName()); + if (e.image) { + pbox->SetImage(e.image); + } else if (e.control && e.jpeg_size) { + pbox->SetImageDataConst({e.control->icon, e.jpeg_size}); + } else { + pbox->SetImage(0); + } + + const auto save_data_space_id = (FsSaveDataSpaceId)e.save_data_space_id; + + // try and get the journal and data size. + FsSaveDataExtraData extra{}; + R_TRY(fsReadSaveDataFileSystemExtraDataBySaveDataSpaceId(&extra, sizeof(extra), save_data_space_id, e.save_data_id)); + + log_write("restoring save: %s\n", path.s); + zlib_filefunc64_def file_func; + mz::FileFuncStdio(&file_func); + + auto zfile = unzOpen2_64(path, &file_func); + R_UNLESS(zfile, 0x1); + ON_SCOPE_EXIT(unzClose(zfile)); + log_write("opened zip\n"); + + bool has_meta{}; + NXSaveMeta meta{}; + + // get manifest + if (UNZ_END_OF_LIST_OF_FILE != unzLocateFile(zfile, NX_SAVE_META_NAME, 0)) { + log_write("found meta file\n"); + if (UNZ_OK == unzOpenCurrentFile(zfile)) { + log_write("opened meta file\n"); + ON_SCOPE_EXIT(unzCloseCurrentFile(zfile)); + + const auto len = unzReadCurrentFile(zfile, &meta, sizeof(meta)); + if (len == sizeof(meta) && meta.magic == NX_SAVE_META_MAGIC && meta.version == NX_SAVE_META_VERSION) { + has_meta = true; + log_write("loaded meta!\n"); + } + } + } + + if (has_meta) { + log_write("extending save file\n"); + R_TRY(fsExtendSaveDataFileSystem(save_data_space_id, e.save_data_id, meta.data_size, meta.journal_size)); + log_write("extended save file\n"); + } else { + log_write("doing manual meta parse\n"); + s64 total_size{}; + + // todo:: manually calculate / guess the save size. + unz_global_info64 ginfo; + R_UNLESS(UNZ_OK == unzGetGlobalInfo64(zfile, &ginfo), 0x1); + R_UNLESS(UNZ_OK == unzGoToFirstFile(zfile), 0x1); + + for (s64 i = 0; i < ginfo.number_entry; i++) { + R_TRY(pbox->ShouldExitResult()); + + if (i > 0) { + R_UNLESS(UNZ_OK == unzGoToNextFile(zfile), 0x1); + } + + R_UNLESS(UNZ_OK == unzOpenCurrentFile(zfile), 0x1); + ON_SCOPE_EXIT(unzCloseCurrentFile(zfile)); + + unz_file_info64 info; + fs::FsPath name; + R_UNLESS(UNZ_OK == unzGetCurrentFileInfo64(zfile, &info, name, sizeof(name), 0, 0, 0, 0), 0x1); + + if (name == NX_SAVE_META_NAME) { + continue; + } + total_size += info.uncompressed_size; + } + + // TODO: untested, should work tho. + const auto rounded_size = total_size + (total_size % extra.journal_size); + log_write("extendeing manual meta parse\n"); + R_TRY(fsExtendSaveDataFileSystem(save_data_space_id, e.save_data_id, rounded_size, extra.journal_size)); + log_write("extended manual meta parse\n"); + } + + FsSaveDataAttribute attr{}; + attr.application_id = e.application_id; + attr.uid = e.uid; + attr.system_save_data_id = e.system_save_data_id; + attr.save_data_type = e.save_data_type; + attr.save_data_rank = e.save_data_rank; + attr.save_data_index = e.save_data_index; + + // try and open the save file system. + fs::FsNativeSave save_fs{save_data_space_id, &attr, false}; + R_TRY(save_fs.GetFsOpenResult()); + + log_write("opened save file\n"); + // restore save data from zip. + R_TRY(thread::TransferUnzipAll(pbox, zfile, &save_fs, "/", [&](const fs::FsPath& name, fs::FsPath& path) -> bool { + // skip restoring the meta file. + if (name == NX_SAVE_META_NAME) { + log_write("skipping meta\n"); + return false; + } + + // restore everything else. + log_write("restoring: %s\n", path.s); + + // commit after every save otherwise FsError_MappingTableFull is thrown. + R_TRY(save_fs.Commit()); + return true; + })); + + log_write("finished, doing commit\n"); + R_TRY(save_fs.Commit()); + R_SUCCEED(); +} + +Result BackupSaveInternal(ProgressBox* pbox, const AccountEntry& acc, const Entry& e, bool compressed, bool is_auto = false) { + pbox->SetTitle(e.GetName()); + if (e.image) { + pbox->SetImage(e.image); + } else if (e.control && e.jpeg_size) { + pbox->SetImageDataConst({e.control->icon, e.jpeg_size}); + } else { + pbox->SetImage(0); + } + + const auto save_data_space_id = (FsSaveDataSpaceId)e.save_data_space_id; + + // try and get the journal and data size. + FsSaveDataExtraData extra{}; + R_TRY(fsReadSaveDataFileSystemExtraDataBySaveDataSpaceId(&extra, sizeof(extra), save_data_space_id, e.save_data_id)); + + FsSaveDataAttribute attr{}; + attr.application_id = e.application_id; + attr.uid = e.uid; + attr.system_save_data_id = e.system_save_data_id; + attr.save_data_type = e.save_data_type; + attr.save_data_rank = e.save_data_rank; + attr.save_data_index = e.save_data_index; + + // try and open the save file system + fs::FsNativeSave save_fs{save_data_space_id, &attr, true}; + R_TRY(save_fs.GetFsOpenResult()); + + // get a list of collections. + filebrowser::FsDirCollections collections; + R_TRY(filebrowser::FsView::get_collections(&save_fs, "/", "", collections)); + + // the save file may be empty, this isn't an error, but we exit early. + R_UNLESS(!collections.empty(), 0x0); + + // we will actually store this to the dump locations, eventually. + fs::FsNativeSd fs; + R_TRY(fs.GetFsOpenResult()); + + const auto t = std::time(NULL); + const auto tm = std::localtime(&t); + + // pre-calculate the time rather than calculate it in the loop. + zip_fileinfo zip_info_default{}; + zip_info_default.tmz_date.tm_sec = tm->tm_sec; + zip_info_default.tmz_date.tm_min = tm->tm_min; + zip_info_default.tmz_date.tm_hour = tm->tm_hour; + zip_info_default.tmz_date.tm_mday = tm->tm_mday; + zip_info_default.tmz_date.tm_mon = tm->tm_mon; + zip_info_default.tmz_date.tm_year = tm->tm_year; + + const auto path = BuildSavePath(acc, e, is_auto); + const auto temp_path = path + ".temp"; + + fs.CreateDirectoryRecursivelyWithPath(temp_path); + ON_SCOPE_EXIT(fs.DeleteFile(temp_path)); + + // zip to memory if less than 1GB and not applet mode. + // TODO: use my mmz code from ftpsrv to stream zip creation. + // this will allow for zipping to memory and flushing every X bytes + // such as flushing every 8MB. + const auto file_download = App::IsApplet() || e.size >= 1024ULL * 1024ULL * 1024ULL; + + mz::MzMem mz_mem{}; + zlib_filefunc64_def file_func; + if (!file_download) { + mz::FileFuncMem(&mz_mem, &file_func); + } else { + mz::FileFuncStdio(&file_func); + } + + { + auto zfile = zipOpen2_64(temp_path, APPEND_STATUS_CREATE, nullptr, &file_func); + R_UNLESS(zfile, 0x1); + ON_SCOPE_EXIT(zipClose(zfile, "sphaira v" APP_VERSION_HASH)); + + // add save meta. + { + const NXSaveMeta meta{ + .magic = NX_SAVE_META_MAGIC, + .version = NX_SAVE_META_VERSION, + .attr = extra.attr, + .owner_id = extra.owner_id, + .timestamp = extra.timestamp, + .flags = extra.flags, + .unk_x54 = extra.unk_x54, + .data_size = extra.data_size, + .journal_size = extra.journal_size, + .commit_id = extra.commit_id, + .raw_size = e.size, + }; + + R_UNLESS(ZIP_OK == zipOpenNewFileInZip(zfile, NX_SAVE_META_NAME, &zip_info_default, NULL, 0, NULL, 0, NULL, Z_DEFLATED, Z_NO_COMPRESSION), 0x1); + ON_SCOPE_EXIT(zipCloseFileInZip(zfile)); + R_UNLESS(ZIP_OK == zipWriteInFileInZip(zfile, &meta, sizeof(meta)), 0x1); + } + + const auto zip_add = [&](const FsDirectoryEntry& dir_entry, const fs::FsPath& file_path) -> Result { + auto zip_info = zip_info_default; + + // try and load the actual timestamp of the file. + // TODO: not supported for saves... + #if 0 + FsTimeStampRaw timestamp{}; + if (R_SUCCEEDED(fs.GetFileTimeStampRaw(file_path, ×tamp)) && timestamp.is_valid) { + const auto time = (time_t)timestamp.modified; + if (auto tm = localtime(&time)) { + zip_info.tmz_date.tm_sec = tm->tm_sec; + zip_info.tmz_date.tm_min = tm->tm_min; + zip_info.tmz_date.tm_hour = tm->tm_hour; + zip_info.tmz_date.tm_mday = tm->tm_mday; + zip_info.tmz_date.tm_mon = tm->tm_mon; + zip_info.tmz_date.tm_year = tm->tm_year; + log_write("got timestamp!\n"); + } + } + #endif + + // the file name needs to be relative to the current directory. + const char* file_name_in_zip = file_path.s + std::strlen("/"); + + // strip root path (/ or ums0:) + if (!std::strncmp(file_name_in_zip, save_fs.Root(), std::strlen(save_fs.Root()))) { + file_name_in_zip += std::strlen(save_fs.Root()); + } + + // root paths are banned in zips, they will warn when extracting otherwise. + if (file_name_in_zip[0] == '/') { + file_name_in_zip++; + } + + pbox->NewTransfer(file_name_in_zip); + + const auto level = compressed ? Z_DEFAULT_COMPRESSION : Z_NO_COMPRESSION; + if (ZIP_OK != zipOpenNewFileInZip(zfile, file_name_in_zip, &zip_info, NULL, 0, NULL, 0, NULL, Z_DEFLATED, level)) { + log_write("failed to add zip for %s\n", file_path.s); + R_THROW(0x1); + } + ON_SCOPE_EXIT(zipCloseFileInZip(zfile)); + + return thread::TransferZip(pbox, zfile, &save_fs, file_path); + }; + + // loop through every save file and store to zip. + for (const auto& collection : collections) { + for (const auto& file : collection.files) { + const auto file_path = fs::AppendPath(collection.path, file.name); + R_TRY(zip_add(file, file_path)); + } + } + } + + // if we dumped the save to ram, flush the data to file. + const auto is_file_based_emummc = App::IsFileBaseEmummc(); + if (!file_download) { + pbox->NewTransfer("Flushing zip to file"); + R_TRY(fs.CreateFile(temp_path, mz_mem.buf.size(), 0)); + + fs::File file; + R_TRY(fs.OpenFile(temp_path, FsOpenMode_Write, &file)); + + R_TRY(thread::Transfer(pbox, mz_mem.buf.size(), + [&](void* data, s64 off, s64 size, u64* bytes_read) -> Result { + size = std::min(size, mz_mem.buf.size() - off); + std::memcpy(data, mz_mem.buf.data() + off, size); + *bytes_read = size; + R_SUCCEED(); + }, + [&](const void* data, s64 off, s64 size) -> Result { + const auto rc = file.Write(off, data, size, FsWriteOption_None); + if (is_file_based_emummc) { + svcSleepThread(2e+6); // 2ms + } + return rc; + } + )); + } + + fs.DeleteFile(path); + R_TRY(fs.RenameFile(temp_path, path)); + + App::Notify("Backed up to "_i18n + path.toString()); + R_SUCCEED(); +} + +void FreeEntry(NVGcontext* vg, Entry& e) { + nvgDeleteImage(vg, e.image); + e.image = 0; +} + +void ThreadFunc(void* user) { + auto data = static_cast(user); + + if (!nxtcInitialize()) { + log_write("[NXTC] failed to init cache\n"); + } + ON_SCOPE_EXIT(nxtcExit()); + + while (data->IsRunning()) { + data->Run(); + } +} + +} // namespace + +ThreadData::ThreadData() { + ueventCreate(&m_uevent, true); + mutexInit(&m_mutex_id); + mutexInit(&m_mutex_result); + m_running = true; +} + +auto ThreadData::IsRunning() const -> bool { + return m_running; +} + +void ThreadData::Run() { + const auto waiter = waiterForUEvent(&m_uevent); + while (IsRunning()) { + const auto rc = waitSingle(waiter, 3e+9); + + // if we timed out, flush the cache and poll again. + if (R_FAILED(rc)) { + nxtcFlushCacheFile(); + continue; + } + + if (!IsRunning()) { + return; + } + + std::vector ids; + { + mutexLock(&m_mutex_id); + ON_SCOPE_EXIT(mutexUnlock(&m_mutex_id)); + std::swap(ids, m_ids); + } + + for (u64 i = 0; i < std::size(ids); i++) { + if (!IsRunning()) { + return; + } + + ThreadResultData result{ids[i]}; + TimeStamp ts; + if (auto data = nxtcGetApplicationMetadataEntryById(ids[i])) { + log_write("[NXTC] loaded from cache time taken: %.2fs %zums %zuns\n", ts.GetSecondsD(), ts.GetMs(), ts.GetNs()); + ON_SCOPE_EXIT(nxtcFreeApplicationMetadata(&data)); + + result.control = std::make_unique(); + result.status = NacpLoadStatus::Loaded; + std::strcpy(result.lang.name, data->name); + std::strcpy(result.lang.author, data->publisher); + std::memcpy(result.control->icon, data->icon_data, data->icon_size); + result.jpeg_size = data->icon_size; + } else { + // sleep after every other entry loaded. + svcSleepThread(2e+6); // 2ms + + result = LoadControlEntry(ids[i]); + if (result.status == NacpLoadStatus::Loaded) { + nxtcAddEntry(ids[i], &result.control->nacp, result.jpeg_size, result.control->icon, true); + } + } + + mutexLock(&m_mutex_result); + ON_SCOPE_EXIT(mutexUnlock(&m_mutex_result)); + m_result.emplace_back(result); + } + } +} + +void ThreadData::Close() { + m_running = false; + ueventSignal(&m_uevent); +} + +void ThreadData::Push(u64 id) { + mutexLock(&m_mutex_id); + ON_SCOPE_EXIT(mutexUnlock(&m_mutex_id)); + + const auto it = std::ranges::find(m_ids, id); + if (it == m_ids.end()) { + m_ids.emplace_back(id); + ueventSignal(&m_uevent); + } +} + +void ThreadData::Push(std::span entries) { + for (auto& e : entries) { + Push(e.application_id); + } +} + +void ThreadData::Pop(std::vector& out) { + mutexLock(&m_mutex_result); + ON_SCOPE_EXIT(mutexUnlock(&m_mutex_result)); + + std::swap(out, m_result); + m_result.clear(); +} + +Menu::Menu(u32 flags) : grid::Menu{"Saves"_i18n, flags} { + this->SetActions( + std::make_pair(Button::L3, Action{[this](){ + if (m_entries.empty()) { + return; + } + + m_entries[m_index].selected ^= 1; + + if (m_entries[m_index].selected) { + m_selected_count++; + } else { + m_selected_count--; + } + }}), + std::make_pair(Button::R3, Action{[this](){ + if (m_entries.empty()) { + return; + } + + if (m_selected_count == m_entries.size()) { + ClearSelection(); + } else { + m_selected_count = m_entries.size(); + for (auto& e : m_entries) { + e.selected = true; + } + } + }}), + std::make_pair(Button::B, Action{"Back"_i18n, [this](){ + SetPop(); + }}), + std::make_pair(Button::X, Action{"Options"_i18n, [this](){ + auto options = std::make_shared("Save Options"_i18n, Sidebar::Side::RIGHT); + ON_SCOPE_EXIT(App::Push(options)); + + SidebarEntryArray::Items account_items; + for (const auto& e : m_accounts) { + account_items.emplace_back(e.base.nickname); + } + + if (m_entries.size()) { + options->Add(std::make_shared("Sort By"_i18n, [this](){ + auto options = std::make_shared("Sort Options"_i18n, Sidebar::Side::RIGHT); + ON_SCOPE_EXIT(App::Push(options)); + + SidebarEntryArray::Items sort_items; + sort_items.push_back("Updated"_i18n); + + SidebarEntryArray::Items order_items; + order_items.push_back("Descending"_i18n); + order_items.push_back("Ascending"_i18n); + + SidebarEntryArray::Items layout_items; + layout_items.push_back("List"_i18n); + layout_items.push_back("Icon"_i18n); + layout_items.push_back("Grid"_i18n); + + options->Add(std::make_shared("Sort"_i18n, sort_items, [this](s64& index_out){ + m_sort.Set(index_out); + SortAndFindLastFile(false); + }, m_sort.Get())); + + options->Add(std::make_shared("Order"_i18n, order_items, [this](s64& index_out){ + m_order.Set(index_out); + SortAndFindLastFile(false); + }, m_order.Get())); + + options->Add(std::make_shared("Layout"_i18n, layout_items, [this](s64& index_out){ + m_layout.Set(index_out); + OnLayoutChange(); + }, m_layout.Get())); + })); + + options->Add(std::make_shared("Account"_i18n, account_items, [this](s64& index_out){ + m_account_index = index_out; + m_dirty = true; + }, m_account_index)); + + options->Add(std::make_shared("Backup"_i18n, [this](){ + std::vector> entries; + if (m_selected_count) { + for (auto& e : m_entries) { + if (e.selected) { + entries.emplace_back(e); + } + } + } else { + entries.emplace_back(m_entries[m_index]); + } + + BackupSaves(entries); + }, true)); + + options->Add(std::make_shared("Restore"_i18n, [this](){ + RestoreSave(); + }, true)); + } + + options->Add(std::make_shared("Advanced"_i18n, [this](){ + auto options = std::make_shared("Advanced Options"_i18n, Sidebar::Side::RIGHT); + ON_SCOPE_EXIT(App::Push(options)); + + options->Add(std::make_shared("Auto backup on restore"_i18n, m_auto_backup_on_restore.Get(), [this](bool& v_out){ + m_auto_backup_on_restore.Set(v_out); + })); + + options->Add(std::make_shared("Compress backup (slow)"_i18n, m_compress_save_backup.Get(), [this](bool& v_out){ + m_compress_save_backup.Set(v_out); + })); + })); + }}) + ); + + OnLayoutChange(); + nsInitialize(); + + AccountUid uids[8]; + s32 account_count; + if (R_SUCCEEDED(accountListAllUsers(uids, std::size(uids), &account_count))) { + for (s32 i = 0; i < account_count; i++) { + AccountProfile profile; + if (R_SUCCEEDED(accountGetProfile(&profile, uids[i]))) { + AccountProfileBase base; + if (R_SUCCEEDED(accountProfileGet(&profile, nullptr, &base))) { + m_accounts.emplace_back(uids[i], profile, base); + } else { + accountProfileClose(&profile); + } + } + } + } + + // try and find the last / default account and set that. + AccountUid uid{}; + if (R_FAILED(accountTrySelectUserWithoutInteraction(&uid, false))) { + accountGetLastOpenedUser(&uid); + } + + const auto it = std::ranges::find_if(m_accounts, [&uid](auto& e){ + return !std::memcmp(&uid, &e.uid, sizeof(uid)); + }); + + if (it != m_accounts.end()) { + m_account_index = std::distance(m_accounts.begin(), it); + } + + for (auto& e : ncm_entries) { + e.Open(); + } + + threadCreate(&m_thread, ThreadFunc, &m_thread_data, nullptr, 1024*32, THREAD_PRIO, THREAD_CORE); + svcSetThreadCoreMask(m_thread.handle, THREAD_CORE, THREAD_AFFINITY_DEFAULT(THREAD_CORE)); + threadStart(&m_thread); +} + +Menu::~Menu() { + m_thread_data.Close(); + + for (auto& e : m_accounts) { + accountProfileClose(&e.profile); + } + + for (auto& e : ncm_entries) { + e.Close(); + } + + FreeEntries(); + nsExit(); + + threadWaitForExit(&m_thread); + threadClose(&m_thread); +} + +void Menu::Update(Controller* controller, TouchInfo* touch) { + if (m_dirty) { + App::Notify("Updating application record list"); + SortAndFindLastFile(true); + } + + 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); + + if (m_entries.empty()) { + gfx::drawTextArgs(vg, GetX() + GetW() / 2.f, GetY() + GetH() / 2.f, 36.f, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, theme->GetColour(ThemeEntryID_TEXT_INFO), "Empty..."_i18n.c_str()); + return; + } + + // max images per frame, in order to not hit io / gpu too hard. + const int image_load_max = 2; + int image_load_count = 0; + + std::vector data; + m_thread_data.Pop(data); + + for (const auto& d : data) { + const auto it = std::ranges::find_if(m_entries, [&d](auto& e) { + return e.application_id == d.id; + }); + + if (it != m_entries.end()) { + LoadResultIntoEntry(*it, d); + } + } + + 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) { + m_thread_data.Push(e.application_id); + e.status = NacpLoadStatus::Progress; + } + + // lazy load image + if (image_load_count < image_load_max) { + if (LoadControlImage(e)) { + image_load_count++; + } + } + + const auto selected = pos == m_index; + DrawEntry(vg, theme, m_layout.Get(), v, selected, e.image, e.GetName(), e.GetAuthor(), ""); + + if (e.selected) { + gfx::drawRect(vg, v, nvgRGBA(0, 0, 0, 180), 5); + gfx::drawText(vg, x + w / 2, y + h / 2, 24.f, "\uE14B", nullptr, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, theme->GetColour(ThemeEntryID_TEXT_SELECTED)); + } + }); +} + +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); + } + + char title[0x40]; + std::snprintf(title, sizeof(title), "%s | %016lX", m_accounts[m_account_index].base.nickname, m_entries[m_index].application_id); + SetTitleSubHeading(title); + 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); + + if (m_accounts.empty()) { + return; + } + + FsSaveDataFilter filter{}; + filter.attr.uid = m_accounts[m_account_index].uid; + filter.filter_by_user_id = true; + + FsSaveDataInfoReader reader; + fsOpenSaveDataInfoReaderWithFilter(&reader, FsSaveDataSpaceId_User, &filter); + ON_SCOPE_EXIT(fsSaveDataInfoReaderClose(&reader)); + + std::vector info_list(ENTRY_CHUNK_COUNT); + while (true) { + s64 record_count{}; + if (R_FAILED(fsSaveDataInfoReaderRead(&reader, info_list.data(), info_list.size(), &record_count))) { + log_write("failed fsSaveDataInfoReaderRead()\n"); + } + + // finished parsing all entries. + if (!record_count) { + break; + } + + for (s32 i = 0; i < record_count; i++) { + m_entries.emplace_back(info_list[i]); + } + } + + m_is_reversed = false; + m_dirty = false; + log_write("games found: %zu time_taken: %.2f seconds %zu ms %zu ns\n", m_entries.size(), ts.GetSecondsD(), ts.GetMs(), ts.GetNs()); + this->Sort(); + SetIndex(0); + ClearSelection(); +} + +void Menu::Sort() { + // const auto sort = m_sort.Get(); + const auto order = m_order.Get(); + + if (order == OrderType_Ascending) { + if (!m_is_reversed) { + std::ranges::reverse(m_entries); + m_is_reversed = true; + } + } else { + if (m_is_reversed) { + std::ranges::reverse(m_entries); + m_is_reversed = false; + } + } +} + +void Menu::SortAndFindLastFile(bool scan) { + const auto app_id = m_entries[m_index].application_id; + if (scan) { + ScanHomebrew(); + } else { + Sort(); + } + SetIndex(0); + + s64 index = -1; + for (u64 i = 0; i < m_entries.size(); i++) { + if (app_id == m_entries[i].application_id) { + index = i; + break; + } + } + + if (index >= 0) { + const auto row = m_list->GetRow(); + const auto page = m_list->GetPage(); + // guesstimate where the position is + if (index >= page) { + m_list->SetYoff((((index - page) + row) / row) * m_list->GetMaxY()); + } else { + m_list->SetYoff(0); + } + SetIndex(index); + } +} + +void Menu::FreeEntries() { + auto vg = App::GetVg(); + + for (auto&p : m_entries) { + FreeEntry(vg, p); + } + + m_entries.clear(); +} + +void Menu::OnLayoutChange() { + m_index = 0; + grid::Menu::OnLayoutChange(m_list, m_layout.Get()); +} + +void Menu::BackupSaves(std::vector>& entries) { + int image = 0; + if (entries.size() == 1) { + image = entries[0].get().image; + } + + App::Push(std::make_shared( + "Are you sure you want to backup save(s)?"_i18n, + "Back"_i18n, "Backup"_i18n, 0, [this, entries](auto op_index){ + if (op_index && *op_index) { + App::Push(std::make_shared(0, "Backup"_i18n, "", [this, entries](auto pbox) -> Result { + for (auto& e : entries) { + // the entry may not have loaded yet. + LoadControlEntry(e); + R_TRY(BackupSaveInternal(pbox, m_accounts[m_account_index], e, m_compress_save_backup.Get())); + } + R_SUCCEED(); + }, [](Result rc){ + App::PushErrorBox(rc, "Backup failed!"_i18n); + + if (R_SUCCEEDED(rc)) { + App::Notify("Backup successfull!"_i18n); + } + })); + } + }, image + )); +} + +void Menu::RestoreSave() { + const auto save_path = BuildSaveBasePath(m_entries[m_index]); + + fs::FsNativeSd fs; + filebrowser::FsDirCollection collection; + filebrowser::FsView::get_collection(&fs, save_path, "", collection, true, false, false); + + // reverse as they will be sorted in oldest -> newest. + std::ranges::reverse(collection.files); + + std::vector paths; + PopupList::Items items; + for (const auto&p : collection.files) { + const auto view = std::string_view{p.name}; + if (view.starts_with("BCAT") || !view.ends_with(".zip")) { + continue; + } + + items.emplace_back(p.name); + paths.emplace_back(fs::AppendPath(collection.path, p.name)); + } + + if (paths.empty()) { + App::Push(std::make_shared( + "No saves found in "_i18n + save_path.toString(), + "OK"_i18n + )); + return; + } + + const auto title = "Restore save for: "_i18n + m_entries[m_index].GetName(); + App::Push(std::make_shared( + title, items, [this, paths, items](auto op_index){ + if (!op_index) { + return; + } + + const auto file_name = items[*op_index]; + const auto file_path = paths[*op_index]; + + App::Push(std::make_shared( + "Are you sure you want to restore "_i18n + file_name + "?", + "Back"_i18n, "Restore"_i18n, 0, [this, file_path](auto op_index){ + if (op_index && *op_index) { + App::Push(std::make_shared(0, "Restore"_i18n, "", [this, file_path](auto pbox) -> Result { + // the entry may not have loaded yet. + LoadControlEntry(m_entries[m_index]); + + if (m_auto_backup_on_restore.Get()) { + pbox->SetActionName("Auto backup"_i18n); + R_TRY(BackupSaveInternal(pbox, m_accounts[m_account_index], m_entries[m_index], m_compress_save_backup.Get(), true)); + } + + pbox->SetActionName("Restore"_i18n); + return RestoreSaveInternal(pbox, m_entries[m_index], file_path); + }, [this](Result rc){ + App::PushErrorBox(rc, "Restore failed!"_i18n); + + if (R_SUCCEEDED(rc)) { + App::Notify("Restore successfull!"_i18n); + } + })); + } + }, m_entries[m_index].image + )); + } + )); +} + +} // namespace sphaira::ui::menu::save diff --git a/sphaira/source/ui/progress_box.cpp b/sphaira/source/ui/progress_box.cpp index e32673b..0078056 100644 --- a/sphaira/source/ui/progress_box.cpp +++ b/sphaira/source/ui/progress_box.cpp @@ -88,6 +88,7 @@ auto ProgressBox::Draw(NVGcontext* vg, Theme* theme) -> void { m_last_offset = m_offset; } + const auto action = m_action; const auto title = m_title; const auto transfer = m_transfer; const auto size = m_size; @@ -166,7 +167,7 @@ auto ProgressBox::Draw(NVGcontext* vg, Theme* theme) -> void { gfx::drawTextArgs(vg, center_x, prog_bar.y + prog_bar.h + 30, 18, NVG_ALIGN_CENTER | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "%s (%s)", time_str, speed_str); } - gfx::drawTextArgs(vg, center_x, m_pos.y + 40, 24, NVG_ALIGN_CENTER | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), m_action.c_str()); + gfx::drawTextArgs(vg, center_x, m_pos.y + 40, 24, NVG_ALIGN_CENTER | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), action.c_str()); const auto draw_text = [&](ScrollingText& scroll, const std::string& txt, float y, float size, float pad, ThemeEntryID id){ float bounds[4]; @@ -187,6 +188,14 @@ auto ProgressBox::Draw(NVGcontext* vg, Theme* theme) -> void { nvgRestore(vg); } +auto ProgressBox::SetActionName(const std::string& action) -> ProgressBox& { + mutexLock(&m_mutex); + m_action = action; + mutexUnlock(&m_mutex); + Yield(); + return *this; +} + auto ProgressBox::SetTitle(const std::string& title) -> ProgressBox& { mutexLock(&m_mutex); m_title = title;