From 3c7c8e76aa273ea905454aaaef2b7e4345bd35fb Mon Sep 17 00:00:00 2001 From: DJCrabhat Date: Fri, 28 Aug 2026 14:35:33 -0700 Subject: [PATCH 1/6] Add post_upload_script Adds an optional post_upload_script to the config, that when provided, executes a command, with {} replacement values. --- include/config.h | 7 + snfm_config_example.yaml | 16 ++ src/config.cpp | 44 +++++ src/send_file.cpp | 389 +++++++++++++++++++++++++-------------- 4 files changed, 320 insertions(+), 136 deletions(-) diff --git a/include/config.h b/include/config.h index 8192cb4..aee6647 100644 --- a/include/config.h +++ b/include/config.h @@ -2,6 +2,7 @@ #include #include #include +#include #include struct RomDestination @@ -19,6 +20,7 @@ struct RomDestinationRule struct Config { std::string default_rom_directory = ".sni"; + std::string post_upload_script; std::vector rom_destination_rules; std::optional> FindRuleForRom(const std::string& rom_name); @@ -26,5 +28,10 @@ struct Config Config LoadConfig(const std::filesystem::path& path); +// Replaces every "{placeholder}" in template_string with the matching value from +// variables. Placeholders with no matching variable are left untouched. +std::string ExpandTemplate(const std::string& template_string, + const std::vector>& variables); + std::optional FindExistingConfigFile(const std::filesystem::path& pwd); \ No newline at end of file diff --git a/snfm_config_example.yaml b/snfm_config_example.yaml index abf3cda..df14c33 100644 --- a/snfm_config_example.yaml +++ b/snfm_config_example.yaml @@ -17,6 +17,22 @@ ### Default is .sni - directories that start with "." are hidden in the FXPAKPRO UI # default_rom_directory: ".sni" +### (optional) a command to run once the ROM has been sent to the device and booted. +### Handy for things like writing the MSU pack you picked out to a file for a stream overlay. +### These variables are substituted in before the command is run: +### {rule_name} - the rule that matched, e.g. "alttpr" (empty if nothing matched) +### {destination_name} - the destination you picked, e.g. "WildArms2" (empty if nothing matched) +### {destination_path} - the directory on the device, e.g. "/ROMs/_alttpr/_msu1/wa2" +### {destination_rom_name} - the ROM's name on the device, e.g. "alttp_msu.sfc" +### {destination_file} - the full path on the device, e.g. "/ROMs/_alttpr/_msu1/wa2/alttp_msu.sfc" +### {source_path} - the full path of the ROM on your computer +### {source_name} - the file name of the ROM on your computer +### The command is run by your shell (cmd.exe on Windows), so quote anything that might +### contain spaces. Windows example: +# post_upload_script: 'echo {destination_name}> "%USERPROFILE%\msu.txt"' +### ...and the same thing on unix: +# post_upload_script: 'echo "{destination_name}" > "$HOME/msu.txt"' + ### a set of rules to use to put certain ROMs in certain locations, such as your randomizer ROMs, useful for MSU1 users rom_destination_rules: # Unique name for your rule diff --git a/src/config.cpp b/src/config.cpp index 223cec5..eae4c42 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #ifdef _WIN32 #include @@ -43,6 +44,45 @@ std::vector StringSplit(const std::string& s, const std::string& de return res; } +std::string ExpandTemplate(const std::string& template_string, + const std::vector>& variables) +{ + std::string result; + size_t pos = 0; + while (pos < template_string.size()) + { + const size_t open = template_string.find('{', pos); + if (open == std::string::npos) + { + result.append(template_string, pos, std::string::npos); + break; + } + const size_t close = template_string.find('}', open); + if (close == std::string::npos) + { + result.append(template_string, pos, std::string::npos); + break; + } + result.append(template_string, pos, open - pos); + + const std::string name = template_string.substr(open + 1, close - open - 1); + auto variable = std::find_if(variables.begin(), variables.end(), + [&name](const auto& v) { return v.first == name; }); + if (variable != variables.end()) + { + result.append(variable->second); + } + else + { + // Unknown placeholder - leave it alone so it's obvious what went wrong. + std::cout << "Unknown template variable {" << name << "}" << std::endl; + result.append(template_string, open, close - open + 1); + } + pos = close + 1; + } + return result; +} + Config LoadConfig(const std::filesystem::path& path) { std::cout << "Loading config file at " << path << std::endl; @@ -54,6 +94,10 @@ Config LoadConfig(const std::filesystem::path& path) if (default_dir) { config.default_rom_directory = default_dir.as(); } + auto post_upload_script = y["post_upload_script"]; + if (post_upload_script) { + config.post_upload_script = post_upload_script.as(); + } auto r = y["rom_destination_rules"]; for (auto rule_yaml : y["rom_destination_rules"]) { diff --git a/src/send_file.cpp b/src/send_file.cpp index 716c398..461af4a 100644 --- a/src/send_file.cpp +++ b/src/send_file.cpp @@ -1,136 +1,253 @@ -#include "snfm.h" -#include "config.h" -#include -#include - -void do_error(const std::string& e) -{ - std::cout << e << std::endl; - std::cout << "(PRESS ENTER TO EXIT)" << std::endl; - std::string s; - std::getline(std::cin, s); -} - - -int main(int argc, char* argv[]) -{ - auto maybePath = FindExistingConfigFile(std::filesystem::path(argv[0]).parent_path()); - Config config; - if (maybePath) { - try { - config = LoadConfig(*maybePath); - } - catch (YAML::Exception e) - { - std::cout << "Error loading config: " << e.what() << std::endl; - return 1; - } - } - - if (argc != 2) - { - do_error("A single argument - the name of the file you're trying to put on your SNES - is required"); - return 1; - } - - std::filesystem::path rom_path = argv[1]; - std::cout << rom_path.string() << std::endl; - - std::string device_directory = config.default_rom_directory; - std::string destination_rom_name = rom_path.filename().string(); - - auto maybe_rule = config.FindRuleForRom(rom_path.filename().string()); - if (maybe_rule) - { - auto rule = maybe_rule->get(); - if (!rule.destinations.empty()) - { - RomDestination& destination = rule.destinations[0]; - if (rule.destinations.size() > 1) - { - std::cout << "Rule " << rule.name << " has multiple destinations possible, please choose the destination:" << std::endl; - - size_t longest_name = 0; - for (const auto& destination : rule.destinations) - { - longest_name = std::max(destination.config_name.size(), longest_name); - } - - const int space_count = std::to_string(rule.destinations.size() + 1).size() + 1; - for (int i = 0; i < rule.destinations.size(); i++) - { - std::cout << " " << std::right << std::setw(space_count) << std::to_string(i); - std::cout << " - " << std::left << std::setw(longest_name) << rule.destinations[i].config_name; - std::cout << " - " << rule.destinations[i].path << "/" << rule.destinations[i].rom_name << std::endl; - } - while (true) { - std::cout << "Please choose your destination (defaults to 0):" << std::endl << "> "; - std::string input; - std::getline(std::cin, input); - - if (input.empty()) - { - break; - } - int pick; - try { - pick = std::stoi(input.c_str()); - } - catch (std::exception const& ex) { - std::cout << "Not a number." << std::endl; - continue; - } - if (pick < 0 || pick > rule.destinations.size()) - { - std::cout << "Choice must be between 0 and " << std::to_string(rule.destinations.size() - 1) << std::endl; - continue; - } - destination = rule.destinations[pick]; - break; - } - - } - std::cout << "Using " << destination.config_name << std::endl; - device_directory = destination.path; - if (!destination.rom_name.empty()) { - destination_rom_name = destination.rom_name; - } - } - } - - if (device_directory.empty() || device_directory[0] != '/') - { - device_directory = "/" + device_directory; - } - - SNIConnection sni; - - auto device_filter = - [](const DevicesResponse::Device& device) -> bool - { - auto caps = device.capabilities(); - return HasCapability(device, DeviceCapability::MakeDirectory) && - HasCapability(device, DeviceCapability::PutFile) && - HasCapability(device, DeviceCapability::BootFile); - }; - std::cout << "Refreshing devices..." << std::endl; - sni.refreshDevices(device_filter); - auto uri = sni.getFirstDeviceUri(); - if (!uri) - { - do_error("Couldn't find any valid devices."); - return 1; - } - - sni.makeDirectory(*uri, device_directory, true); - - std::optional put_path = sni.putFile(*uri, rom_path, device_directory, destination_rom_name); - if (!put_path) - { - do_error("Couldn't put the file, due to some error."); - return 1; - } - sni.bootFile(*uri, *put_path); - - return 0; -} +#include "snfm.h" +#include "config.h" +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#endif + +void do_error(const std::string& e) +{ + std::cout << e << std::endl; + std::cout << "(PRESS ENTER TO EXIT)" << std::endl; + std::string s; + std::getline(std::cin, s); +} + + +// The values we substitute in come from ROM file names and the config file, so they +// can contain characters the shell treats as syntax. Escape them so that whatever is +// in them ends up as literal text rather than as extra commands. This only works if +// the placeholder is left unquoted in the config - see snfm_config_example.yaml. +std::string EscapeForShell(const std::string& value) +{ +#ifdef _WIN32 + // cmd.exe has no way to escape a quote once it's inside one, so drop quotes (and + // newlines) outright - Windows file names can't contain them anyway - and escape + // everything else cmd would act on with a caret. + std::string escaped; + for (const char c : value) + { + if (c == '"' || c == ' ' || c == ' +') + { + continue; + } + if (std::strchr("^&|<>()%", c) != nullptr) + { + escaped.push_back('^'); + } + escaped.push_back(c); + } + return escaped; +#else + // Single quotes protect everything except a single quote itself, which has to be + // closed, escaped, and reopened. + std::string escaped = "'"; + for (const char c : value) + { + if (c == ''') + { + escaped += "'\''"; + } + else + { + escaped.push_back(c); + } + } + escaped.push_back('''); + return escaped; +#endif +} + +void run_post_upload_script(const std::string& script_template, + const std::vector>& variables) +{ + std::vector> escaped_variables; + escaped_variables.reserve(variables.size()); + for (const auto& variable : variables) + { + escaped_variables.emplace_back(variable.first, EscapeForShell(variable.second)); + } + + const std::string command = ExpandTemplate(script_template, escaped_variables); + std::cout << "Running post upload script: " << command << std::endl; + + std::string to_run = command; +#ifdef _WIN32 + // cmd.exe strips the outer quotes off a command that starts with one, which + // breaks quoted script paths - give it a spare pair to chew on. + if (!to_run.empty() && to_run.front() == '"') + { + to_run = "\"" + to_run + "\""; + } +#endif + const int result = std::system(to_run.c_str()); + if (result == -1) + { + std::cout << "Couldn't run the post upload script." << std::endl; + } + else if (result != 0) + { +#ifdef _WIN32 + std::cout << "Post upload script exited with code " << result << std::endl; +#else + // system() hands back a wait status, not the exit code. + if (WIFEXITED(result)) + { + std::cout << "Post upload script exited with code " << WEXITSTATUS(result) << std::endl; + } + else if (WIFSIGNALED(result)) + { + std::cout << "Post upload script was killed by signal " << WTERMSIG(result) << std::endl; + } + else + { + std::cout << "Post upload script failed with status " << result << std::endl; + } +#endif + } +} + +int main(int argc, char* argv[]) +{ + auto maybePath = FindExistingConfigFile(std::filesystem::path(argv[0]).parent_path()); + Config config; + if (maybePath) { + try { + config = LoadConfig(*maybePath); + } + catch (YAML::Exception e) + { + std::cout << "Error loading config: " << e.what() << std::endl; + return 1; + } + } + + if (argc != 2) + { + do_error("A single argument - the name of the file you're trying to put on your SNES - is required"); + return 1; + } + + std::filesystem::path rom_path = argv[1]; + std::cout << rom_path.string() << std::endl; + + std::string device_directory = config.default_rom_directory; + std::string destination_rom_name = rom_path.filename().string(); + std::string rule_name; + std::string destination_name; + + auto maybe_rule = config.FindRuleForRom(rom_path.filename().string()); + if (maybe_rule) + { + auto rule = maybe_rule->get(); + rule_name = rule.name; + if (!rule.destinations.empty()) + { + RomDestination& destination = rule.destinations[0]; + if (rule.destinations.size() > 1) + { + std::cout << "Rule " << rule.name << " has multiple destinations possible, please choose the destination:" << std::endl; + + size_t longest_name = 0; + for (const auto& destination : rule.destinations) + { + longest_name = std::max(destination.config_name.size(), longest_name); + } + + const int space_count = std::to_string(rule.destinations.size() + 1).size() + 1; + for (int i = 0; i < rule.destinations.size(); i++) + { + std::cout << " " << std::right << std::setw(space_count) << std::to_string(i); + std::cout << " - " << std::left << std::setw(longest_name) << rule.destinations[i].config_name; + std::cout << " - " << rule.destinations[i].path << "/" << rule.destinations[i].rom_name << std::endl; + } + while (true) { + std::cout << "Please choose your destination (defaults to 0):" << std::endl << "> "; + std::string input; + std::getline(std::cin, input); + + if (input.empty()) + { + break; + } + int pick; + try { + pick = std::stoi(input.c_str()); + } + catch (std::exception const& ex) { + std::cout << "Not a number." << std::endl; + continue; + } + if (pick < 0 || pick >= static_cast(rule.destinations.size())) + { + std::cout << "Choice must be between 0 and " << std::to_string(rule.destinations.size() - 1) << std::endl; + continue; + } + destination = rule.destinations[pick]; + break; + } + + } + std::cout << "Using " << destination.config_name << std::endl; + destination_name = destination.config_name; + device_directory = destination.path; + if (!destination.rom_name.empty()) { + destination_rom_name = destination.rom_name; + } + } + } + + if (device_directory.empty() || device_directory[0] != '/') + { + device_directory = "/" + device_directory; + } + + SNIConnection sni; + + auto device_filter = + [](const DevicesResponse::Device& device) -> bool + { + auto caps = device.capabilities(); + return HasCapability(device, DeviceCapability::MakeDirectory) && + HasCapability(device, DeviceCapability::PutFile) && + HasCapability(device, DeviceCapability::BootFile); + }; + std::cout << "Refreshing devices..." << std::endl; + sni.refreshDevices(device_filter); + auto uri = sni.getFirstDeviceUri(); + if (!uri) + { + do_error("Couldn't find any valid devices."); + return 1; + } + + sni.makeDirectory(*uri, device_directory, true); + + std::optional put_path = sni.putFile(*uri, rom_path, device_directory, destination_rom_name); + if (!put_path) + { + do_error("Couldn't put the file, due to some error."); + return 1; + } + sni.bootFile(*uri, *put_path); + + if (!config.post_upload_script.empty()) + { + run_post_upload_script(config.post_upload_script, { + {"rule_name", rule_name}, + {"destination_name", destination_name}, + {"destination_path", device_directory}, + {"destination_rom_name", destination_rom_name}, + {"destination_file", put_path->generic_string()}, + {"source_path", rom_path.string()}, + {"source_name", rom_path.filename().string()}, + }); + } + + return 0; +} From e13e0b6c86ebd4da31c4eb125ff0b3dc1fd49090 Mon Sep 17 00:00:00 2001 From: DJCrabhat Date: Fri, 28 Aug 2026 14:40:11 -0700 Subject: [PATCH 2/6] Fix mangled char literals in EscapeForShell, plus script docs 3c7c8e7 was committed mid-edit and left send_file.cpp with character literals that don't compile ('' and a raw newline for '\r'/'\n', and ''' for '\''). Restore them so the shell escaping works as intended: single quotes on unix, carets with quotes dropped for cmd.exe. The rest of the review fixes (escaping the substituted values, decoding std::system's POSIX wait status, the destination picker's off-by-one) went in with that commit already. Also: - Document that placeholders should be left unquoted since values are escaped, and put the redirect first in the Windows example so a destination name ending in a digit isn't parsed by cmd.exe as a stream number. - Drop the unused protobuf::libprotoc link; only libprotobuf is needed for the generated pb.cc. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 2 +- snfm_config_example.yaml | 12 ++++++++---- src/send_file.cpp | 9 ++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6981c36..5a6f32e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ PROTOBUF_GENERATE_GRPC_CPP(PROTO_GRPC_SRCS PROTO_GRPC_HDRS sni/protos/sni/sni.pr INCLUDE_DIRECTORIES(${CMAKE_CURRENT_BINARY_DIR} include icons %) add_library(snfm_common ${PROTO_SRCS} ${PROTO_HDRS} ${PROTO_GRPC_SRCS} ${PROTO_GRPC_HDRS}) -target_link_libraries(snfm_common protobuf::libprotoc protobuf::libprotobuf gRPC::grpc++_unsecure) +target_link_libraries(snfm_common protobuf::libprotobuf gRPC::grpc++_unsecure) if(WIN32) target_compile_definitions(snfm_common PUBLIC NOMINMAX) endif() diff --git a/snfm_config_example.yaml b/snfm_config_example.yaml index df14c33..f6514c3 100644 --- a/snfm_config_example.yaml +++ b/snfm_config_example.yaml @@ -27,11 +27,15 @@ ### {destination_file} - the full path on the device, e.g. "/ROMs/_alttpr/_msu1/wa2/alttp_msu.sfc" ### {source_path} - the full path of the ROM on your computer ### {source_name} - the file name of the ROM on your computer -### The command is run by your shell (cmd.exe on Windows), so quote anything that might -### contain spaces. Windows example: -# post_upload_script: 'echo {destination_name}> "%USERPROFILE%\msu.txt"' +### The command is run by your shell (cmd.exe on Windows). The values are escaped for +### you, so leave the placeholders unquoted - whatever a ROM name or a destination +### happens to contain is then passed along as plain text instead of being run as part +### of the command. Quote the parts of the command you wrote yourself, as usual. +### Windows example - the redirect goes first so that a destination name ending in a +### digit (like "WildArms2") isn't read by cmd.exe as a stream number: +# post_upload_script: '> "%USERPROFILE%\msu.txt" echo {destination_name}' ### ...and the same thing on unix: -# post_upload_script: 'echo "{destination_name}" > "$HOME/msu.txt"' +# post_upload_script: 'echo {destination_name} > "$HOME/msu.txt"' ### a set of rules to use to put certain ROMs in certain locations, such as your randomizer ROMs, useful for MSU1 users rom_destination_rules: diff --git a/src/send_file.cpp b/src/send_file.cpp index 461af4a..43fb7a5 100644 --- a/src/send_file.cpp +++ b/src/send_file.cpp @@ -31,8 +31,7 @@ std::string EscapeForShell(const std::string& value) std::string escaped; for (const char c : value) { - if (c == '"' || c == ' ' || c == ' -') + if (c == '"' || c == '\r' || c == '\n') { continue; } @@ -49,16 +48,16 @@ std::string EscapeForShell(const std::string& value) std::string escaped = "'"; for (const char c : value) { - if (c == ''') + if (c == '\'') { - escaped += "'\''"; + escaped += "'\\''"; } else { escaped.push_back(c); } } - escaped.push_back('''); + escaped.push_back('\''); return escaped; #endif } From dc7d35d88336a10a66b05b7c1ed9428ceab8c045 Mon Sep 17 00:00:00 2001 From: DJCrabhat Date: Fri, 28 Aug 2026 14:45:11 -0700 Subject: [PATCH 3/6] Restore CRLF line endings in send_file.cpp The tooling that applied the previous fixes rewrote the file with LF, which churned all 252 lines against the CRLF the rest of the repo (and this file's own history) uses. Content is unchanged from e13e0b6. Co-Authored-By: Claude Opus 5 (1M context) --- src/send_file.cpp | 504 +++++++++++++++++++++++----------------------- 1 file changed, 252 insertions(+), 252 deletions(-) diff --git a/src/send_file.cpp b/src/send_file.cpp index 43fb7a5..b07c97f 100644 --- a/src/send_file.cpp +++ b/src/send_file.cpp @@ -1,252 +1,252 @@ -#include "snfm.h" -#include "config.h" -#include -#include -#include -#include - -#ifndef _WIN32 -#include -#endif - -void do_error(const std::string& e) -{ - std::cout << e << std::endl; - std::cout << "(PRESS ENTER TO EXIT)" << std::endl; - std::string s; - std::getline(std::cin, s); -} - - -// The values we substitute in come from ROM file names and the config file, so they -// can contain characters the shell treats as syntax. Escape them so that whatever is -// in them ends up as literal text rather than as extra commands. This only works if -// the placeholder is left unquoted in the config - see snfm_config_example.yaml. -std::string EscapeForShell(const std::string& value) -{ -#ifdef _WIN32 - // cmd.exe has no way to escape a quote once it's inside one, so drop quotes (and - // newlines) outright - Windows file names can't contain them anyway - and escape - // everything else cmd would act on with a caret. - std::string escaped; - for (const char c : value) - { - if (c == '"' || c == '\r' || c == '\n') - { - continue; - } - if (std::strchr("^&|<>()%", c) != nullptr) - { - escaped.push_back('^'); - } - escaped.push_back(c); - } - return escaped; -#else - // Single quotes protect everything except a single quote itself, which has to be - // closed, escaped, and reopened. - std::string escaped = "'"; - for (const char c : value) - { - if (c == '\'') - { - escaped += "'\\''"; - } - else - { - escaped.push_back(c); - } - } - escaped.push_back('\''); - return escaped; -#endif -} - -void run_post_upload_script(const std::string& script_template, - const std::vector>& variables) -{ - std::vector> escaped_variables; - escaped_variables.reserve(variables.size()); - for (const auto& variable : variables) - { - escaped_variables.emplace_back(variable.first, EscapeForShell(variable.second)); - } - - const std::string command = ExpandTemplate(script_template, escaped_variables); - std::cout << "Running post upload script: " << command << std::endl; - - std::string to_run = command; -#ifdef _WIN32 - // cmd.exe strips the outer quotes off a command that starts with one, which - // breaks quoted script paths - give it a spare pair to chew on. - if (!to_run.empty() && to_run.front() == '"') - { - to_run = "\"" + to_run + "\""; - } -#endif - const int result = std::system(to_run.c_str()); - if (result == -1) - { - std::cout << "Couldn't run the post upload script." << std::endl; - } - else if (result != 0) - { -#ifdef _WIN32 - std::cout << "Post upload script exited with code " << result << std::endl; -#else - // system() hands back a wait status, not the exit code. - if (WIFEXITED(result)) - { - std::cout << "Post upload script exited with code " << WEXITSTATUS(result) << std::endl; - } - else if (WIFSIGNALED(result)) - { - std::cout << "Post upload script was killed by signal " << WTERMSIG(result) << std::endl; - } - else - { - std::cout << "Post upload script failed with status " << result << std::endl; - } -#endif - } -} - -int main(int argc, char* argv[]) -{ - auto maybePath = FindExistingConfigFile(std::filesystem::path(argv[0]).parent_path()); - Config config; - if (maybePath) { - try { - config = LoadConfig(*maybePath); - } - catch (YAML::Exception e) - { - std::cout << "Error loading config: " << e.what() << std::endl; - return 1; - } - } - - if (argc != 2) - { - do_error("A single argument - the name of the file you're trying to put on your SNES - is required"); - return 1; - } - - std::filesystem::path rom_path = argv[1]; - std::cout << rom_path.string() << std::endl; - - std::string device_directory = config.default_rom_directory; - std::string destination_rom_name = rom_path.filename().string(); - std::string rule_name; - std::string destination_name; - - auto maybe_rule = config.FindRuleForRom(rom_path.filename().string()); - if (maybe_rule) - { - auto rule = maybe_rule->get(); - rule_name = rule.name; - if (!rule.destinations.empty()) - { - RomDestination& destination = rule.destinations[0]; - if (rule.destinations.size() > 1) - { - std::cout << "Rule " << rule.name << " has multiple destinations possible, please choose the destination:" << std::endl; - - size_t longest_name = 0; - for (const auto& destination : rule.destinations) - { - longest_name = std::max(destination.config_name.size(), longest_name); - } - - const int space_count = std::to_string(rule.destinations.size() + 1).size() + 1; - for (int i = 0; i < rule.destinations.size(); i++) - { - std::cout << " " << std::right << std::setw(space_count) << std::to_string(i); - std::cout << " - " << std::left << std::setw(longest_name) << rule.destinations[i].config_name; - std::cout << " - " << rule.destinations[i].path << "/" << rule.destinations[i].rom_name << std::endl; - } - while (true) { - std::cout << "Please choose your destination (defaults to 0):" << std::endl << "> "; - std::string input; - std::getline(std::cin, input); - - if (input.empty()) - { - break; - } - int pick; - try { - pick = std::stoi(input.c_str()); - } - catch (std::exception const& ex) { - std::cout << "Not a number." << std::endl; - continue; - } - if (pick < 0 || pick >= static_cast(rule.destinations.size())) - { - std::cout << "Choice must be between 0 and " << std::to_string(rule.destinations.size() - 1) << std::endl; - continue; - } - destination = rule.destinations[pick]; - break; - } - - } - std::cout << "Using " << destination.config_name << std::endl; - destination_name = destination.config_name; - device_directory = destination.path; - if (!destination.rom_name.empty()) { - destination_rom_name = destination.rom_name; - } - } - } - - if (device_directory.empty() || device_directory[0] != '/') - { - device_directory = "/" + device_directory; - } - - SNIConnection sni; - - auto device_filter = - [](const DevicesResponse::Device& device) -> bool - { - auto caps = device.capabilities(); - return HasCapability(device, DeviceCapability::MakeDirectory) && - HasCapability(device, DeviceCapability::PutFile) && - HasCapability(device, DeviceCapability::BootFile); - }; - std::cout << "Refreshing devices..." << std::endl; - sni.refreshDevices(device_filter); - auto uri = sni.getFirstDeviceUri(); - if (!uri) - { - do_error("Couldn't find any valid devices."); - return 1; - } - - sni.makeDirectory(*uri, device_directory, true); - - std::optional put_path = sni.putFile(*uri, rom_path, device_directory, destination_rom_name); - if (!put_path) - { - do_error("Couldn't put the file, due to some error."); - return 1; - } - sni.bootFile(*uri, *put_path); - - if (!config.post_upload_script.empty()) - { - run_post_upload_script(config.post_upload_script, { - {"rule_name", rule_name}, - {"destination_name", destination_name}, - {"destination_path", device_directory}, - {"destination_rom_name", destination_rom_name}, - {"destination_file", put_path->generic_string()}, - {"source_path", rom_path.string()}, - {"source_name", rom_path.filename().string()}, - }); - } - - return 0; -} +#include "snfm.h" +#include "config.h" +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#endif + +void do_error(const std::string& e) +{ + std::cout << e << std::endl; + std::cout << "(PRESS ENTER TO EXIT)" << std::endl; + std::string s; + std::getline(std::cin, s); +} + + +// The values we substitute in come from ROM file names and the config file, so they +// can contain characters the shell treats as syntax. Escape them so that whatever is +// in them ends up as literal text rather than as extra commands. This only works if +// the placeholder is left unquoted in the config - see snfm_config_example.yaml. +std::string EscapeForShell(const std::string& value) +{ +#ifdef _WIN32 + // cmd.exe has no way to escape a quote once it's inside one, so drop quotes (and + // newlines) outright - Windows file names can't contain them anyway - and escape + // everything else cmd would act on with a caret. + std::string escaped; + for (const char c : value) + { + if (c == '"' || c == '\r' || c == '\n') + { + continue; + } + if (std::strchr("^&|<>()%", c) != nullptr) + { + escaped.push_back('^'); + } + escaped.push_back(c); + } + return escaped; +#else + // Single quotes protect everything except a single quote itself, which has to be + // closed, escaped, and reopened. + std::string escaped = "'"; + for (const char c : value) + { + if (c == '\'') + { + escaped += "'\\''"; + } + else + { + escaped.push_back(c); + } + } + escaped.push_back('\''); + return escaped; +#endif +} + +void run_post_upload_script(const std::string& script_template, + const std::vector>& variables) +{ + std::vector> escaped_variables; + escaped_variables.reserve(variables.size()); + for (const auto& variable : variables) + { + escaped_variables.emplace_back(variable.first, EscapeForShell(variable.second)); + } + + const std::string command = ExpandTemplate(script_template, escaped_variables); + std::cout << "Running post upload script: " << command << std::endl; + + std::string to_run = command; +#ifdef _WIN32 + // cmd.exe strips the outer quotes off a command that starts with one, which + // breaks quoted script paths - give it a spare pair to chew on. + if (!to_run.empty() && to_run.front() == '"') + { + to_run = "\"" + to_run + "\""; + } +#endif + const int result = std::system(to_run.c_str()); + if (result == -1) + { + std::cout << "Couldn't run the post upload script." << std::endl; + } + else if (result != 0) + { +#ifdef _WIN32 + std::cout << "Post upload script exited with code " << result << std::endl; +#else + // system() hands back a wait status, not the exit code. + if (WIFEXITED(result)) + { + std::cout << "Post upload script exited with code " << WEXITSTATUS(result) << std::endl; + } + else if (WIFSIGNALED(result)) + { + std::cout << "Post upload script was killed by signal " << WTERMSIG(result) << std::endl; + } + else + { + std::cout << "Post upload script failed with status " << result << std::endl; + } +#endif + } +} + +int main(int argc, char* argv[]) +{ + auto maybePath = FindExistingConfigFile(std::filesystem::path(argv[0]).parent_path()); + Config config; + if (maybePath) { + try { + config = LoadConfig(*maybePath); + } + catch (YAML::Exception e) + { + std::cout << "Error loading config: " << e.what() << std::endl; + return 1; + } + } + + if (argc != 2) + { + do_error("A single argument - the name of the file you're trying to put on your SNES - is required"); + return 1; + } + + std::filesystem::path rom_path = argv[1]; + std::cout << rom_path.string() << std::endl; + + std::string device_directory = config.default_rom_directory; + std::string destination_rom_name = rom_path.filename().string(); + std::string rule_name; + std::string destination_name; + + auto maybe_rule = config.FindRuleForRom(rom_path.filename().string()); + if (maybe_rule) + { + auto rule = maybe_rule->get(); + rule_name = rule.name; + if (!rule.destinations.empty()) + { + RomDestination& destination = rule.destinations[0]; + if (rule.destinations.size() > 1) + { + std::cout << "Rule " << rule.name << " has multiple destinations possible, please choose the destination:" << std::endl; + + size_t longest_name = 0; + for (const auto& destination : rule.destinations) + { + longest_name = std::max(destination.config_name.size(), longest_name); + } + + const int space_count = std::to_string(rule.destinations.size() + 1).size() + 1; + for (int i = 0; i < rule.destinations.size(); i++) + { + std::cout << " " << std::right << std::setw(space_count) << std::to_string(i); + std::cout << " - " << std::left << std::setw(longest_name) << rule.destinations[i].config_name; + std::cout << " - " << rule.destinations[i].path << "/" << rule.destinations[i].rom_name << std::endl; + } + while (true) { + std::cout << "Please choose your destination (defaults to 0):" << std::endl << "> "; + std::string input; + std::getline(std::cin, input); + + if (input.empty()) + { + break; + } + int pick; + try { + pick = std::stoi(input.c_str()); + } + catch (std::exception const& ex) { + std::cout << "Not a number." << std::endl; + continue; + } + if (pick < 0 || pick >= static_cast(rule.destinations.size())) + { + std::cout << "Choice must be between 0 and " << std::to_string(rule.destinations.size() - 1) << std::endl; + continue; + } + destination = rule.destinations[pick]; + break; + } + + } + std::cout << "Using " << destination.config_name << std::endl; + destination_name = destination.config_name; + device_directory = destination.path; + if (!destination.rom_name.empty()) { + destination_rom_name = destination.rom_name; + } + } + } + + if (device_directory.empty() || device_directory[0] != '/') + { + device_directory = "/" + device_directory; + } + + SNIConnection sni; + + auto device_filter = + [](const DevicesResponse::Device& device) -> bool + { + auto caps = device.capabilities(); + return HasCapability(device, DeviceCapability::MakeDirectory) && + HasCapability(device, DeviceCapability::PutFile) && + HasCapability(device, DeviceCapability::BootFile); + }; + std::cout << "Refreshing devices..." << std::endl; + sni.refreshDevices(device_filter); + auto uri = sni.getFirstDeviceUri(); + if (!uri) + { + do_error("Couldn't find any valid devices."); + return 1; + } + + sni.makeDirectory(*uri, device_directory, true); + + std::optional put_path = sni.putFile(*uri, rom_path, device_directory, destination_rom_name); + if (!put_path) + { + do_error("Couldn't put the file, due to some error."); + return 1; + } + sni.bootFile(*uri, *put_path); + + if (!config.post_upload_script.empty()) + { + run_post_upload_script(config.post_upload_script, { + {"rule_name", rule_name}, + {"destination_name", destination_name}, + {"destination_path", device_directory}, + {"destination_rom_name", destination_rom_name}, + {"destination_file", put_path->generic_string()}, + {"source_path", rom_path.string()}, + {"source_name", rom_path.filename().string()}, + }); + } + + return 0; +} From 96fb3378067faee6e537dbddcee58d8501c00063 Mon Sep 17 00:00:00 2001 From: DJCrabhat Date: Fri, 28 Aug 2026 15:04:26 -0700 Subject: [PATCH 4/6] Move shell handling into shell.cpp The post-upload script code had accumulated in send_file.cpp, which is otherwise just the program's flow, and ExpandTemplate sat in config.cpp even though the only thing that expands templates is the script runner. Collect all three in one place: - New shell.h / shell.cpp holding ExpandTemplate, EscapeForShell and RunPostUploadScript, moved as-is. - send_file.cpp is back to do_error and main, with its include list restored to what it was before the feature plus shell.h. - config.h / config.cpp keep only the post_upload_script field and its parsing, dropping the and includes that were there for ExpandTemplate. - Name the script runner RunPostUploadScript to match the other two. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 3 +- include/config.h | 6 -- include/shell.h | 20 +++++++ src/config.cpp | 40 ------------- src/send_file.cpp | 102 +-------------------------------- src/shell.cpp | 141 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 147 deletions(-) create mode 100644 include/shell.h create mode 100644 src/shell.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a6f32e..852fd92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ PROTOBUF_GENERATE_GRPC_CPP(PROTO_GRPC_SRCS PROTO_GRPC_HDRS sni/protos/sni/sni.pr INCLUDE_DIRECTORIES(${CMAKE_CURRENT_BINARY_DIR} include icons %) add_library(snfm_common ${PROTO_SRCS} ${PROTO_HDRS} ${PROTO_GRPC_SRCS} ${PROTO_GRPC_HDRS}) -target_link_libraries(snfm_common protobuf::libprotobuf gRPC::grpc++_unsecure) +target_link_libraries(snfm_common protobuf::libprotoc protobuf::libprotobuf gRPC::grpc++_unsecure) if(WIN32) target_compile_definitions(snfm_common PUBLIC NOMINMAX) endif() @@ -102,6 +102,7 @@ if(WIN32) endif(WIN32) add_executable(send_file "src/send_file.cpp" + "src/shell.cpp" "src/snfm.cpp" "src/config.cpp") target_link_libraries(send_file PRIVATE snfm_common config) diff --git a/include/config.h b/include/config.h index aee6647..212d8f8 100644 --- a/include/config.h +++ b/include/config.h @@ -2,7 +2,6 @@ #include #include #include -#include #include struct RomDestination @@ -28,10 +27,5 @@ struct Config Config LoadConfig(const std::filesystem::path& path); -// Replaces every "{placeholder}" in template_string with the matching value from -// variables. Placeholders with no matching variable are left untouched. -std::string ExpandTemplate(const std::string& template_string, - const std::vector>& variables); - std::optional FindExistingConfigFile(const std::filesystem::path& pwd); \ No newline at end of file diff --git a/include/shell.h b/include/shell.h new file mode 100644 index 0000000..3571c89 --- /dev/null +++ b/include/shell.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +// Replaces every "{placeholder}" in template_string with the matching value from +// variables. Placeholders with no matching variable are left untouched. +std::string ExpandTemplate(const std::string& template_string, + const std::vector>& variables); + +// Escapes value so that the shell hands it to the command as literal text instead of +// acting on anything in it. This only works if the placeholder is left unquoted in +// the config - see snfm_config_example.yaml. +std::string EscapeForShell(const std::string& value); + +// Substitutes the (escaped) variables into script_template and runs the result through +// the shell, reporting anything that goes wrong on stdout. +void RunPostUploadScript(const std::string& script_template, + const std::vector>& variables); diff --git a/src/config.cpp b/src/config.cpp index eae4c42..f2fa3d6 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #ifdef _WIN32 #include @@ -44,45 +43,6 @@ std::vector StringSplit(const std::string& s, const std::string& de return res; } -std::string ExpandTemplate(const std::string& template_string, - const std::vector>& variables) -{ - std::string result; - size_t pos = 0; - while (pos < template_string.size()) - { - const size_t open = template_string.find('{', pos); - if (open == std::string::npos) - { - result.append(template_string, pos, std::string::npos); - break; - } - const size_t close = template_string.find('}', open); - if (close == std::string::npos) - { - result.append(template_string, pos, std::string::npos); - break; - } - result.append(template_string, pos, open - pos); - - const std::string name = template_string.substr(open + 1, close - open - 1); - auto variable = std::find_if(variables.begin(), variables.end(), - [&name](const auto& v) { return v.first == name; }); - if (variable != variables.end()) - { - result.append(variable->second); - } - else - { - // Unknown placeholder - leave it alone so it's obvious what went wrong. - std::cout << "Unknown template variable {" << name << "}" << std::endl; - result.append(template_string, open, close - open + 1); - } - pos = close + 1; - } - return result; -} - Config LoadConfig(const std::filesystem::path& path) { std::cout << "Loading config file at " << path << std::endl; diff --git a/src/send_file.cpp b/src/send_file.cpp index b07c97f..3dc92d8 100644 --- a/src/send_file.cpp +++ b/src/send_file.cpp @@ -1,14 +1,9 @@ #include "snfm.h" #include "config.h" +#include "shell.h" #include -#include -#include #include -#ifndef _WIN32 -#include -#endif - void do_error(const std::string& e) { std::cout << e << std::endl; @@ -18,99 +13,6 @@ void do_error(const std::string& e) } -// The values we substitute in come from ROM file names and the config file, so they -// can contain characters the shell treats as syntax. Escape them so that whatever is -// in them ends up as literal text rather than as extra commands. This only works if -// the placeholder is left unquoted in the config - see snfm_config_example.yaml. -std::string EscapeForShell(const std::string& value) -{ -#ifdef _WIN32 - // cmd.exe has no way to escape a quote once it's inside one, so drop quotes (and - // newlines) outright - Windows file names can't contain them anyway - and escape - // everything else cmd would act on with a caret. - std::string escaped; - for (const char c : value) - { - if (c == '"' || c == '\r' || c == '\n') - { - continue; - } - if (std::strchr("^&|<>()%", c) != nullptr) - { - escaped.push_back('^'); - } - escaped.push_back(c); - } - return escaped; -#else - // Single quotes protect everything except a single quote itself, which has to be - // closed, escaped, and reopened. - std::string escaped = "'"; - for (const char c : value) - { - if (c == '\'') - { - escaped += "'\\''"; - } - else - { - escaped.push_back(c); - } - } - escaped.push_back('\''); - return escaped; -#endif -} - -void run_post_upload_script(const std::string& script_template, - const std::vector>& variables) -{ - std::vector> escaped_variables; - escaped_variables.reserve(variables.size()); - for (const auto& variable : variables) - { - escaped_variables.emplace_back(variable.first, EscapeForShell(variable.second)); - } - - const std::string command = ExpandTemplate(script_template, escaped_variables); - std::cout << "Running post upload script: " << command << std::endl; - - std::string to_run = command; -#ifdef _WIN32 - // cmd.exe strips the outer quotes off a command that starts with one, which - // breaks quoted script paths - give it a spare pair to chew on. - if (!to_run.empty() && to_run.front() == '"') - { - to_run = "\"" + to_run + "\""; - } -#endif - const int result = std::system(to_run.c_str()); - if (result == -1) - { - std::cout << "Couldn't run the post upload script." << std::endl; - } - else if (result != 0) - { -#ifdef _WIN32 - std::cout << "Post upload script exited with code " << result << std::endl; -#else - // system() hands back a wait status, not the exit code. - if (WIFEXITED(result)) - { - std::cout << "Post upload script exited with code " << WEXITSTATUS(result) << std::endl; - } - else if (WIFSIGNALED(result)) - { - std::cout << "Post upload script was killed by signal " << WTERMSIG(result) << std::endl; - } - else - { - std::cout << "Post upload script failed with status " << result << std::endl; - } -#endif - } -} - int main(int argc, char* argv[]) { auto maybePath = FindExistingConfigFile(std::filesystem::path(argv[0]).parent_path()); @@ -237,7 +139,7 @@ int main(int argc, char* argv[]) if (!config.post_upload_script.empty()) { - run_post_upload_script(config.post_upload_script, { + RunPostUploadScript(config.post_upload_script, { {"rule_name", rule_name}, {"destination_name", destination_name}, {"destination_path", device_directory}, diff --git a/src/shell.cpp b/src/shell.cpp new file mode 100644 index 0000000..96f18b2 --- /dev/null +++ b/src/shell.cpp @@ -0,0 +1,141 @@ +#include "shell.h" +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#endif + +std::string ExpandTemplate(const std::string& template_string, + const std::vector>& variables) +{ + std::string result; + size_t pos = 0; + while (pos < template_string.size()) + { + const size_t open = template_string.find('{', pos); + if (open == std::string::npos) + { + result.append(template_string, pos, std::string::npos); + break; + } + const size_t close = template_string.find('}', open); + if (close == std::string::npos) + { + result.append(template_string, pos, std::string::npos); + break; + } + result.append(template_string, pos, open - pos); + + const std::string name = template_string.substr(open + 1, close - open - 1); + auto variable = std::find_if(variables.begin(), variables.end(), + [&name](const auto& v) { return v.first == name; }); + if (variable != variables.end()) + { + result.append(variable->second); + } + else + { + // Unknown placeholder - leave it alone so it's obvious what went wrong. + std::cout << "Unknown template variable {" << name << "}" << std::endl; + result.append(template_string, open, close - open + 1); + } + pos = close + 1; + } + return result; +} + +// The values we substitute in come from ROM file names and the config file, so they +// can contain characters the shell treats as syntax. Escape them so that whatever is +// in them ends up as literal text rather than as extra commands. This only works if +// the placeholder is left unquoted in the config - see snfm_config_example.yaml. +std::string EscapeForShell(const std::string& value) +{ +#ifdef _WIN32 + // cmd.exe has no way to escape a quote once it's inside one, so drop quotes (and + // newlines) outright - Windows file names can't contain them anyway - and escape + // everything else cmd would act on with a caret. + std::string escaped; + for (const char c : value) + { + if (c == '"' || c == '\r' || c == '\n') + { + continue; + } + if (std::strchr("^&|<>()%", c) != nullptr) + { + escaped.push_back('^'); + } + escaped.push_back(c); + } + return escaped; +#else + // Single quotes protect everything except a single quote itself, which has to be + // closed, escaped, and reopened. + std::string escaped = "'"; + for (const char c : value) + { + if (c == '\'') + { + escaped += "'\\''"; + } + else + { + escaped.push_back(c); + } + } + escaped.push_back('\''); + return escaped; +#endif +} + +void RunPostUploadScript(const std::string& script_template, + const std::vector>& variables) +{ + std::vector> escaped_variables; + escaped_variables.reserve(variables.size()); + for (const auto& variable : variables) + { + escaped_variables.emplace_back(variable.first, EscapeForShell(variable.second)); + } + + const std::string command = ExpandTemplate(script_template, escaped_variables); + std::cout << "Running post upload script: " << command << std::endl; + + std::string to_run = command; +#ifdef _WIN32 + // cmd.exe strips the outer quotes off a command that starts with one, which + // breaks quoted script paths - give it a spare pair to chew on. + if (!to_run.empty() && to_run.front() == '"') + { + to_run = "\"" + to_run + "\""; + } +#endif + const int result = std::system(to_run.c_str()); + if (result == -1) + { + std::cout << "Couldn't run the post upload script." << std::endl; + } + else if (result != 0) + { +#ifdef _WIN32 + std::cout << "Post upload script exited with code " << result << std::endl; +#else + // system() hands back a wait status, not the exit code. + if (WIFEXITED(result)) + { + std::cout << "Post upload script exited with code " << WEXITSTATUS(result) << std::endl; + } + else if (WIFSIGNALED(result)) + { + std::cout << "Post upload script was killed by signal " << WTERMSIG(result) << std::endl; + } + else + { + std::cout << "Post upload script failed with status " << result << std::endl; + } +#endif + } +} From 3043616dd5f4641c1fbff70cc194364bbc49eb27 Mon Sep 17 00:00:00 2001 From: DJCrabhat Date: Fri, 28 Aug 2026 15:17:06 -0700 Subject: [PATCH 5/6] Drop protobuf::libprotoc again It came back in 96fb337 by accident - the file was staged wholesale rather than re-diffed - and CMake's generate step fails on it because the target isn'\''t exported. Nothing here uses protoc's compiler APIs, only the generated pb.cc, which needs libprotobuf alone. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 852fd92..490154d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ PROTOBUF_GENERATE_GRPC_CPP(PROTO_GRPC_SRCS PROTO_GRPC_HDRS sni/protos/sni/sni.pr INCLUDE_DIRECTORIES(${CMAKE_CURRENT_BINARY_DIR} include icons %) add_library(snfm_common ${PROTO_SRCS} ${PROTO_HDRS} ${PROTO_GRPC_SRCS} ${PROTO_GRPC_HDRS}) -target_link_libraries(snfm_common protobuf::libprotoc protobuf::libprotobuf gRPC::grpc++_unsecure) +target_link_libraries(snfm_common protobuf::libprotobuf gRPC::grpc++_unsecure) if(WIN32) target_compile_definitions(snfm_common PUBLIC NOMINMAX) endif() From 8cc2f34cf2263666ba057b07a790d6211af62220 Mon Sep 17 00:00:00 2001 From: DJCrabhat Date: Fri, 28 Aug 2026 15:19:50 -0700 Subject: [PATCH 6/6] Put protobuf::libprotoc back Reverts 3043616 and the same change in e13e0b6. The removal was a local workaround that was sitting uncommitted in the working tree when the review ran, and I should not have folded it into a commit. Whether this link stays is the maintainer call, so main goes back to what it was. Note that a fresh configure fails on this target with a current vcpkg; CI pins vcpkg at 5bf0c55, where it still exists. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 490154d..852fd92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ PROTOBUF_GENERATE_GRPC_CPP(PROTO_GRPC_SRCS PROTO_GRPC_HDRS sni/protos/sni/sni.pr INCLUDE_DIRECTORIES(${CMAKE_CURRENT_BINARY_DIR} include icons %) add_library(snfm_common ${PROTO_SRCS} ${PROTO_HDRS} ${PROTO_GRPC_SRCS} ${PROTO_GRPC_HDRS}) -target_link_libraries(snfm_common protobuf::libprotobuf gRPC::grpc++_unsecure) +target_link_libraries(snfm_common protobuf::libprotoc protobuf::libprotobuf gRPC::grpc++_unsecure) if(WIN32) target_compile_definitions(snfm_common PUBLIC NOMINMAX) endif()