devoptab: add mount creator.

This commit is contained in:
ITotalJustice
2025-09-14 14:04:20 +01:00
parent b476c54825
commit 9cdb77bafa
10 changed files with 393 additions and 76 deletions

View File

@@ -128,6 +128,7 @@ add_executable(sphaira
source/utils/devoptab_bfsar.cpp
source/utils/devoptab_vfs.cpp
source/utils/devoptab_fatfs.cpp
source/utils/devoptab.cpp
source/usb/base.cpp
source/usb/usbds.cpp

View File

@@ -175,7 +175,10 @@ private:
class SidebarEntryTextInput final : public SidebarEntryTextBase {
public:
// uses normal keyboard.
explicit SidebarEntryTextInput(const std::string& title, const std::string& value, const std::string& guide = {}, s64 len_min = -1, s64 len_max = PATH_MAX, const std::string& info = "");
// uses numpad.
explicit SidebarEntryTextInput(const std::string& title, s64 value, const std::string& guide = {}, s64 len_min = -1, s64 len_max = PATH_MAX, const std::string& info = "");
private:
const std::string m_guide;
@@ -202,10 +205,8 @@ public:
using Items = std::vector<std::unique_ptr<SidebarEntryBase>>;
public:
explicit Sidebar(const std::string& title, Side side, Items&& items);
explicit Sidebar(const std::string& title, Side side);
explicit Sidebar(const std::string& title, const std::string& sub, Side side, Items&& items);
explicit Sidebar(const std::string& title, const std::string& sub, Side side);
explicit Sidebar(const std::string& title, Side side, float width = 450.f);
explicit Sidebar(const std::string& title, const std::string& sub, Side side, float width = 450.f);
auto Update(Controller* controller, TouchInfo* touch) -> void override;
auto Draw(NVGcontext* vg, Theme* theme) -> void override;
@@ -242,4 +243,11 @@ private:
static constexpr Vec2 m_box_size{400.f, 70.f};
};
class FormSidebar : public Sidebar {
public:
// explicit FormSidebar(const std::string& title) : Sidebar{title, Side::RIGHT, 540.f} {
explicit FormSidebar(const std::string& title) : Sidebar{title, Side::RIGHT} {
}
};
} // namespace sphaira::ui

View File

@@ -36,4 +36,6 @@ void UmountNeworkDevice(const fs::FsPath& mount);
// SEE: https://github.com/devkitPro/newlib/issues/35
void FixDkpBug();
void DisplayDevoptabSideBar();
} // namespace sphaira::devoptab

View File

@@ -131,7 +131,7 @@ struct MountConfig {
std::string user{};
std::string pass{};
std::string dump_path{};
std::optional<long> port{};
long port{};
long timeout{};
bool read_only{};
bool no_stat_file{true};
@@ -141,6 +141,7 @@ struct MountConfig {
std::unordered_map<std::string, std::string> extra{};
};
using MountConfigs = std::vector<MountConfig>;
struct PullThreadData final : PushPullThreadData {
using PushPullThreadData::PushPullThreadData;
@@ -214,6 +215,8 @@ private:
bool m_mounted{};
};
void LoadConfigsFromIni(const fs::FsPath& path, MountConfigs& out_configs);
using CreateDeviceCallback = std::function<std::unique_ptr<MountDevice>(const MountConfig& config)>;
Result MountNetworkDevice(const CreateDeviceCallback& create_device, size_t file_size, size_t dir_size, const char* name, bool force_read_only = false);

View File

@@ -1953,6 +1953,12 @@ void App::DisplayAdvancedOptions(bool left_side) {
}, "When enabled, it replaces /hbmenu.nro with Sphaira, creating a backup of hbmenu to /switch/hbmenu.nro\n\n" \
"Disabling will give you the option to restore hbmenu."_i18n);
options->Add<ui::SidebarEntryCallback>("Add / modify mounts"_i18n, [](){
devoptab::DisplayDevoptabSideBar();
}, "Create, modify, delete network mounts (HTTP, FTP, SFTP, SMB, NFS).\n"
"Mount options only require a URL and Name be set, with other fields being optional, such as port, user, pass etc.\n\n"
"Any changes made will require restarting Sphaira to take effect."_i18n);
options->Add<ui::SidebarEntryBool>("Boost CPU during transfer"_i18n, App::GetApp()->m_progress_boost_mode,
"Enables boost mode during transfers which can improve transfer speed. "
"This sets the CPU to 1785mhz and lowers the GPU 76mhz"_i18n);

