add file hashing to the file browser (crc32, md5, sha1, sha256)
This commit is contained in:
@@ -78,6 +78,7 @@ add_executable(sphaira
|
|||||||
source/nxlink.cpp
|
source/nxlink.cpp
|
||||||
source/owo.cpp
|
source/owo.cpp
|
||||||
source/swkbd.cpp
|
source/swkbd.cpp
|
||||||
|
source/hasher.cpp
|
||||||
source/i18n.cpp
|
source/i18n.cpp
|
||||||
source/ftpsrv_helper.cpp
|
source/ftpsrv_helper.cpp
|
||||||
source/threaded_file_transfer.cpp
|
source/threaded_file_transfer.cpp
|
||||||
|
|||||||
30
sphaira/include/hasher.hpp
Normal file
30
sphaira/include/hasher.hpp
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "fs.hpp"
|
||||||
|
#include "ui/progress_box.hpp"
|
||||||
|
#include <string>
|
||||||
|
#include <memory>
|
||||||
|
#include <switch.h>
|
||||||
|
|
||||||
|
namespace sphaira::hash {
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
Crc32,
|
||||||
|
Md5,
|
||||||
|
Sha1,
|
||||||
|
Sha256,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct BaseSource {
|
||||||
|
virtual ~BaseSource() = default;
|
||||||
|
virtual Result Size(s64* out) = 0;
|
||||||
|
virtual Result Read(void* buf, s64 off, s64 size, u64* bytes_read) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto GetTypeStr(Type type) -> const char*;
|
||||||
|
|
||||||
|
// returns the hash string.
|
||||||
|
Result Hash(ui::ProgressBox* pbox, Type type, std::shared_ptr<BaseSource> source, std::string& out);
|
||||||
|
Result Hash(ui::ProgressBox* pbox, Type type, fs::Fs* fs, const fs::FsPath& path, std::string& out);
|
||||||
|
|
||||||
|
} // namespace sphaira::hash
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "nro.hpp"
|
#include "nro.hpp"
|
||||||
#include "fs.hpp"
|
#include "fs.hpp"
|
||||||
#include "option.hpp"
|
#include "option.hpp"
|
||||||
|
#include "hasher.hpp"
|
||||||
// #include <optional>
|
// #include <optional>
|
||||||
#include <span>
|
#include <span>
|
||||||
|
|
||||||
@@ -272,6 +273,8 @@ private:
|
|||||||
return (fs::FsNative*)m_fs.get();
|
return (fs::FsNative*)m_fs.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DisplayHash(hash::Type type);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr inline const char* INI_SECTION = "filebrowser";
|
static constexpr inline const char* INI_SECTION = "filebrowser";
|
||||||
|
|
||||||
|
|||||||
184
sphaira/source/hasher.cpp
Normal file
184
sphaira/source/hasher.cpp
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#include "hasher.hpp"
|
||||||
|
#include <mbedtls/md5.h>
|
||||||
|
|
||||||
|
namespace sphaira::hash {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
consteval auto CalculateHashStrLen(s64 buf_size) {
|
||||||
|
return buf_size * 2 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileSource final : BaseSource {
|
||||||
|
FileSource(fs::Fs* fs, const fs::FsPath& path) : m_fs{fs} {
|
||||||
|
m_open_result = m_fs->OpenFile(path, FsOpenMode_Read, std::addressof(m_file));
|
||||||
|
}
|
||||||
|
|
||||||
|
~FileSource() {
|
||||||
|
m_fs->FileClose(std::addressof(m_file));
|
||||||
|
}
|
||||||
|
|
||||||
|
Result Size(s64* out) {
|
||||||
|
return m_fs->FileGetSize(std::addressof(m_file), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
Result Read(void* buf, s64 off, s64 size, u64* bytes_read) {
|
||||||
|
return m_fs->FileRead(std::addressof(m_file), off, buf, size, 0, bytes_read);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
fs::Fs* m_fs{};
|
||||||
|
fs::File m_file{};
|
||||||
|
Result m_open_result{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HashSource {
|
||||||
|
virtual ~HashSource() = default;
|
||||||
|
virtual void Update(const void* buf, s64 size) = 0;
|
||||||
|
virtual void Get(std::string& out) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HashCrc32 final : HashSource {
|
||||||
|
void Update(const void* buf, s64 size) {
|
||||||
|
m_seed = crc32CalculateWithSeed(m_seed, buf, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Get(std::string& out) {
|
||||||
|
char str[CalculateHashStrLen(sizeof(m_seed))];
|
||||||
|
std::snprintf(str, sizeof(str), "%08x", m_seed);
|
||||||
|
out = str;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
u32 m_seed{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HashMd5 final : HashSource {
|
||||||
|
HashMd5() {
|
||||||
|
mbedtls_md5_init(&m_ctx);
|
||||||
|
mbedtls_md5_starts_ret(&m_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
~HashMd5() {
|
||||||
|
mbedtls_md5_free(&m_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Update(const void* buf, s64 size) {
|
||||||
|
mbedtls_md5_update_ret(&m_ctx, (const u8*)buf, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Get(std::string& out) {
|
||||||
|
u8 hash[16];
|
||||||
|
mbedtls_md5_finish_ret(&m_ctx, hash);
|
||||||
|
|
||||||
|
char str[CalculateHashStrLen(sizeof(hash))];
|
||||||
|
for (u32 i = 0; i < sizeof(hash); i++) {
|
||||||
|
std::sprintf(str + i * 2, "%02x", hash[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
out = str;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
mbedtls_md5_context m_ctx{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HashSha1 final : HashSource {
|
||||||
|
HashSha1() {
|
||||||
|
sha1ContextCreate(&m_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Update(const void* buf, s64 size) {
|
||||||
|
sha1ContextUpdate(&m_ctx, buf, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Get(std::string& out) {
|
||||||
|
u8 hash[SHA1_HASH_SIZE];
|
||||||
|
sha1ContextGetHash(&m_ctx, hash);
|
||||||
|
|
||||||
|
char str[CalculateHashStrLen(sizeof(hash))];
|
||||||
|
for (u32 i = 0; i < sizeof(hash); i++) {
|
||||||
|
std::sprintf(str + i * 2, "%02x", hash[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
out = str;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
Sha1Context m_ctx{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HashSha256 final : HashSource {
|
||||||
|
HashSha256() {
|
||||||
|
sha256ContextCreate(&m_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Update(const void* buf, s64 size) {
|
||||||
|
sha256ContextUpdate(&m_ctx, buf, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Get(std::string& out) {
|
||||||
|
u8 hash[SHA256_HASH_SIZE];
|
||||||
|
sha256ContextGetHash(&m_ctx, hash);
|
||||||
|
|
||||||
|
char str[CalculateHashStrLen(sizeof(hash))];
|
||||||
|
for (u32 i = 0; i < sizeof(hash); i++) {
|
||||||
|
std::sprintf(str + i * 2, "%02x", hash[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
out = str;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
Sha256Context m_ctx{};
|
||||||
|
};
|
||||||
|
|
||||||
|
Result Hash(ui::ProgressBox* pbox, std::unique_ptr<HashSource> hash, std::shared_ptr<BaseSource> source, std::string& out) {
|
||||||
|
s64 size;
|
||||||
|
R_TRY(source->Size(&size));
|
||||||
|
|
||||||
|
s64 offset{};
|
||||||
|
std::vector<u8> chunk(1024 * 512);
|
||||||
|
while (offset < size) {
|
||||||
|
R_TRY(pbox->ShouldExitResult());
|
||||||
|
const auto rsize = std::min<s64>(chunk.size(), size - offset);
|
||||||
|
|
||||||
|
u64 bytes_read;
|
||||||
|
R_TRY(source->Read(chunk.data(), offset, rsize, &bytes_read));
|
||||||
|
hash->Update(chunk.data(), bytes_read);
|
||||||
|
|
||||||
|
offset += bytes_read;
|
||||||
|
pbox->UpdateTransfer(offset, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
hash->Get(out);
|
||||||
|
R_SUCCEED();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
auto GetTypeStr(Type type) -> const char* {
|
||||||
|
switch (type) {
|
||||||
|
case Type::Crc32: return "CRC32";
|
||||||
|
case Type::Md5: return "MD5";
|
||||||
|
case Type::Sha1: return "SHA1";
|
||||||
|
case Type::Sha256: return "SHA256";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Result Hash(ui::ProgressBox* pbox, Type type, std::shared_ptr<BaseSource> source, std::string& out) {
|
||||||
|
switch (type) {
|
||||||
|
case Type::Crc32: return Hash(pbox, std::make_unique<HashCrc32>(), source, out);
|
||||||
|
case Type::Md5: return Hash(pbox, std::make_unique<HashMd5>(), source, out);
|
||||||
|
case Type::Sha1: return Hash(pbox, std::make_unique<HashSha1>(), source, out);
|
||||||
|
case Type::Sha256: return Hash(pbox, std::make_unique<HashSha256>(), source, out);
|
||||||
|
}
|
||||||
|
R_THROW(0x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Result Hash(ui::ProgressBox* pbox, Type type, fs::Fs* fs, const fs::FsPath& path, std::string& out) {
|
||||||
|
auto source = std::make_shared<FileSource>(fs, path);
|
||||||
|
return Hash(pbox, type, source, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace sphaira::has
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "yyjson_helper.hpp"
|
#include "yyjson_helper.hpp"
|
||||||
#include "swkbd.hpp"
|
#include "swkbd.hpp"
|
||||||
#include "i18n.hpp"
|
#include "i18n.hpp"
|
||||||
|
#include "hasher.hpp"
|
||||||
#include "nro.hpp"
|
#include "nro.hpp"
|
||||||
|
|
||||||
#include <minIni.h>
|
#include <minIni.h>
|
||||||
@@ -21,7 +22,6 @@
|
|||||||
#include <yyjson.h>
|
#include <yyjson.h>
|
||||||
#include <stb_image.h>
|
#include <stb_image.h>
|
||||||
#include <minizip/unzip.h>
|
#include <minizip/unzip.h>
|
||||||
#include <mbedtls/md5.h>
|
|
||||||
#include <ranges>
|
#include <ranges>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
@@ -356,51 +356,11 @@ auto InstallApp(ProgressBox* pbox, const Entry& entry) -> Result {
|
|||||||
pbox->NewTransfer("Checking MD5"_i18n);
|
pbox->NewTransfer("Checking MD5"_i18n);
|
||||||
log_write("starting md5 check\n");
|
log_write("starting md5 check\n");
|
||||||
|
|
||||||
FsFile f;
|
std::string hash_out;
|
||||||
R_TRY(fs.OpenFile(zip_out, FsOpenMode_Read, &f));
|
R_TRY(hash::Hash(pbox, hash::Type::Md5, &fs, zip_out, hash_out));
|
||||||
ON_SCOPE_EXIT(fsFileClose(&f));
|
|
||||||
|
|
||||||
s64 size;
|
if (strncasecmp(hash_out.data(), entry.md5.data(), entry.md5.length())) {
|
||||||
R_TRY(fsFileGetSize(&f, &size));
|
log_write("bad md5: %.*s vs %.*s\n", 32, hash_out.data(), 32, entry.md5.c_str());
|
||||||
|
|
||||||
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) {
|
|
||||||
R_TRY(pbox->ShouldExitResult());
|
|
||||||
|
|
||||||
u64 bytes_read;
|
|
||||||
R_TRY(fsFileRead(&f, offset, chunk.data(), chunk.size(), 0, &bytes_read));
|
|
||||||
|
|
||||||
if (mbedtls_md5_update_ret(&ctx, chunk.data(), bytes_read)) {
|
|
||||||
log_write("failed to update ret\n");
|
|
||||||
R_THROW(0x1);
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += bytes_read;
|
|
||||||
pbox->UpdateTransfer(offset, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
u8 md5_out[16];
|
|
||||||
if (mbedtls_md5_finish_ret(&ctx, (u8*)md5_out)) {
|
|
||||||
R_THROW(0x1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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());
|
|
||||||
R_THROW(0x1);
|
R_THROW(0x1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "owo.hpp"
|
#include "owo.hpp"
|
||||||
#include "swkbd.hpp"
|
#include "swkbd.hpp"
|
||||||
#include "i18n.hpp"
|
#include "i18n.hpp"
|
||||||
|
#include "hasher.hpp"
|
||||||
#include "location.hpp"
|
#include "location.hpp"
|
||||||
#include "threaded_file_transfer.hpp"
|
#include "threaded_file_transfer.hpp"
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ namespace sphaira::ui::menu::filebrowser {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr FsEntry FS_ENTRY_DEFAULT{
|
constexpr FsEntry FS_ENTRY_DEFAULT{
|
||||||
"Sd", "/", FsType::Sd, FsEntryFlag_Assoc,
|
"microSD card", "/", FsType::Sd, FsEntryFlag_Assoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
constexpr FsEntry FS_ENTRIES[]{
|
constexpr FsEntry FS_ENTRIES[]{
|
||||||
@@ -599,6 +600,29 @@ Menu::Menu(const std::vector<NroEntry>& nro_entries) : MenuBase{"FileBrowser"_i1
|
|||||||
auto options = std::make_shared<Sidebar>("Advanced Options"_i18n, Sidebar::Side::RIGHT);
|
auto options = std::make_shared<Sidebar>("Advanced Options"_i18n, Sidebar::Side::RIGHT);
|
||||||
ON_SCOPE_EXIT(App::Push(options));
|
ON_SCOPE_EXIT(App::Push(options));
|
||||||
|
|
||||||
|
SidebarEntryArray::Items mount_items;
|
||||||
|
std::vector<FsEntry> fs_entries;
|
||||||
|
for (const auto& e: FS_ENTRIES) {
|
||||||
|
fs_entries.emplace_back(e);
|
||||||
|
mount_items.push_back(i18n::get(e.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto stdio_locations = location::GetStdio(false);
|
||||||
|
for (const auto& e: stdio_locations) {
|
||||||
|
u32 flags{};
|
||||||
|
if (e.write_protect) {
|
||||||
|
flags |= FsEntryFlag_ReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs_entries.emplace_back(e.name, e.mount, FsType::Stdio, flags);
|
||||||
|
mount_items.push_back(e.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
options->Add(std::make_shared<SidebarEntryArray>("Mount"_i18n, mount_items, [this, fs_entries](s64& index_out){
|
||||||
|
App::PopToMenu();
|
||||||
|
SetFs(fs_entries[index_out].root, fs_entries[index_out]);
|
||||||
|
}, m_fs_entry.name));
|
||||||
|
|
||||||
options->Add(std::make_shared<SidebarEntryCallback>("Create File"_i18n, [this](){
|
options->Add(std::make_shared<SidebarEntryCallback>("Create File"_i18n, [this](){
|
||||||
std::string out;
|
std::string out;
|
||||||
if (R_SUCCEEDED(swkbd::ShowText(out, "Set File Name"_i18n.c_str(), fs::AppendPath(m_path, ""))) && !out.empty()) {
|
if (R_SUCCEEDED(swkbd::ShowText(out, "Set File Name"_i18n.c_str(), fs::AppendPath(m_path, ""))) && !out.empty()) {
|
||||||
@@ -654,33 +678,28 @@ Menu::Menu(const std::vector<NroEntry>& nro_entries) : MenuBase{"FileBrowser"_i1
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options->Add(std::make_shared<SidebarEntryCallback>("Hash"_i18n, [this](){
|
||||||
|
auto options = std::make_shared<Sidebar>("Hash Options"_i18n, Sidebar::Side::RIGHT);
|
||||||
|
ON_SCOPE_EXIT(App::Push(options));
|
||||||
|
|
||||||
|
options->Add(std::make_shared<SidebarEntryCallback>("CRC32"_i18n, [this](){
|
||||||
|
DisplayHash(hash::Type::Crc32);
|
||||||
|
}));
|
||||||
|
options->Add(std::make_shared<SidebarEntryCallback>("MD5"_i18n, [this](){
|
||||||
|
DisplayHash(hash::Type::Md5);
|
||||||
|
}));
|
||||||
|
options->Add(std::make_shared<SidebarEntryCallback>("SHA1"_i18n, [this](){
|
||||||
|
DisplayHash(hash::Type::Sha1);
|
||||||
|
}));
|
||||||
|
options->Add(std::make_shared<SidebarEntryCallback>("SHA256"_i18n, [this](){
|
||||||
|
DisplayHash(hash::Type::Sha256);
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
options->Add(std::make_shared<SidebarEntryBool>("Ignore read only"_i18n, m_ignore_read_only.Get(), [this](bool& v_out){
|
options->Add(std::make_shared<SidebarEntryBool>("Ignore read only"_i18n, m_ignore_read_only.Get(), [this](bool& v_out){
|
||||||
m_ignore_read_only.Set(v_out);
|
m_ignore_read_only.Set(v_out);
|
||||||
m_fs->SetIgnoreReadOnly(v_out);
|
m_fs->SetIgnoreReadOnly(v_out);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
SidebarEntryArray::Items mount_items;
|
|
||||||
std::vector<FsEntry> fs_entries;
|
|
||||||
for (const auto& e: FS_ENTRIES) {
|
|
||||||
fs_entries.emplace_back(e);
|
|
||||||
mount_items.push_back(i18n::get(e.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto stdio_locations = location::GetStdio(false);
|
|
||||||
for (const auto& e: stdio_locations) {
|
|
||||||
u32 flags{};
|
|
||||||
if (e.write_protect) {
|
|
||||||
flags |= FsEntryFlag_ReadOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs_entries.emplace_back(e.name, e.mount, FsType::Stdio, flags);
|
|
||||||
mount_items.push_back(e.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
options->Add(std::make_shared<SidebarEntryArray>("Mount"_i18n, mount_items, [this, fs_entries](s64& index_out){
|
|
||||||
App::PopToMenu();
|
|
||||||
SetFs(fs_entries[index_out].root, fs_entries[index_out]);
|
|
||||||
}, m_fs_entry.name));
|
|
||||||
}));
|
}));
|
||||||
}})
|
}})
|
||||||
);
|
);
|
||||||
@@ -1933,6 +1952,27 @@ void Menu::SetFs(const fs::FsPath& new_path, const FsEntry& new_entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Menu::DisplayHash(hash::Type type) {
|
||||||
|
// hack because we cannot share output between threaded calls...
|
||||||
|
static std::string hash_out;
|
||||||
|
hash_out.clear();
|
||||||
|
|
||||||
|
App::Push(std::make_shared<ProgressBox>(0, "Hashing"_i18n, GetEntry().name, [this, type](auto pbox) -> Result {
|
||||||
|
R_TRY(hash::Hash(pbox, type, m_fs.get(), GetNewPathCurrent(), hash_out));
|
||||||
|
|
||||||
|
R_SUCCEED();
|
||||||
|
}, [this, type](Result rc){
|
||||||
|
App::PushErrorBox(rc, "Failed to hash file..."_i18n);
|
||||||
|
|
||||||
|
if (R_SUCCEEDED(rc)) {
|
||||||
|
char buf[0x100];
|
||||||
|
// std::snprintf(buf, sizeof(buf), "%s\n%s\n%s", hash::GetTypeStr(type), hash_out.c_str(), GetEntry().GetName());
|
||||||
|
std::snprintf(buf, sizeof(buf), "%s\n%s", hash::GetTypeStr(type), hash_out.c_str());
|
||||||
|
App::Push(std::make_shared<OptionBox>(buf, "OK"_i18n));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace sphaira::ui::menu::filebrowser
|
} // namespace sphaira::ui::menu::filebrowser
|
||||||
|
|
||||||
// options
|
// options
|
||||||
|
|||||||
Reference in New Issue
Block a user