diff --git a/CMakeLists.txt b/CMakeLists.txt index 6981c36..852fd92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 8192cb4..212d8f8 100644 --- a/include/config.h +++ b/include/config.h @@ -19,6 +19,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); 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/snfm_config_example.yaml b/snfm_config_example.yaml index abf3cda..f6514c3 100644 --- a/snfm_config_example.yaml +++ b/snfm_config_example.yaml @@ -17,6 +17,26 @@ ### 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). 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"' + ### 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..f2fa3d6 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -54,6 +54,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..3dc92d8 100644 --- a/src/send_file.cpp +++ b/src/send_file.cpp @@ -1,5 +1,6 @@ #include "snfm.h" #include "config.h" +#include "shell.h" #include #include @@ -38,11 +39,14 @@ int main(int argc, char* argv[]) 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]; @@ -80,7 +84,7 @@ int main(int argc, char* argv[]) std::cout << "Not a number." << std::endl; continue; } - if (pick < 0 || pick > rule.destinations.size()) + 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; @@ -91,6 +95,7 @@ int main(int argc, char* argv[]) } 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; @@ -132,5 +137,18 @@ int main(int argc, char* argv[]) } sni.bootFile(*uri, *put_path); + if (!config.post_upload_script.empty()) + { + RunPostUploadScript(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; } 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 + } +}