View File

@@ -53,7 +53,7 @@ namespace {
using RomDatabaseIndexs = std::vector<size_t>;
struct ForwarderForm final : public Sidebar {
struct ForwarderForm final : public FormSidebar {
explicit ForwarderForm(const FileAssocEntry& assoc, const RomDatabaseIndexs& db_indexs, const FileEntry& entry, const fs::FsPath& arg_path);
private:
@@ -288,7 +288,7 @@ auto GetRomIcon(std::string filename, const RomDatabaseIndexs& db_indexs, const
}
ForwarderForm::ForwarderForm(const FileAssocEntry& assoc, const RomDatabaseIndexs& db_indexs, const FileEntry& entry, const fs::FsPath& arg_path)
: Sidebar{"Forwarder Creation", Side::RIGHT}
: FormSidebar{"Forwarder Creation"}
, m_assoc{assoc}
, m_db_indexs{db_indexs}
, m_arg_path{arg_path} {

View File

@@ -314,6 +314,16 @@ SidebarEntryTextInput::SidebarEntryTextInput(const std::string& title, const std
});
}
SidebarEntryTextInput::SidebarEntryTextInput(const std::string& title, s64 value, const std::string& guide, s64 len_min, s64 len_max, const std::string& info)
: SidebarEntryTextInput{title, std::to_string(value), guide, len_min, len_max, info} {
SetCallback([this](){
s64 out = std::stoul(GetValue());
if (R_SUCCEEDED(swkbd::ShowNumPad(out, m_guide.c_str(), GetValue().c_str(), m_len_min, m_len_max))) {
SetValue(std::to_string(out));
}
});
}
SidebarEntryFilePicker::SidebarEntryFilePicker(const std::string& title, const std::string& value, const std::vector<std::string>& filter, const std::string& info)
: SidebarEntryTextBase{title, value, {}, info}, m_filter{filter} {
@@ -328,26 +338,21 @@ SidebarEntryFilePicker::SidebarEntryFilePicker(const std::string& title, const s
});
}
Sidebar::Sidebar(const std::string& title, Side side, Items&& items)
: Sidebar{title, "", side, std::forward<decltype(items)>(items)} {
Sidebar::Sidebar(const std::string& title, Side side, float width)
: Sidebar{title, "", side, width} {
}
Sidebar::Sidebar(const std::string& title, Side side)
: Sidebar{title, "", side, {}} {
}
Sidebar::Sidebar(const std::string& title, const std::string& sub, Side side, Items&& items)
Sidebar::Sidebar(const std::string& title, const std::string& sub, Side side, float width)
: m_title{title}
, m_sub{sub}
, m_side{side}
, m_items{std::forward<decltype(items)>(items)} {
, m_side{side} {
switch (m_side) {
case Side::LEFT:
SetPos(Vec4{0.f, 0.f, 450.f, SCREEN_HEIGHT});
SetPos(Vec4{0.f, 0.f, width, SCREEN_HEIGHT});
break;
case Side::RIGHT:
SetPos(Vec4{SCREEN_WIDTH - 450.f, 0.f, 450.f, SCREEN_HEIGHT});
SetPos(Vec4{SCREEN_WIDTH - width, 0.f, width, SCREEN_HEIGHT});
break;
}
@@ -365,11 +370,6 @@ Sidebar::Sidebar(const std::string& title, const std::string& sub, Side side, It
m_list->SetScrollBarPos(GetX() + GetW() - 20, m_base_pos.y - 10, pos.h - m_base_pos.y + 48);
}
Sidebar::Sidebar(const std::string& title, const std::string& sub, Side side)
: Sidebar{title, sub, side, {}} {
}
auto Sidebar::Update(Controller* controller, TouchInfo* touch) -> void {
Widget::Update(controller, touch);

View File

@@ -0,0 +1,294 @@
#include "utils/devoptab_common.hpp"
#include "utils/thread.hpp"
#include "ui/sidebar.hpp"
#include "ui/popup_list.hpp"
#include "ui/option_box.hpp"
#include "app.hpp"
#include "defines.hpp"
#include "log.hpp"
#include "download.hpp"
#include "i18n.hpp"
#include <cstring>
#include <algorithm>
#include <fcntl.h>
#include <minIni.h>
#include <curl/curl.h>
namespace sphaira::devoptab {
namespace {
using namespace sphaira::ui;
using namespace sphaira::devoptab::common;
enum class DevoptabType {
HTTP,
FTP,
SFTP,
NFS,
SMB,
WEBDAV,
};
struct TypeEntry {
const char* name;
DevoptabType type;
};
const TypeEntry TYPE_ENTRIES[] = {
{"HTTP", DevoptabType::HTTP},
{"FTP", DevoptabType::FTP},
{"SFTP", DevoptabType::SFTP},
{"NFS", DevoptabType::NFS},
{"SMB", DevoptabType::SMB},
{"WEBDAV", DevoptabType::WEBDAV},
};
struct TypeConfig {
TypeEntry type;
MountConfig config;
};
using TypeConfigs = std::vector<TypeConfig>;
auto BuildIniPathFromType(DevoptabType type) -> fs::FsPath {
switch (type) {
case DevoptabType::HTTP: return "/config/sphaira/mount/http.ini";
case DevoptabType::FTP: return "/config/sphaira/mount/ftp.ini";
case DevoptabType::SFTP: return "/config/sphaira/mount/sftp.ini";
case DevoptabType::NFS: return "/config/sphaira/mount/nfs.ini";
case DevoptabType::SMB: return "/config/sphaira/mount/smb.ini";
case DevoptabType::WEBDAV: return "/config/sphaira/mount/webdav.ini";
}
std::unreachable();
}
auto GetTypeName(const TypeConfig& type_config) -> std::string {
char name[128]{};
std::snprintf(name, sizeof(name), "[%s] %s", type_config.type.name, type_config.config.name.c_str());
return name;
}
void LoadAllConfigs(TypeConfigs& out_configs) {
out_configs.clear();
for (const auto& e : TYPE_ENTRIES) {
const auto ini_path = BuildIniPathFromType(e.type);
MountConfigs configs{};
LoadConfigsFromIni(ini_path, configs);
for (const auto& config : configs) {
out_configs.emplace_back(e, config);
}
}
}
struct DevoptabForm final : public FormSidebar {
// create new.
explicit DevoptabForm();
// modify existing.
explicit DevoptabForm(DevoptabType type, const MountConfig& config);
private:
void SetupButtons(bool type_change);
private:
DevoptabType m_type{};
MountConfig m_config{};
SidebarEntryTextInput* m_name{};
SidebarEntryTextInput* m_url{};
SidebarEntryTextInput* m_port{};
// SidebarEntryTextInput* m_timeout{};
SidebarEntryTextInput* m_user{};
SidebarEntryTextInput* m_pass{};
SidebarEntryTextInput* m_dump_path{};
};
DevoptabForm::DevoptabForm(DevoptabType type, const MountConfig& config)
: FormSidebar{"Mount Creator"}
, m_type{type}
, m_config{config} {
SetupButtons(false);
}
DevoptabForm::DevoptabForm() : FormSidebar{"Mount Creator"} {
SetupButtons(true);
}
void DevoptabForm::SetupButtons(bool type_change) {
if (type_change) {
SidebarEntryArray::Items items;
for (const auto& e : TYPE_ENTRIES) {
items.emplace_back(e.name);
}
this->Add<SidebarEntryArray>(
"Type", items, [this](s64& index) {
m_type = TYPE_ENTRIES[index].type;
},
(s64)m_type,
"Select the type of the forwarder."_i18n
);
}
m_name = this->Add<SidebarEntryTextInput>(
"Name", m_config.name, "", -1, 32,
"Set the name of the application"_i18n
);
m_url = this->Add<SidebarEntryTextInput>(
"URL", m_config.url, "", -1, PATH_MAX,
"Set the URL of the application"_i18n
);
m_port = this->Add<SidebarEntryTextInput>(
"Port", m_config.port, "Port number", 1, 5,
"Optional: Set the port of the server. If left empty, the default port for the protocol will be used."_i18n
);
#if 0
m_timeout = this->Add<SidebarEntryTextInput>(
"Timeout", m_config.timeout, "Timeout in milliseconds", 1, 5,
"Optional: Set the timeout in seconds."_i18n
);
#endif
m_user = this->Add<SidebarEntryTextInput>(
"User", m_config.user, "", -1, PATH_MAX,
"Optional: Set the username of the application"_i18n
);
m_pass = this->Add<SidebarEntryTextInput>(
"Pass", m_config.pass, "", -1, PATH_MAX,
"Optional: Set the password of the application"_i18n
);
m_dump_path = this->Add<SidebarEntryTextInput>(
"Dump path", m_config.dump_path, "", -1, PATH_MAX,
"Optional: Set the dump path used when exporting games and saves."_i18n
);
this->Add<SidebarEntryBool>(
"Read only", m_config.read_only,
"Mount the filesystem as read only.\n\n"
"Setting this option also hidens the mount from being show as an export option."_i18n
);
this->Add<SidebarEntryBool>(
"No stat file", m_config.no_stat_file,
"Enabling stops the file browser from checking the file size and timestamp of each file. "
"This improves browsing performance."_i18n
);
this->Add<SidebarEntryBool>(
"No stat dir", m_config.no_stat_dir,
"Enabling stops the file browser from checking how many files and folders are in a folder. "
"This improves browsing performance, especially for servers that has slow directory listing."_i18n
);
this->Add<SidebarEntryBool>(
"FS hidden", m_config.fs_hidden,
"Hide the mount from being visible in the file browser."_i18n
);
this->Add<SidebarEntryBool>(
"Export hidden", m_config.dump_hidden,
"Hide the mount from being visible as a export option for games and saves."_i18n
);
const auto callback = this->Add<SidebarEntryCallback>("Save", [this](){
m_config.name = m_name->GetValue();
m_config.url = m_url->GetValue();
m_config.user = m_user->GetValue();
m_config.pass = m_pass->GetValue();
m_config.dump_path = m_dump_path->GetValue();
m_config.port = std::stoul(m_port->GetValue());
// m_config.timeout = m_timeout->GetValue();
const auto ini_path = BuildIniPathFromType(m_type);
ini_puts(m_config.name.c_str(), "url", m_config.url.c_str(), ini_path);
ini_puts(m_config.name.c_str(), "user", m_config.user.c_str(), ini_path);
ini_puts(m_config.name.c_str(), "pass", m_config.pass.c_str(), ini_path);
ini_puts(m_config.name.c_str(), "dump_path", m_config.dump_path.c_str(), ini_path);
ini_putl(m_config.name.c_str(), "port", m_config.port, ini_path);
ini_putl(m_config.name.c_str(), "timeout", m_config.timeout, ini_path);
// todo: update minini to have put_bool.
ini_puts(m_config.name.c_str(), "read_only", m_config.read_only ? "true" : "false", ini_path);
ini_puts(m_config.name.c_str(), "no_stat_file", m_config.no_stat_file ? "true" : "false", ini_path);
ini_puts(m_config.name.c_str(), "no_stat_dir", m_config.no_stat_dir ? "true" : "false", ini_path);
ini_puts(m_config.name.c_str(), "fs_hidden", m_config.fs_hidden ? "true" : "false", ini_path);
ini_puts(m_config.name.c_str(), "dump_hidden", m_config.dump_hidden ? "true" : "false", ini_path);
App::Notify("Mount entry saved. Restart Sphaira to apply changes."_i18n);
this->SetPop();
}, "Saves the mount entry.\n\n"
"NOTE: You must restart Sphaira for changes to take effect!"_i18n);
// ensure that all fields are valid.
callback->Depends([this](){
return
!m_name->GetValue().empty() &&
!m_url->GetValue().empty();
}, "Name and URL must be set!"_i18n);
}
} // namespace
void DisplayDevoptabSideBar() {
auto options = std::make_unique<Sidebar>("Devoptab Options"_i18n, Sidebar::Side::RIGHT);
ON_SCOPE_EXIT(App::Push(std::move(options)));
options->Add<SidebarEntryCallback>("Create New Entry"_i18n, [](){
App::Push<DevoptabForm>();
}, "Creates a new mount option.\n\n"
"NOTE: You must restart Sphaira for changes to take effect!"_i18n);
options->Add<SidebarEntryCallback>("Modify Existing Entry"_i18n, [](){
PopupList::Items items;
TypeConfigs configs;
LoadAllConfigs(configs);
for (const auto& e : configs) {
items.emplace_back(GetTypeName(e));
}
App::Push<PopupList>("Modify Entry"_i18n, items, [configs](std::optional<s64> index){
if (!index.has_value()) {
return;
}
const auto& entry = configs[index.value()];
App::Push<DevoptabForm>(entry.type.type, entry.config);
});
}, "Modify an existing mount option.\n\n"
"NOTE: You must restart Sphaira for changes to take effect!"_i18n);
options->Add<SidebarEntryCallback>("Delete Existing Entry"_i18n, [](){
PopupList::Items items;
TypeConfigs configs;
LoadAllConfigs(configs);
for (const auto& e : configs) {
items.emplace_back(GetTypeName(e));
}
App::Push<PopupList>("Delete Entry"_i18n, items, [configs](std::optional<s64> index){
if (!index.has_value()) {
return;
}
const auto& entry = configs[index.value()];
const auto ini_path = BuildIniPathFromType(entry.type.type);
ini_puts(entry.config.name.c_str(), nullptr, nullptr, ini_path);
});
}, "Delete an existing mount option.\n\n"
"NOTE: You must restart Sphaira for changes to take effect!"_i18n);
}
} // namespace sphaira::devoptab

View File

@@ -769,6 +769,58 @@ void update_devoptab_for_read_only(devoptab_t* devoptab, bool read_only) {
}
}
void LoadConfigsFromIni(const fs::FsPath& path, MountConfigs& out_configs) {
static const auto cb = [](const mTCHAR *Section, const mTCHAR *Key, const mTCHAR *Value, void *UserData) -> int {
auto e = static_cast<MountConfigs*>(UserData);
if (!Section || !Key || !Value) {
return 1;
}
// add new entry if use section changed.
if (e->empty() || std::strcmp(Section, e->back().name.c_str())) {
e->emplace_back(Section);
}
if (!std::strcmp(Key, "url")) {
e->back().url = Value;
} else if (!std::strcmp(Key, "user")) {
e->back().user = Value;
} else if (!std::strcmp(Key, "pass")) {
e->back().pass = Value;
} else if (!std::strcmp(Key, "dump_path")) {
e->back().dump_path = Value;
} else if (!std::strcmp(Key, "port")) {
const auto port = ini_parse_getl(Value, -1);
if (port < 0 || port > 65535) {
log_write("[DEVOPTAB] INI: invalid port %s\n", Value);
} else {
e->back().port = port;
}
} else if (!std::strcmp(Key, "timeout")) {
e->back().timeout = ini_parse_getl(Value, e->back().timeout);
} else if (!std::strcmp(Key, "read_only")) {
e->back().read_only = ini_parse_getbool(Value, e->back().read_only);
} else if (!std::strcmp(Key, "no_stat_file")) {
e->back().no_stat_file = ini_parse_getbool(Value, e->back().no_stat_file);
} else if (!std::strcmp(Key, "no_stat_dir")) {
e->back().no_stat_dir = ini_parse_getbool(Value, e->back().no_stat_dir);
} else if (!std::strcmp(Key, "fs_hidden")) {
e->back().fs_hidden = ini_parse_getbool(Value, e->back().fs_hidden);
} else if (!std::strcmp(Key, "dump_hidden")) {
e->back().dump_hidden = ini_parse_getbool(Value, e->back().dump_hidden);
} else {
log_write("[DEVOPTAB] INI: extra key %s=%s\n", Key, Value);
e->back().extra.emplace(Key, Value);
}
return 1;
};
out_configs.resize(0);
ini_browse(cb, &out_configs, path);
log_write("[DEVOPTAB] Found %zu mount configs\n", out_configs.size());
}
bool MountNetworkDevice2(std::unique_ptr<MountDevice>&& device, const MountConfig& config, size_t file_size, size_t dir_size, const char* name, const char* mount_name) {
if (!device) {
log_write("[DEVOPTAB] No device for %s\n", mount_name);
@@ -877,60 +929,11 @@ Result MountNetworkDevice(const CreateDeviceCallback& create_device, size_t file
SCOPED_RWLOCK(&g_rwlock, true);
using MountConfigs = std::vector<MountConfig>;
static const auto cb = [](const mTCHAR *Section, const mTCHAR *Key, const mTCHAR *Value, void *UserData) -> int {
auto e = static_cast<MountConfigs*>(UserData);
if (!Section || !Key || !Value) {
return 1;
}
// add new entry if use section changed.
if (e->empty() || std::strcmp(Section, e->back().name.c_str())) {
e->emplace_back(Section);
}
if (!std::strcmp(Key, "url")) {
e->back().url = Value;
} else if (!std::strcmp(Key, "user")) {
e->back().user = Value;
} else if (!std::strcmp(Key, "pass")) {
e->back().pass = Value;
} else if (!std::strcmp(Key, "dump_path")) {
e->back().dump_path = Value;
} else if (!std::strcmp(Key, "port")) {
const auto port = ini_parse_getl(Value, -1);
if (port < 0 || port > 65535) {
log_write("[DEVOPTAB] INI: invalid port %s\n", Value);
} else {
e->back().port = port;
}
} else if (!std::strcmp(Key, "timeout")) {
e->back().timeout = ini_parse_getl(Value, e->back().timeout);
} else if (!std::strcmp(Key, "read_only")) {
e->back().read_only = ini_parse_getbool(Value, e->back().read_only);
} else if (!std::strcmp(Key, "no_stat_file")) {
e->back().no_stat_file = ini_parse_getbool(Value, e->back().no_stat_file);
} else if (!std::strcmp(Key, "no_stat_dir")) {
e->back().no_stat_dir = ini_parse_getbool(Value, e->back().no_stat_dir);
} else if (!std::strcmp(Key, "fs_hidden")) {
e->back().fs_hidden = ini_parse_getbool(Value, e->back().fs_hidden);
} else if (!std::strcmp(Key, "dump_hidden")) {
e->back().dump_hidden = ini_parse_getbool(Value, e->back().dump_hidden);
} else {
log_write("[DEVOPTAB] INI: extra key %s=%s\n", Key, Value);
e->back().extra.emplace(Key, Value);
}
return 1;
};
fs::FsPath config_path{};
std::snprintf(config_path, sizeof(config_path), "/config/sphaira/mount/%s.ini", name);
MountConfigs configs{};
ini_browse(cb, &configs, config_path);
log_write("[DEVOPTAB] Found %zu mount configs\n", configs.size());
LoadConfigsFromIni(config_path, configs);
for (auto& config : configs) {
if (config.name.empty()) {
@@ -1192,8 +1195,8 @@ bool MountCurlDevice::Mount() {
return false;
}
if (config.port.has_value()) {
rc = curl_url_set(curlu, CURLUPART_PORT, std::to_string(config.port.value()).c_str(), flags);
if (config.port > 0) {
rc = curl_url_set(curlu, CURLUPART_PORT, std::to_string(config.port).c_str(), flags);
if (rc != CURLUE_OK) {
log_write("[CURL] curl_url_set() port failed: %s\n", curl_url_strerror_wrap(rc));
}

View File

@@ -193,7 +193,7 @@ bool Device::Mount() {
hints.ai_socktype = SOCK_STREAM;
addrinfo* res{};
const auto port = this->config.port.value_or(22);
const auto port = this->config.port > 0 ? this->config.port : 22;
const auto port_str = std::to_string(port);
auto ret = getaddrinfo(this->config.url.c_str(), port_str.c_str(), &hints, &res);
if (ret != 0) {