diff --git a/.vscode/settings.json b/.vscode/settings.json index e6e4f3a..f3760f2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -103,7 +103,8 @@ "locale": "cpp", "stdfloat": "cpp", "text_encoding": "cpp", - "forward_list": "cpp" + "forward_list": "cpp", + "*.bak": "cpp" }, "cSpell.words": [ "Alsa", diff --git a/PiPedalCommon/src/HtmlHelper.cpp b/PiPedalCommon/src/HtmlHelper.cpp index 5aa26dd..8d618a7 100644 --- a/PiPedalCommon/src/HtmlHelper.cpp +++ b/PiPedalCommon/src/HtmlHelper.cpp @@ -394,6 +394,59 @@ std::string HtmlHelper::generateEtag(const std::filesystem::path &path) static std::map httpErrorStrings = { {200,"OK"}, + {201, "Created"}, + {202, "Accepted"}, + {203, "Non-Authoritative Information"}, + {204, "No Content"}, + {205, "Reset Content"}, + {206, "Partial Content"}, + {300, "Multiple Choices"}, + {301, "Moved Permanently"}, + {302, "Found"}, + {303, "See Other"}, + {304, "Not Modified"}, + {305, "Use Proxy"}, + {307, "Temporary Redirect"}, + {308, "Permanent Redirect"}, + {400, "Bad Request"}, + {401, "Unauthorized"}, + {402, "Payment Required"}, + {403, "Forbidden"}, + {404, "Not Found"}, + {405, "Method Not Allowed"}, + {406, "Not Acceptable"}, + {407, "Proxy Authentication Required"}, + {408, "Request Timeout"}, + {409, "Conflict"}, + {410, "Gone"}, + {411, "Length Required"}, + {412, "Precondition Failed"}, + {413, "Payload Too Large"}, + {414, "URI Too Long"}, + {415, "Unsupported Media Type"}, + {416, "Range Not Satisfiable"}, + {417, "Expectation Failed"}, + {418, "I'm a teapot"}, + {421, "Misdirected Request"}, + {422, "Unprocessable Content"}, + {423, "Locked"}, + {424, "Failed Dependency"}, + {426, "Upgrade Required"}, + {428, "Precondition Required"}, + {429, "Too Many Requests"}, + {431, "Request Header Fields Too Large"}, + {451, "Unavailable For Legal Reasons"}, + {500, "Internal Server Error"}, + {501, "Not Implemented"}, + {502, "Bad Gateway"}, + {503, "Service Unavailable"}, + {504, "Gateway Timeout"}, + {505, "HTTP Version Not Supported"}, + {506, "Variant Also Negotiates"}, + {507, "Insufficient Storage"}, + {508, "Loop Detected"}, + {510, "Not Extended"}, + {511, "Network Authentication Required"} }; @@ -405,6 +458,6 @@ std::string HtmlHelper::httpErrorString(int errorCode) { return SS(errorCode << " " << ff->second); } - return SS(errorCode); + return SS(errorCode << " Unknown error"); } diff --git a/PiPedalCommon/src/include/util.hpp b/PiPedalCommon/src/include/util.hpp index 1a6183d..fa84535 100644 --- a/PiPedalCommon/src/include/util.hpp +++ b/PiPedalCommon/src/include/util.hpp @@ -124,7 +124,9 @@ namespace pipedal std::string ToLower(const std::string&value); - std::string safeFilenameToString(const std::string &filename); - std::string stringToSafeFilename(const std::string &name); + std::string SafeFilenameToString(const std::string &filename); + std::string StringToSafeFilename(const std::string &name); + + std::string ShellEscape(const std::string &input); } diff --git a/PiPedalCommon/src/util.cpp b/PiPedalCommon/src/util.cpp index 7b1f89e..16580f5 100644 --- a/PiPedalCommon/src/util.cpp +++ b/PiPedalCommon/src/util.cpp @@ -243,13 +243,13 @@ bool pipedal::HasWritePermissions(const std::filesystem::path &path) return access(path.c_str(), W_OK) == 0; } -const std::string SF_SPECIAL_CHARS = " <>@;:\"\'/[]?=+%."; +const std::string SAFE_FILENAME_SPECIAL_CHARS = "<>@;:\"\'/[]?=+%.&|*^~`#"; static std::array makeSfBits() { std::array result; - for (char c : SF_SPECIAL_CHARS) + for (char c : SAFE_FILENAME_SPECIAL_CHARS) { result[(unsigned char)c] = true; } @@ -257,6 +257,10 @@ static std::array makeSfBits() { result[i] = true; } + for (size_t i = 0; i < 0x20; ++i) + { + result[i] = true; + } return result; } @@ -285,7 +289,7 @@ static char fromHexChars(char c1, char c2) return (char)uc; } -std::string pipedal::safeFilenameToString(const std::string &filename) +std::string pipedal::SafeFilenameToString(const std::string &filename) { size_t ix = 0; std::ostringstream os; @@ -315,7 +319,7 @@ std::string pipedal::safeFilenameToString(const std::string &filename) } return os.str(); } -std::string pipedal::stringToSafeFilename(const std::string &name) +std::string pipedal::StringToSafeFilename(const std::string &name) { std::ostringstream os; for (char c : name) @@ -338,3 +342,24 @@ std::string pipedal::stringToSafeFilename(const std::string &name) } return os.str(); } + +std::string pipedal::ShellEscape(const std::string &input) +{ + // Escape string for safe use in shell commands by using single quotes + // and escaping any single quotes in the input + std::string result = "'"; + for (char c : input) + { + if (c == '\'') + { + // End quote, add escaped single quote, start quote again + result += "'\\''"; + } + else + { + result += c; + } + } + result += "'"; + return result; +} diff --git a/src/AudioHost.hpp b/src/AudioHost.hpp index f582547..4fd6fa5 100644 --- a/src/AudioHost.hpp +++ b/src/AudioHost.hpp @@ -157,7 +157,7 @@ namespace pipedal { public: virtual void OnNotifyLv2StateChanged(uint64_t instanceId) = 0; - virtual void OnNotifyMaybeLv2StateChanged(uint64_t instanceId) = 0; + virtual bool OnNotifyMaybeLv2StateChanged(uint64_t instanceId) = 0; virtual void OnNotifyVusSubscription(const std::vector &updates) = 0; virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) = 0; virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) = 0; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 19f7ce4..c417232 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -204,6 +204,8 @@ else() endif() set (PIPEDAL_SOURCES + Curl.cpp Curl.hpp + Tone3000DownloadType.cpp Tone3000DownloadType.hpp Tone3000DownloadProgress.cpp Tone3000DownloadProgress.hpp Tone3000Download.cpp Tone3000Download.hpp Tone3000Downloader.cpp Tone3000Downloader.hpp diff --git a/src/Curl.cpp b/src/Curl.cpp new file mode 100644 index 0000000..82f3ecb --- /dev/null +++ b/src/Curl.cpp @@ -0,0 +1,706 @@ +/* + * Copyright (c) 2026 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include "Curl.hpp" +#include "ss.hpp" +#include "TemporaryFile.hpp" +#include +#include "SysExec.hpp" +#include +#include "util.hpp" +#include +#include +#include +#include +#include +#include +#include "Finally.hpp" +#include "Lv2Log.hpp" + +static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"}; + +static bool enableLogging = true; + + +namespace pipedal +{ + + static void logHeaders(const std::vector&headers) + { + if (enableLogging) + { + std::ofstream os {"/tmp/PipedalCurl.log"}; + for (const auto&header: headers) + { + os << header << "\n"; + } + } + } + static std::string getCurlExitCodeString(int exitCode) + { + // Translate documented curl exit codes to strings. + // https://curl.se/libcurl/c/libcurl-errors.html + switch (exitCode) + { + case 0: return "OK"; // CURLE_OK - All fine + case 1: return "Unsupported protocol."; + case 2: return "Failed to initialize."; + case 3: return "URL malformed."; + case 4: return "Feature not built-in."; + case 5: return "Couldn't resolve proxy."; + case 6: return "Couldn't resolve host."; + case 7: return "Failed to connect to host."; + case 8: return "Weird server reply."; + case 9: return "Access denied to remote resource."; + case 10: return "FTP accept failed."; + case 11: return "FTP weird PASS reply."; + case 12: return "FTP accept timeout."; + case 13: return "FTP weird PASV reply."; + case 14: return "FTP weird 227 format."; + case 15: return "FTP can't get host."; + case 16: return "HTTP/2 error."; + case 17: return "FTP couldn't set type."; + case 18: return "Partial file transfer."; + case 19: return "FTP couldn't retrieve file."; + case 21: return "FTP quote error."; + case 22: return "HTTP page not retrieved."; + case 23: return "Write error."; + case 25: return "Upload failed."; + case 26: return "Read error."; + case 27: return "Out of memory."; + case 28: return "Operation timeout."; + case 30: return "FTP PORT failed."; + case 31: return "FTP couldn't use REST."; + case 33: return "Range error."; + case 35: return "SSL connect error."; + case 36: return "Bad download resume."; + case 37: return "FILE couldn't read file."; + case 38: return "LDAP cannot bind."; + case 39: return "LDAP search failed."; + case 42: return "Aborted by callback."; + case 43: return "Bad function argument."; + case 45: return "Interface failed."; + case 47: return "Too many redirects."; + case 48: return "Unknown option."; + case 49: return "Setopt option syntax error."; + case 52: return "Got nothing from server."; + case 53: return "SSL engine not found."; + case 54: return "SSL engine set failed."; + case 55: return "Failed sending network data."; + case 56: return "Failure receiving network data."; + case 58: return "Problem with local client certificate."; + case 59: return "Couldn't use specified SSL cipher."; + case 60: return "Peer SSL certificate or SSH fingerprint verification failed."; + case 61: return "Unrecognized transfer encoding."; + case 63: return "Maximum file size exceeded."; + case 64: return "Requested FTP SSL level failed."; + case 65: return "Send failed to rewind."; + case 66: return "SSL engine initialization failed."; + case 67: return "Login denied."; + case 68: return "TFTP file not found."; + case 69: return "TFTP permission problem."; + case 70: return "Remote disk full."; + case 71: return "TFTP illegal operation."; + case 72: return "TFTP unknown transfer ID."; + case 73: return "Remote file already exists."; + case 74: return "TFTP no such user."; + case 77: return "Problem reading SSL CA cert."; + case 78: return "Remote file not found."; + case 79: return "SSH error."; + case 80: return "SSL shutdown failed."; + case 82: return "Failed to load CRL file."; + case 83: return "SSL issuer check failed."; + case 84: return "FTP PRET command failed."; + case 85: return "RTSP CSeq mismatch."; + case 86: return "RTSP session ID mismatch."; + case 87: return "Unable to parse FTP file list."; + case 88: return "Chunk callback error."; + case 89: return "No connection available."; + case 90: return "SSL pinned public key mismatch."; + case 91: return "SSL invalid certificate status."; + case 92: return "HTTP/2 stream error."; + case 93: return "Recursive API call."; + case 94: return "Authentication error."; + case 95: return "HTTP/3 error."; + case 96: return "QUIC connection error."; + case 97: return "Proxy handshake error."; + case 98: return "SSL client certificate required."; + case 99: return "Unrecoverable poll error."; + case 100: return "Value or data field too large."; + case 101: return "ECH failed."; + default: + return SS("Failed with exit code " << exitCode << "."); + } + } + + static void throwCurlExitCode(int exitCode) + { + if (exitCode == 0) return; + std::string message = getCurlExitCodeString(exitCode); + throw std::runtime_error(SS("Server download failed. " << message)); + } + + static int checkCurlHttpResponse(const std::vector &headers) + { + if (headers.size() == 0) + { + throw std::runtime_error("Download failed. Invalid curl response: no headers."); + } + + const std::string&httpResponse = headers[0]; + // verify that the string starts with HTTP/, and then parse the numeric error code in the result, throwing a std::runtime_error if it doesn't parse. + // e.g. "HTTP/2 200" + if (!httpResponse.starts_with("HTTP/")) + { + throw std::runtime_error(SS("Download failed. Invalid curl response: " << httpResponse)); + } + + size_t codeStart = httpResponse.find(' '); + if (codeStart == std::string::npos) + { + throw std::runtime_error(SS("Download failed. Invalid curl response: " << httpResponse)); + } + ++codeStart; + + size_t codeEnd = httpResponse.find(' ', codeStart); + if (codeEnd == std::string::npos) + { + codeEnd = httpResponse.length(); + } + + int statusCode = 0; + try + { + statusCode = std::stoi(httpResponse.substr(codeStart, codeEnd - codeStart)); + } + catch (const std::exception &) + { + throw std::runtime_error(SS("Download failed. Invalid curl response: " << httpResponse)); + } + + return statusCode; + } + + + int CurlGet( + const std::string &url, + std::vector &output, + std::vector *headersOpt + ) { + TemporaryFile tempFile { WEB_TEMP_DIR}; + int rc = CurlGet(url,tempFile.Path(), headersOpt); + if (rc == 200) + { + std::ifstream inputStream(tempFile.Path(), std::ios::binary); + if (!inputStream) + { + throw std::runtime_error("Failed to open download file."); + } + inputStream.seekg(0, std::ios::end); + std::streamsize size = inputStream.tellg(); + inputStream.seekg(0, std::ios::beg); + output.resize(static_cast(size)); + if (size > 0) + { + inputStream.read(reinterpret_cast(output.data()), size); + } + } + return rc; + + } + int CurlGet( + const std::string&url, + std::vector &output, + std::vector*headersOpt + ) + { + TemporaryFile tempFile {WEB_TEMP_DIR}; + int rc = CurlGet(url,tempFile.Path(), headersOpt); + + std::ifstream inputStream(tempFile.Path(), std::ios::binary); + if (!inputStream) + { + throw std::runtime_error("Failed to open download file."); + } + output.clear(); + std::string line; + while (std::getline(inputStream, line)) + { + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + output.push_back(line); + } + return rc; + + } + + + int CurlGet( + const std::string &url, + const std::filesystem::path&outputPath, + std::vector *headersOpt + ) + { + + TemporaryFile headersFile { WEB_TEMP_DIR}; + + std::vector defaultHeaders; + if (headersOpt == nullptr) + { + headersOpt = &defaultHeaders; + } + + bool bResult = true; + + std::string args = SS("-s -L -D " << ShellEscape(headersFile.Path().c_str()) << " " << ShellEscape(url) << " -o " << ShellEscape(outputPath.c_str()) ) ; + auto curlOutput = sysExecForOutput("/usr/bin/curl", args); + + if (headersOpt != nullptr) + { + std::ifstream headersStream(headersFile.Path()); + if (headersStream) + { + headersOpt->clear(); + std::string line; + while (std::getline(headersStream, line)) + { + // Remove trailing carriage return if present + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + if (!line.empty()) + { + headersOpt->push_back(line); + } + } + } + } + + logHeaders(*headersOpt); + if (curlOutput.exitCode == 512) + { + throw std::runtime_error("Invalid curl arguments."); + } + if (curlOutput.exitCode != EXIT_SUCCESS && curlOutput.exitCode != 22 * 256) + { + if (WIFEXITED(curlOutput.exitCode)) + { + auto exitCode = WEXITSTATUS(curlOutput.exitCode); + throwCurlExitCode(exitCode); + return -99; + } + else if (WIFSIGNALED(curlOutput.exitCode)) + { + throw std::runtime_error("No internet access."); + } + else + { + throw std::runtime_error(SS("Unable to exec curl. (exit status = " << curlOutput.exitCode << ")")); + } + } + + + int errorCode = checkCurlHttpResponse(*headersOpt); + + + return errorCode; + } + + + static int curlExec( + const std::string&commandPath, // path to executable. + const std::vector &arguments, // commandline arguments. + const std::function &onStdoutLine, + std::string&stdErrOutput + ) + { + // Unexpected errors throw std::runtime_error. + + // create linux socket pairs for stdout and stderr. + int stdoutPipe[2] = {-1,-1}; + int stderrPipe[2] = {-1,-1}; + + Finally ffPipes { + [&stdoutPipe, &stderrPipe]() + { + if (stdoutPipe[0] != -1) close(stdoutPipe[0]); + if (stdoutPipe[1] != -1) close(stdoutPipe[1]); + if (stderrPipe[0] != -1) close(stdoutPipe[0]); + if (stderrPipe[1] != -1) close(stdoutPipe[1]); + } + }; + + if (socketpair(AF_UNIX, SOCK_STREAM, 0, stdoutPipe) == -1) + { + throw std::runtime_error("Failed to create stdout socket pair"); + } + if (socketpair(AF_UNIX, SOCK_STREAM, 0, stderrPipe) == -1) + { + throw std::runtime_error("Failed to create stderr socket pair"); + } + + // exec the command with the supplied arguments + pid_t pid = fork(); + if (pid == -1) + { + throw std::runtime_error("Failed to fork process"); + } + + if (pid == 0) + { + // Child process + close(stdoutPipe[0]); // Close read end + close(stderrPipe[0]); // Close read end + + // Redirect stdout and stderr + dup2(stdoutPipe[1], STDOUT_FILENO); + dup2(stderrPipe[1], STDERR_FILENO); + + close(stdoutPipe[1]); + close(stderrPipe[1]); + + + // stdin to /dev/null. + int devNull = open("/dev/null", O_RDONLY); + if (devNull == -1) + { + std::cerr << "Failed to open /dev/null" << std::endl; + exit(EXIT_FAILURE); + } + if (dup2(devNull, STDIN_FILENO) == -1) + { + std::cerr << "Failed to redirect stdin" << std::endl; + close(devNull); + exit(EXIT_FAILURE); + } + close(devNull); + + + // Build argv array + std::vector argv; + argv.push_back(const_cast(commandPath.c_str())); + for (const auto& arg : arguments) + { + argv.push_back(const_cast(arg.c_str())); + } + argv.push_back(nullptr); + + execv(commandPath.c_str(), argv.data()); + + // If execv returns, it failed + std::cerr << "Failed to execute: " << commandPath << std::endl; + exit(EXIT_FAILURE); + } + + // Parent process + close(stdoutPipe[1]); // Close write end + stdoutPipe[1] = -1; + close(stderrPipe[1]); // Close write end + stderrPipe[1] = -1; + + // Read from stderr and stdout. + // stderr output gets appended to the stdErrOutput argument. + // stdin output gets read line by line, and onStdOutLine is called for each line of text that's read. + + // Set non-blocking mode for both pipes + fcntl(stdoutPipe[0], F_SETFL, O_NONBLOCK); + fcntl(stderrPipe[0], F_SETFL, O_NONBLOCK); + + std::string stdoutBuffer; + std::string stderrBuffer; + + bool stdoutOpen = true; + bool stderrOpen = true; + + while (stdoutOpen || stderrOpen) + { + fd_set readfds; + FD_ZERO(&readfds); + int maxfd = -1; + + if (stdoutOpen) + { + FD_SET(stdoutPipe[0], &readfds); + maxfd = std::max(maxfd, stdoutPipe[0]); + } + if (stderrOpen) + { + FD_SET(stderrPipe[0], &readfds); + maxfd = std::max(maxfd, stderrPipe[0]); + } + + struct timeval timeout; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + + int ret = select(maxfd + 1, &readfds, nullptr, nullptr, &timeout); + + if (ret > 0) + { + // Read from stdout + if (stdoutOpen && FD_ISSET(stdoutPipe[0], &readfds)) + { + char buffer[4096]; + ssize_t n = read(stdoutPipe[0], buffer, sizeof(buffer)); + if (n > 0) + { + stdoutBuffer.append(buffer, n); + + // Process complete lines + size_t pos; + while ((pos = stdoutBuffer.find('\n')) != std::string::npos) + { + std::string line = stdoutBuffer.substr(0, pos); + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + if (!onStdoutLine(line)) + { + // Kill the child process and return immediately + // do NOT waitpid, as that will be done in the processing loop. + kill(pid, SIGTERM); + } + stdoutBuffer.erase(0, pos + 1); + } + } + else if (n == 0) + { + stdoutOpen = false; + close(stdoutPipe[0]); + + // Process any remaining data + if (!stdoutBuffer.empty()) + { + if (!stdoutBuffer.empty() && stdoutBuffer.back() == '\r') + { + stdoutBuffer.pop_back(); + } + if (!stdoutBuffer.empty()) + { + if (!onStdoutLine(stdoutBuffer)) + { + // abort the child process + kill(pid, SIGINT); // Curl: SIGINT= graceful shutdown. SIGTERM=abrupt shutdown. + // but wait for output conforming the cancellation + // i.e. don't waitpid -- that will be done on cleanup, and don't abort the loop. + } + } + } + } + } + + // Read from stderr + if (stderrOpen && FD_ISSET(stderrPipe[0], &readfds)) + { + char buffer[4096]; + ssize_t n = read(stderrPipe[0], buffer, sizeof(buffer)); + if (n > 0) + { + stdErrOutput.append(buffer, n); + } + else if (n == 0) + { + stderrOpen = false; + close(stderrPipe[0]); + } + } + } + } + + // Return the procesess's exit code. + int status; + waitpid(pid, &status, 0); + + if (WIFEXITED(status)) + { + auto exitCode = WEXITSTATUS(status); + if (exitCode == EXIT_SUCCESS) { + return EXIT_SUCCESS; + } + throwCurlExitCode(exitCode); + return exitCode; + } + else if (WIFSIGNALED(status)) + { + return -1; + } + + return -2; + } + int CurlGet( + const std::vector &request, + const std::function &progressCallback, + std::vector*headersOpt + ) + { + if (request.empty()) + { + return 200; + } + + size_t currentDownload = 0; + size_t totalDownloads = request.size(); + int lastStatusCode = 200; + + // Create temporary file for curl config + TemporaryFile configFile{WEB_TEMP_DIR}; + + // Write curl config file with URL and output pairs + { + std::ofstream configStream(configFile.Path()); + if (!configStream) + { + throw std::runtime_error("Failed to create curl config file"); + } + + for (const auto& req : request) + { + configStream << "url = \"" << req.url << "\"\n"; + configStream << "output = \"" << req.outputFile.string() << "\"\n"; + } + } + + // Build curl arguments + std::vector curlArgs; + curlArgs.push_back("-s"); // Silent mode + curlArgs.push_back("-L"); // Follow redirects + // curlArgs.push_back("-w"); // Write format + // curlArgs.push_back("URL: %{url_effective}\\n"); + curlArgs.push_back("--parallel-max"); + curlArgs.push_back("1"); + curlArgs.push_back("-D"); // Dump headers to stdout + curlArgs.push_back("-"); // Write headers to stdout + curlArgs.push_back("--no-progress-meter"); // Suppress progress + curlArgs.push_back("--retry-delay"); + curlArgs.push_back("2"); + curlArgs.push_back("--retry-max-time"); + curlArgs.push_back("6"); + curlArgs.push_back("-K"); + curlArgs.push_back(configFile.Path().string()); + curlArgs.push_back("--fail-early"); // quit as soon as one transfer fails. + + std::string stderrOutput; + + std::string savedHttpHeader; + + // Process stdout lines + auto onStdoutLine = [&](const std::string& line) -> bool { + if (headersOpt != nullptr) + { + headersOpt->push_back(line); + } + // Empty line indicates start of new download or end of headers + if (line.empty()) + { + if (!savedHttpHeader.empty()) + { + std::string lastHeader = savedHttpHeader; + savedHttpHeader = ""; + // Parse status code + size_t codeStart = lastHeader.find(' '); + if (codeStart != std::string::npos) + { + codeStart++; // Skip the space + size_t codeEnd = lastHeader.find(' ', codeStart); + if (codeEnd == std::string::npos) + { + codeEnd = lastHeader.length(); + } + + std::string codeStr = lastHeader.substr(codeStart, codeEnd - codeStart); + try + { + int statusCode = std::stoi(codeStr); + + // Handle different status codes + if (statusCode == 200) + { + // Successful download + currentDownload++; + lastStatusCode = 200; + if (progressCallback) + { + progressCallback(currentDownload, totalDownloads); + } + } + else if (statusCode == 429) + { + // Retry - curl will handle it, just ignore + return true; + } + else if (statusCode == 503) + { + // Throttling - save position and expect exit + lastStatusCode = 503; + Lv2Log::warning("%s","curl: Received 503 response."); + return false; + } + else if (statusCode >= 300 && statusCode < 400) + { + // Redirect, &c - advisory only, ignore, and hope curl will deal with it. + return true; + } + else if (statusCode >= 400) + { + // Error - fatal + lastStatusCode = statusCode; + return false; // Stop processing + } + } + catch (const std::exception&) + { + // Failed to parse status code - continue + return true; + } + } + } + } else if (line.starts_with("HTTP/")) + { + // Delay processing until we get a blank line. + savedHttpHeader = line; + } + // Other lines are headers - ignore unless we need to save them + return true; + }; + + // Execute curl + int exitCode = curlExec("/usr/bin/curl", curlArgs, onStdoutLine, stderrOutput); + + logHeaders(*headersOpt); + + if (exitCode != EXIT_SUCCESS && exitCode != -1) + { + throwCurlExitCode(exitCode); + } + + // Return appropriate status code + + return lastStatusCode; + } + +} + diff --git a/src/Curl.hpp b/src/Curl.hpp new file mode 100644 index 0000000..a1aec8e --- /dev/null +++ b/src/Curl.hpp @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace pipedal { + + extern int CurlGet( + const std::string &url, + const std::filesystem::path&outputFile, + std::vector *headersOpt = nullptr + ); + extern int CurlGet( + const std::string &url, + std::vector &output, + std::vector *headersOpt = nullptr + ); + extern int CurlGet( + const std::string&url, + std::vector &output, + std::vector *headersOpt = nullptr + ); + + struct CurlDownloadRequest{ + std::string url; + std::filesystem::path outputFile; + }; + extern int CurlGet( + const std::vector &request, + const std::function &progressCallback, + std::vector*headersOpt + ); + +} \ No newline at end of file diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 008f6e9..957fab6 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -234,6 +234,7 @@ void PiPedalModel::Init(const PiPedalConfiguration &configuration) int64_t PiPedalModel::DownloadModelsFromTone3000( int64_t clientId, + Tone3000DownloadType downloadType, const std::string &downloadPath, const std::string &tone3000Url) { @@ -261,6 +262,7 @@ int64_t PiPedalModel::DownloadModelsFromTone3000( tone3000Downloader->SetListener(this); } return tone3000Downloader->RequestDownload( + downloadType, downloadPath, tone3000Url ); @@ -508,11 +510,10 @@ void PiPedalModel::OnNotifyLv2StateChanged(uint64_t instanceId) { // a sent PATCH_Set, or an explicit state changed notification. OnNotifyMaybeLv2StateChanged(instanceId); - this->SetPresetChanged(-1, true, false); } // The plugin notified us that a path path property changed. The state *purrobably changed. -void PiPedalModel::OnNotifyMaybeLv2StateChanged(uint64_t instanceId) +bool PiPedalModel::OnNotifyMaybeLv2StateChanged(uint64_t instanceId) { // one or more received PATCH_Sets, which MAY change the state. std::lock_guard lock(mutex); @@ -521,7 +522,7 @@ void PiPedalModel::OnNotifyMaybeLv2StateChanged(uint64_t instanceId) { if (!audioHost) { - return; + return false; } bool changed = this->audioHost->UpdatePluginState(*item); if (changed) @@ -532,8 +533,11 @@ void PiPedalModel::OnNotifyMaybeLv2StateChanged(uint64_t instanceId) Lv2PluginState newState = item->lv2State(); FireLv2StateChanged(instanceId, newState); + this->SetPresetChanged(-1, true, false); + return true; } } + return false; } void PiPedalModel::SetInputVolume(float value) @@ -3403,3 +3407,8 @@ int64_t PiPedalModel::CopyPresetsToBank(int64_t bankInstanceId, const std::vecto return lastAdded; } + +std::string PiPedalModel::Tone3000ThumbnailDirectory() +{ + return "/var/pipedal/tone3000_thumbnails"; +} diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index c6d297e..02a0c2d 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -125,6 +125,7 @@ namespace pipedal virtual void OnTone3000DownloadComplete(int64_t handle, const std::string &resultPath) override; virtual void OnTone3000DownloadError(int64_t handle, const std::string &errorMessage) override; + std::shared_ptr tone3000Downloader; void CancelAudioRetry(); clock::time_point lastRestartTime = clock::time_point::min(); @@ -246,7 +247,7 @@ namespace pipedal private: // IAudioHostCallbacks virtual void OnNotifyLv2StateChanged(uint64_t instanceId) override; - virtual void OnNotifyMaybeLv2StateChanged(uint64_t instanceId) override; + virtual bool OnNotifyMaybeLv2StateChanged(uint64_t instanceId) override; virtual void OnNotifyVusSubscription(const std::vector &updates) override; virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) override; virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) override; @@ -291,7 +292,13 @@ namespace pipedal Decrease }; + + Storage &GetStorage() { return storage; } + + virtual std::string Tone3000ThumbnailDirectory() override; + + bool GetHasWifi(); void NextBank(Direction direction = Direction::Increase); @@ -301,6 +308,7 @@ namespace pipedal int64_t DownloadModelsFromTone3000( int64_t clientId, + Tone3000DownloadType downloadType, const std::string &downloadPath, const std::string &tone3000Url); diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index 992f57e..d52beed 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -19,6 +19,7 @@ #include "pch.h" +#include "Curl.hpp" #include "PiPedalSocket.hpp" #include "Updater.hpp" #include "json.hpp" @@ -51,11 +52,13 @@ class DownloadModelsFromTone3000Body { public: std::string downloadPath_; std::string tone3000Url_; + std::string downloadType_; DECLARE_JSON_MAP(DownloadModelsFromTone3000Body); }; JSON_MAP_BEGIN(DownloadModelsFromTone3000Body) JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, downloadPath) JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, tone3000Url) +JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, downloadType) JSON_MAP_END() @@ -693,6 +696,8 @@ private: int64_t subscriptionHandle; int64_t instanceId; }; + + bool PingTone3000Server(); std::vector activeVuSubscriptions; struct PortMonitorSubscription @@ -1904,6 +1909,7 @@ public: pReader->read(&args); auto result = this->model.DownloadModelsFromTone3000( this->clientId, + StringToTone3000DownloadType(args.downloadType_), args.downloadPath_, args.tone3000Url_ ); @@ -1913,6 +1919,13 @@ public: int64_t handle = -1; pReader->read(&handle); model.CancelTone3000Download(clientId,handle); + } else if (message == "pingTone3000Server") + { + std::shared_ptr this_ = shared_from_this(); + model.Post([this_, replyTo] () { + bool result = this_->PingTone3000Server(); + this_->Reply(replyTo,"pingTone3000Server",result); + }); } else { @@ -2451,3 +2464,19 @@ std::shared_ptr pipedal::MakePiPedalSocketFactory(PiPedalModel & { return std::make_shared(model); } + + +bool PiPedalSocketHandler::PingTone3000Server() +{ + constexpr const char* TONE3000_PING_URL = "https://www.tone3000.com/robots.txt"; + std::vector output; + std::vector headers; + try { + int result = CurlGet(TONE3000_PING_URL, output, &headers); + return result == 200; + } catch(const std::exception&e) + { + Lv2Log::error("PingTone3000Server: %s",e.what()); + return false; + } +} \ No newline at end of file diff --git a/src/Storage.cpp b/src/Storage.cpp index 2083059..5c90d68 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -2135,13 +2135,13 @@ static void AddFilesToResult( if (match && !name.starts_with(".")) { resultFiles.push_back( - FileEntry(path, safeFilenameToString(name), false, false)); + FileEntry(path, SafeFilenameToString(name), false, false)); } } } else if (dir_entry.is_directory()) { - resultFiles.push_back(FileEntry{path, safeFilenameToString(name), true, fs::is_symlink(path)}); + resultFiles.push_back(FileEntry{path, SafeFilenameToString(name), true, fs::is_symlink(path)}); } } } @@ -2336,7 +2336,7 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons modDirectoryPath = uploadsDirectory / fileProperty.directory(); result.breadcrumbs_.push_back({ modDirectoryPath.string(), - safeFilenameToString(fs::path(fileProperty.directory()).filename().string())}); + SafeFilenameToString(fs::path(fileProperty.directory()).filename().string())}); } else { @@ -2361,7 +2361,7 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons while (iRp != rp.end()) { cumulativePath /= (*iRp); - result.breadcrumbs_.push_back({cumulativePath, safeFilenameToString(*iRp)}); + result.breadcrumbs_.push_back({cumulativePath, SafeFilenameToString(*iRp)}); ++iRp; } } @@ -2451,7 +2451,7 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const while (iAbsolutePath != fsAbsolutePath.end()) { cumulativePath /= (*iAbsolutePath); - result.breadcrumbs_.push_back({cumulativePath.string(), safeFilenameToString(iAbsolutePath->string())}); + result.breadcrumbs_.push_back({cumulativePath.string(), SafeFilenameToString(iAbsolutePath->string())}); ++iAbsolutePath; } } diff --git a/src/Tone3000Download.cpp b/src/Tone3000Download.cpp index 30ca6ac..2ecc86a 100644 --- a/src/Tone3000Download.cpp +++ b/src/Tone3000Download.cpp @@ -30,6 +30,8 @@ #include "Tone3000DownloadProgress.hpp" #include #include "HtmlHelper.hpp" +#include "Curl.hpp" +#include "PiPedalModel.hpp" using namespace pipedal; using namespace pipedal::tone3000; @@ -77,28 +79,7 @@ JSON_MAP_REFERENCE(Tone3000DownloadResult, errorMessage) JSON_MAP_END() static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"}; - -static bool enableLogging = true; -static std::string shellEscape(const std::string &input) -{ - // Escape string for safe use in shell commands by using single quotes - // and escaping any single quotes in the input - std::string result = "'"; - for (char c : input) - { - if (c == '\'') - { - // End quote, add escaped single quote, start quote again - result += "'\\''"; - } - else - { - result += c; - } - } - result += "'"; - return result; -} +static const std::filesystem::path TONE3000_THUMBNAIL_PATH{"/var/pipedal/tone3000_thumbnails"}; static std::string getCurlErrorFromExitCode(int exitCode) { @@ -139,99 +120,6 @@ static void cancellableSleep(std::chrono::steady_clock::duration duration, std:: } } - -static void downloadFile( - const std::string &url, - const fs::path &path, - std::function &isCancelled) -{ - // Create temporary file for HTTP status code - TemporaryFile httpCodeFile{WEB_TEMP_DIR}; - fs::path httpCodePath = httpCodeFile.Path(); - - TemporaryFile headersFile{WEB_TEMP_DIR}; - fs::path headersFilePath = headersFile.Path(); - - for (size_t retry = 0; retry < 10; ++retry) - { - - // Build curl command with error detection and proper escaping - std::ostringstream os; - os << "/usr/bin/curl -f -s -S -L --max-time 60 --retry 5 --retry-delay 1 --retry-max-time 10 --max-filesize 104857600"; - os << " -w '%{http_code}' -o "; - os << shellEscape(path.string()); - os << " -D " << shellEscape(headersFilePath); - os << " "; - os << shellEscape(url); - os << " > "; - os << shellEscape(httpCodePath.string()); - - std::string command = os.str(); - - // Execute curl command - int exitCode = std::system(command.c_str()); - - // Read HTTP status code - int httpCode = 0; - if (fs::exists(httpCodePath)) - { - std::ifstream httpCodeStream(httpCodePath); - std::string httpCodeStr; - std::getline(httpCodeStream, httpCodeStr); - try - { - httpCode = std::stoi(httpCodeStr); - } - catch (...) - { - // If parsing fails, keep httpCode as 0 - } - } - - if (exitCode != 0) - { - if (exitCode == 22 * 256) // HTTP error - { - std::ifstream headersStream(headersFilePath); - std::string headerStr; - while (std::getline(headersStream, headerStr)) - { - std::cout << headerStr << std::endl; - } - if (httpCode == 429 || httpCode == 403 || httpCode == 503) - { - cancellableSleep(std::chrono::milliseconds(2000), isCancelled); - if (isCancelled()) - { - return; - } - continue; - } - throw std::runtime_error( - SS("Server was unabled to download the file. Http error: " - << HtmlHelper::httpErrorString(httpCode))); - } - else - { - throw std::runtime_error( - SS("Server was unable to download the file. " - << getCurlErrorFromExitCode(exitCode) - << " " << "url: " << url)); - } - } - else - { - break; - } - } - - // Verify file was downloaded - if (!fs::exists(path) || fs::file_size(path) == 0) - { - throw std::runtime_error("Downloaded file is empty or missing"); - } -} - static std::string mdSanitize(const std::string &str, const std::string &illegalCharacters = "") { std::ostringstream os; @@ -254,6 +142,80 @@ static std::string mdSanitize(const std::string &str, const std::string &illegal return os.str(); } +#ifdef JUNK +static std::string mdDataUrl(const std::string &mimeType, const std::filesystem::path &filePath) +{ + // maximum 512k! + size_t fileSize = fs::file_size(filePath); + if (fileSize > 512UL * 1024UL * 1024UL) + { + return ""; + } + std::ifstream file(filePath, std::ios::binary); + if (!file.is_open()) + { + return ""; + } + + static const char kBase64Alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + std::string result; + + std::ostringstream os; + os << "data:" << mimeType << ";base64,"; + + result = os.str(); + size_t encodedDataSize = ((fileSize + 2) / 3) * 4; + result.reserve(result.length() + encodedDataSize); + + constexpr size_t kBufferSize = 32 * 1024; + std::vector buffer; + buffer.resize(kBufferSize); + + size_t offset = 0; + while (offset < fileSize) + { + size_t thisTime = std::min(kBufferSize, fileSize - offset); + file.read(buffer.data(), thisTime); + + size_t i = 0; + while (i + 2 < thisTime) + { + unsigned int triple = (buffer[i] << 16) | (buffer[i + 1] << 8) | buffer[i + 2]; + result.push_back(kBase64Alphabet[(triple >> 18) & 0x3F]); + result.push_back(kBase64Alphabet[(triple >> 12) & 0x3F]); + result.push_back(kBase64Alphabet[(triple >> 6) & 0x3F]); + result.push_back(kBase64Alphabet[triple & 0x3F]); + i += 3; + } + + if (i < thisTime) + { + unsigned int triple = buffer[i] << 16; + result.push_back(kBase64Alphabet[(triple >> 18) & 0x3F]); + if (i + 1 < thisTime) + { + triple |= buffer[i + 1] << 8; + result.push_back(kBase64Alphabet[(triple >> 12) & 0x3F]); + result.push_back(kBase64Alphabet[(triple >> 6) & 0x3F]); + result.push_back('='); + } + else + { + result.push_back(kBase64Alphabet[(triple >> 12) & 0x3F]); + result.push_back('='); + result.push_back('='); + } + } + + offset += thisTime; + } + + return result; +} +#endif + static std::string mdDate(const tone3000_time_point &date_) { std::ostringstream os; @@ -376,7 +338,7 @@ namespace static std::vector licenses{ {.key = "t3k", - .displayName = "T3K", + .displayName = "T3K", .url = "licenses/t3k_license.html", .licenseFlags = LicenseFlags::None}, {.key = "cc-by", @@ -485,7 +447,7 @@ static std::string mdHref(const std::string &url) { std::ostringstream os; os << '\''; - for (char c: url) + for (char c : url) { if (c == '\'') { @@ -498,7 +460,7 @@ static std::string mdHref(const std::string &url) } static std::string mdLicense(const std::string &license) { - + auto ff = licenseEnum.find(license); LicenseInfo licenseInfo; if (ff != licenseEnum.end()) @@ -551,8 +513,7 @@ static std::map platformEnumValues = {"ir", "I/R"}, {"aida-x", "Aida X"}, {"aa-snapshot", "aa-snapshot"}, - {"proteus", "Proteus"} - }; + {"proteus", "Proteus"}}; static std::string mdPlatform(const std::string &platform) { return mdEnum(platform, platformEnumValues); @@ -569,37 +530,30 @@ static std::string mdUser(const tone3000::Tone3000User &user) // clang-format on } - static void mdLink(std::ostream &f, const std::string &label, const std::string &url) { f << "[" << mdSanitize(label, "]") << "](" << mdSanitize(url, ")") << ")"; } -static void mdImage(std::ostream &f, const std::string url) -{ - f << "![" << mdSanitize("Plugin Thumbname", "]") << "](" << mdSanitize(url, "]") << "#tone3000_thumbnail" << ")"; - f << "\n\n"; -} -static void writeReadme(const fs::path &path, const Tone3000Download &download) +static void writeReadme(const fs::path &path, const std::string &thumbnailUrl, const Tone3000Download &download) { std::ofstream of{path}; if (!of.is_open()) { throw std::runtime_error(SS("Unable to create file " << path)); } - std::string imageLink; - if (download.images().size() > 0) - { - imageLink = download.images()[0]; - } // clang-format off of << - "
\n" - << " \n" - << "
\n" + "
\n"; + if (!thumbnailUrl.empty()) + { + + of << " \n"; + } + of << "
\n" << "

" << mdSanitize(download.title()) << "

\n" << "
\n" << "

\n" @@ -607,10 +561,22 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download) << " " << mdUser(download.user()) << "
\n" - << " " << mdSizes(download.sizes()) << ", " - << mdGear(download.gear()) << ", " - << mdPlatform(download.platform()) - << "
\n"; + << " "; + if (download.sizes().has_value()) { + of + << mdSizes(download.sizes().value()) << ", " + ; + } + if (download.platform() == "ir") + { + of + << (mdPlatform(download.platform())); + } else { + of + << mdGear(download.gear()) << ", " + << mdPlatform(download.platform()); + } + of << "
\n"; if (download.license() != "") { of << " License: " << mdLicense(download.license()) << "\n"; } @@ -636,6 +602,72 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download) } } +static void CleanUpFailedDownload(const std::filesystem::path &folder, const std::vector &requests) +{ + + std::error_code ec; // do NOT throw errors. + + if (fs::exists(folder)) + { + for (const auto &request : requests) + { + if (fs::exists(request.outputFile)) + { + fs::remove(request.outputFile, ec); // ignoring errors. + } + } + // If there's a readme file, remove that too. + fs::path readmePath = folder / "README.md"; + if (fs::exists(readmePath)) + { + fs::remove(readmePath, ec); // ignoring errors + } + + // if the folder directory is empty, remove the the folder too. + try + { + if (fs::is_empty(folder)) + { + fs::remove(folder, ec); // ignoring errors. + } + } + catch (const std::exception &) + { + } + } +} + +static std::string GetHeader(const std::string &headerName, const std::vector &htmlHeaders) +{ + std::string needle = headerName; + std::transform(needle.begin(), needle.end(), needle.begin(), [](unsigned char c) + { return std::tolower(c); }); + + for (const auto &header : htmlHeaders) + { + auto pos = header.find(':'); + if (pos == std::string::npos) + { + continue; + } + std::string name = header.substr(0, pos); + std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) + { return std::tolower(c); }); + if (name != needle) + { + continue; + } + std::string value = header.substr(pos + 1); + auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) + { + return ""; + } + auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); + } + return ""; +} std::string pipedal::tone3000::DownloadTone3000Bundle( const std::filesystem::path &destinationFolder, const std::string &downloadUrl, @@ -645,13 +677,18 @@ std::string pipedal::tone3000::DownloadTone3000Bundle( { TemporaryFile downloadTemporaryFile{WEB_TEMP_DIR}; fs::path downloadPath = downloadTemporaryFile.Path(); + std::vector headers; std::string resultDirectory; Tone3000DownloadProgress progress; progress.handle(handle); progressCallback(progress); - downloadFile(downloadUrl, downloadPath, isCancelled); + int httpResult = CurlGet(downloadUrl, downloadPath, &headers); + if (httpResult != 200) + { + throw std::runtime_error(SS("Download failed: HTTP " << HtmlHelper::httpErrorString(httpResult))); + } if (isCancelled()) { @@ -677,40 +714,120 @@ std::string pipedal::tone3000::DownloadTone3000Bundle( progress.total(tone3000Download.models().size()); progressCallback(progress); - if (tone3000Download.platform() != "nam") + if (tone3000Download.platform() != "nam" && tone3000Download.platform() != "ir") { throw std::runtime_error(SS("Unexpected file format. (platform=\"" << tone3000Download.platform() << "\")")); } - fs::path bundlePath = destinationFolder / stringToSafeFilename(tone3000Download.title()); + fs::path bundlePath = destinationFolder / StringToSafeFilename(tone3000Download.title()); resultDirectory = bundlePath; fs::create_directories(bundlePath); - uint64_t nModel = 0; + + std::vector requests; + for (auto &model : tone3000Download.models()) { - if (isCancelled()) + fs::path modelPath; + if (tone3000Download.platform() == "ir") { - return resultDirectory; + modelPath = bundlePath / SS(StringToSafeFilename(model.name()) << ".wav"); } - fs::path modelPath = bundlePath / SS(stringToSafeFilename(model.name()) << ".nam"); - downloadFile(model.model_url(), modelPath, isCancelled); - if (isCancelled()) + else { - return resultDirectory; + modelPath = bundlePath / SS(StringToSafeFilename(model.name()) << ".nam"); } - - progress.progress(++nModel); - progressCallback(progress); + requests.push_back(CurlDownloadRequest{url : model.model_url(), outputFile : modelPath}); } - if (isCancelled()) + try { - return resultDirectory; + int result = CurlGet( + requests, + [&](size_t completed, size_t total) + { + if (isCancelled()) + { + return false; + } + progress.progress(completed); + progress.total(total); + progressCallback(progress); + return true; + }, + &headers); + if (isCancelled()) + { + CleanUpFailedDownload(bundlePath, requests); + return ""; + } + if (result != 200) + { + CleanUpFailedDownload(bundlePath, requests); + throw std::runtime_error(SS("Server download failed. HTTP Error " << HtmlHelper::httpErrorString(result))); + } + fs::path readmePath = bundlePath / "README.md"; + + std::string thumbnailUrl; + if (tone3000Download.images().has_value() && tone3000Download.images().value().size() != 0) + { + + TemporaryFile imageFile{TONE3000_THUMBNAIL_PATH}; + + + + std::string imageUrl = tone3000Download.images().value()[0]; + std::vector imageHeaders; + try + { + if (CurlGet(imageUrl, imageFile.Path(), &imageHeaders) == 200) + { + std::string mediaType = GetHeader("Content-Type", imageHeaders); + std::string extension; + if (mediaType == "image/jpeg") + { + extension = ".jpeg"; + } else if (mediaType == "image/webp") + { + extension = ".webp"; + } else if (mediaType == "image/png") + { + extension = ".png"; + } else { + Lv2Log::warning("Tone3000 thumbnail has an unexpected media type of '%s'", mediaType.c_str()); + throw std::runtime_error("Invalid thumbnail media type."); + } + if (mediaType == "image/jpeg") + { + fs::path thumnbailTempPath = imageFile.Detach(); + fs::path finalPath = TONE3000_THUMBNAIL_PATH / SS(tone3000Download.id() << extension); + if (fs::exists(finalPath)) + { + fs::remove(finalPath); + } + fs::create_directories(finalPath.parent_path()); + fs::rename(thumnbailTempPath, finalPath); + thumbnailUrl = SS("var/tone3000_thumbnail?id=" << tone3000Download.id()); +// thumbnailUrl = mdDataUrl("image/jpeg", finalPath); + } + } + } + catch (const std::exception &) + { + // ignore. + } + } + writeReadme(readmePath, thumbnailUrl, tone3000Download); + } + catch (const std::exception &e) + { + CleanUpFailedDownload(bundlePath, requests); + if (isCancelled()) + { + return ""; + } + throw; } - fs::path readmePath = bundlePath / "README.md"; - writeReadme(readmePath, tone3000Download); return resultDirectory; - } Tone3000DownloadResult::Tone3000DownloadResult() diff --git a/src/Tone3000Download.cpp.bak b/src/Tone3000Download.cpp.bak new file mode 100644 index 0000000..e6dcde9 --- /dev/null +++ b/src/Tone3000Download.cpp.bak @@ -0,0 +1,711 @@ +/* + * Copyright (c) 2026 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include "ss.hpp" +#include "Tone3000Download.hpp" +#include "TemporaryFile.hpp" +#include +#include +#include "util.hpp" +#include "Tone3000DownloadProgress.hpp" +#include +#include "HtmlHelper.hpp" + +using namespace pipedal; +using namespace pipedal::tone3000; +namespace fs = std::filesystem; + +JSON_MAP_BEGIN(Tone3000Model) +JSON_MAP_REFERENCE(Tone3000Model, id) +JSON_MAP_REFERENCE(Tone3000Model, name) +JSON_MAP_REFERENCE(Tone3000Model, model_url) +JSON_MAP_REFERENCE(Tone3000Model, created_at) +JSON_MAP_REFERENCE(Tone3000Model, size) +JSON_MAP_REFERENCE(Tone3000Model, user_id) +JSON_MAP_END() + +JSON_MAP_BEGIN(Tone3000User) +JSON_MAP_REFERENCE(Tone3000User, id) +JSON_MAP_REFERENCE(Tone3000User, username) +JSON_MAP_REFERENCE(Tone3000User, avatar_url) +JSON_MAP_REFERENCE(Tone3000User, url) +JSON_MAP_END() + +JSON_MAP_BEGIN(Tone3000Download) +JSON_MAP_REFERENCE(Tone3000Download, id) +JSON_MAP_REFERENCE(Tone3000Download, user_id) +JSON_MAP_REFERENCE(Tone3000Download, title) +JSON_MAP_REFERENCE(Tone3000Download, description) +JSON_MAP_REFERENCE(Tone3000Download, created_at) +JSON_MAP_REFERENCE(Tone3000Download, updated_at) +JSON_MAP_REFERENCE(Tone3000Download, platform) +JSON_MAP_REFERENCE(Tone3000Download, gear) +JSON_MAP_REFERENCE(Tone3000Download, images) +JSON_MAP_REFERENCE(Tone3000Download, is_public) +JSON_MAP_REFERENCE(Tone3000Download, links) +JSON_MAP_REFERENCE(Tone3000Download, model_count) +JSON_MAP_REFERENCE(Tone3000Download, favorites_count) +JSON_MAP_REFERENCE(Tone3000Download, license) +JSON_MAP_REFERENCE(Tone3000Download, sizes) +JSON_MAP_REFERENCE(Tone3000Download, user) +JSON_MAP_REFERENCE(Tone3000Download, models) +JSON_MAP_END() + +JSON_MAP_BEGIN(Tone3000DownloadResult) +JSON_MAP_REFERENCE(Tone3000DownloadResult, success) +JSON_MAP_REFERENCE(Tone3000DownloadResult, errorMessage) +JSON_MAP_END() + +static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"}; + +static bool enableLogging = true; + +static std::string getCurlErrorFromExitCode(int exitCode) +{ + + switch (exitCode) + { + case 3 * 256: // malformed URL + return "Invalid URL."; + case 6 * 256: // could not resolve hostname + return "Could not resolve hostname. Check that the PiPedal server is connected to the Internet."; + case 7 * 256: // failed to connect to host + return "Failed to connect to host."; + case 22 * 256: // HTTP error + return "HTTP error downloading file."; + case 28 * 256: // timeout + return "Download timeout."; + case 63 * 256: // file too large + return "File too large."; + default: + return SS("Failed to download file (exit code: " << (exitCode / 256) << ")"); + } +} + +static void cancellableSleep(std::chrono::steady_clock::duration duration, std::function &isCancelled) +{ + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + while (true) + { + if (std::chrono::steady_clock::now() - start >= duration) + { + break; + } + if (isCancelled()) + { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } +} + +static void downloadFile( + const std::string &url, + const fs::path &path, + std::function &isCancelled) +{ + // Create temporary file for HTTP status code + TemporaryFile httpCodeFile{WEB_TEMP_DIR}; + fs::path httpCodePath = httpCodeFile.Path(); + + TemporaryFile headersFile{WEB_TEMP_DIR}; + fs::path headersFilePath = headersFile.Path(); + + for (size_t retry = 0; retry < 10; ++retry) + { + + // Build curl command with error detection and proper escaping + std::ostringstream os; + os << "/usr/bin/curl -f -s -S -L --max-time 60 --retry 5 --retry-delay 1 --retry-max-time 10 --max-filesize 104857600"; + os << " -w '%{http_code}' -o "; + os << ShellEscape(path.string()); + os << " -D " << ShellEscape(headersFilePath); + os << " "; + os << ShellEscape(url); + os << " > "; + os << ShellEscape(httpCodePath.string()); + + std::string command = os.str(); + + // Execute curl command + int exitCode = std::system(command.c_str()); + + // Read HTTP status code + int httpCode = 0; + if (fs::exists(httpCodePath)) + { + std::ifstream httpCodeStream(httpCodePath); + std::string httpCodeStr; + std::getline(httpCodeStream, httpCodeStr); + try + { + httpCode = std::stoi(httpCodeStr); + } + catch (...) + { + // If parsing fails, keep httpCode as 0 + } + } + + if (exitCode != 0) + { + if (exitCode == 22 * 256) // HTTP error + { + std::ifstream headersStream(headersFilePath); + std::string headerStr; + while (std::getline(headersStream, headerStr)) + { + std::cout << headerStr << std::endl; + } + if (httpCode == 429 || httpCode == 403 || httpCode == 503) + { + cancellableSleep(std::chrono::milliseconds(2000), isCancelled); + if (isCancelled()) + { + return; + } + continue; + } + throw std::runtime_error( + SS("Server was unabled to download the file. Http error: " + << HtmlHelper::httpErrorString(httpCode))); + } + else + { + throw std::runtime_error( + SS("Server was unable to download the file. " + << getCurlErrorFromExitCode(exitCode) + << " " << "url: " << url)); + } + } + else + { + break; + } + } + + // Verify file was downloaded + if (!fs::exists(path) || fs::file_size(path) == 0) + { + throw std::runtime_error("Downloaded file is empty or missing"); + } +} + +static std::string mdSanitize(const std::string &str, const std::string &illegalCharacters = "") +{ + std::ostringstream os; + for (char c : str) + { + if ((unsigned char)c < 0x20) + continue; + if (c == '<') + { + os << "<"; + continue; + } + auto npos = illegalCharacters.find_first_of(c); + if (npos != std::string::npos) + { + continue; + } + os << c; + } + return os.str(); +} + +static std::string mdDate(const tone3000_time_point &date_) +{ + std::ostringstream os; + // C++ doesn't handle timezones. Just zap the timezone, and replace it with "Z" + std::string date = date_; + + // Remove timezone offset if present and replace with Z + size_t plusPos = date.find('+'); + if (plusPos != std::string::npos) + { + date = date.substr(0, plusPos) + "Z"; + } + // Parse ISO 8601 date string into tm + std::tm tm = {}; + std::istringstream ss(date); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + if (ss.fail()) + { + os << date; + return os.str(); + } + + os << std::put_time(&tm, "%x"); + return os.str(); +} + +static std::string mdEnum(const std::string &value, const std::map &enumValues) +{ + auto ff = enumValues.find(value); + if (ff == enumValues.end()) + { + return value; + } + return ff->second; +} + +static std::string mdEnumList( + const std::vector &values, + const std::map &enumValues) +{ + if (values.size() == 1) + { + return mdEnum(values[0], enumValues); + } + std::ostringstream os; + os << '['; + bool first = true; + for (const auto &value : values) + { + if (first) + { + first = false; + } + else + { + os << ", "; + } + os << mdEnum(value, enumValues); + } + os << ']'; + return os.str(); +} + +static std::map sizeEnumValues = + { + {"standard", "Standard"}, + {"lite", "Lite"}, + {"feather", "Feather"}, + {"nano", "Nano"}, + {"custom", "Custom"}}; +static std::string mdSizes(const std::vector &sizes) +{ + return mdEnumList(sizes, sizeEnumValues); +} + +static std::map gearEnumValues = + { + {"amp", "Amp only"}, + {"full-rig", "Full rig"}, + {"pedal", "Pedal"}, + {"outboard", "Outboard"}, + {"ir", "I/R"}}; + +static std::string mdGear(const std::string &gear) +{ + return mdEnum(gear, gearEnumValues); +} + +namespace +{ + enum class LicenseFlags + { + None = 0, + Cc = 1, + By = 2, + CcBy = 3, // = Cc | By + Sa = 4, + Nc = 8, + Nd = 16, + Cc0 = 32, + }; + + static bool operator&(LicenseFlags v1, LicenseFlags v2) + { + return (((int)v1) & ((int)v2)) != 0; + } + static LicenseFlags operator|(LicenseFlags v1, LicenseFlags v2) + { + return (LicenseFlags)(((int)(v1)) | ((int)(v2))); + } + + struct LicenseInfo + { + std::string key; + std::string displayName; + std::string url; + LicenseFlags licenseFlags; + }; +} + +static std::vector licenses{ + {.key = "t3k", + .displayName = "T3K", + .url = "licenses/t3k_license.html", + .licenseFlags = LicenseFlags::None}, + {.key = "cc-by", + .displayName = "CC BY 4.0", + .url = "https://creativecommons.org/licenses/by/4.0/", + .licenseFlags = LicenseFlags::CcBy}, + {.key = "cc-by-sa", + .displayName = "CC BY-SA 4.0", + .url = "https://creativecommons.org/licenses/by-sa/4.0/", + .licenseFlags = LicenseFlags::CcBy | LicenseFlags::Sa}, + {.key = "cc-by-nc", + .displayName = "CC BY-NC 4.0", + .url = "https://creativecommons.org/licenses/by-nc/4.0/", + .licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nc}, + {.key = "cc-by-nc-sa", + .displayName = "CC BY-NC-SA 4.0", + .url = "https://creativecommons.org/licenses/by-nc-sa/4.0/", + .licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nc | LicenseFlags::Sa}, + {.key = "cc-by-nd", + .displayName = "CC BY-ND 4.0", + .url = "https://creativecommons.org/licenses/by-nd/4.0/", + .licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nd}, + {.key = "cc-by-nc-nd", + .displayName = "CC BY-NC-ND 4.0", + .url = "https://creativecommons.org/licenses/by-nc-nd/4.0/", + .licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nc | LicenseFlags::Nd}, + { + .key = "cco", + .displayName = "CC0", + .url = "https://creativecommons.org/publicdomain/zero/1.0/", + .licenseFlags = LicenseFlags::Cc | LicenseFlags::Cc0, + + }, +}; + +static std::map makeLicenseEnum() +{ + std::map result; + for (const auto &license : licenses) + { + result[license.key] = license; + } + return result; +} + +static std::map licenseEnum = makeLicenseEnum(); + +static std::string mdLicenseIcon(LicenseFlags licenseFlag) +{ + std::ostringstream os; + std::string url; + + switch (licenseFlag) + { + case LicenseFlags::Cc: + url = "img/cc.svg"; + break; + case LicenseFlags::By: + url = "img/by.svg"; + break; + case LicenseFlags::Sa: + url = "img/sa.svg"; + break; + case LicenseFlags::Nc: + url = "img/nc.svg"; + break; + case LicenseFlags::Nd: + url = "img/nd.svg"; + break; + case LicenseFlags::Cc0: + url = "img/cc0.svg"; + break; + + case LicenseFlags::None: + throw std::runtime_error("Invalid argument."); + } + os << ""; + return os.str(); +} + +static void mdEscapeString(std::ostream &f, const std::string &str) +{ + size_t ix = 0; + while (ix < str.size()) + { + char c = str[ix++]; + if (c == '\n') + { + if (ix < str.size() && str[ix] == '\n') + { + f << "\n\n"; + } + else + { + f << " \n"; // a
in md. + } + } + else + { + f << c; + } + } +} + +static std::string mdHref(const std::string &url) +{ + std::ostringstream os; + os << '\''; + for (char c : url) + { + if (c == '\'') + { + continue; + } + os << c; + } + os << '\''; + return os.str(); +} +static std::string mdLicense(const std::string &license) +{ + + auto ff = licenseEnum.find(license); + LicenseInfo licenseInfo; + if (ff != licenseEnum.end()) + { + licenseInfo = ff->second; + } + else + { + licenseInfo.displayName = license; + } + + std::ostringstream os; + + if (licenseInfo.licenseFlags != LicenseFlags::None) + { + for (LicenseFlags licenseFlag : std::vector{LicenseFlags::Cc, LicenseFlags::By, LicenseFlags::Cc0, LicenseFlags::Nc, LicenseFlags::Sa, LicenseFlags::Nd}) + { + if (licenseInfo.licenseFlags & licenseFlag) + { + os << mdLicenseIcon(licenseFlag); + } + } + os << " "; + } + + if (licenseInfo.url != "") + { + os << "" << mdSanitize(licenseInfo.displayName) << ""; + } + else + { + os << mdSanitize(licenseInfo.displayName); + } + return os.str(); +} +// static std::string mdTestLicenses() +// { +// std::ostringstream os; +// for (auto license: licenseEnum) +// { +// os << "
" << mdLicense(license.first) << "\n"; +// } +// return os.str(); +// } +static std::map platformEnumValues = + { + {"nam", "NAM"}, + {"ir", "I/R"}, + {"aida-x", "Aida X"}, + {"aa-snapshot", "aa-snapshot"}, + {"proteus", "Proteus"}}; +static std::string mdPlatform(const std::string &platform) +{ + return mdEnum(platform, platformEnumValues); +} +static std::string mdUser(const tone3000::Tone3000User &user) +{ + // clang-format off + std::ostringstream os; + os << + "" << mdSanitize(user.username()) << ""; + return os.str(); + // clang-format on +} + +static void mdLink(std::ostream &f, const std::string &label, const std::string &url) +{ + f << "[" << mdSanitize(label, "]") << "](" << mdSanitize(url, ")") << ")"; +} + +static void mdImage(std::ostream &f, const std::string url) +{ + f << "![" << mdSanitize("Plugin Thumbname", "]") << "](" << mdSanitize(url, "]") << "#tone3000_thumbnail" << ")"; + f << "\n\n"; +} +static void writeReadme(const fs::path &path, const Tone3000Download &download) +{ + std::ofstream of{path}; + if (!of.is_open()) + { + throw std::runtime_error(SS("Unable to create file " << path)); + } + std::string imageLink; + if (download.images().size() > 0) + { + imageLink = download.images()[0]; + } + + // clang-format off + of << + + "

\n" + << " \n" + << "
\n" + << "

" << mdSanitize(download.title()) << "

\n" + << "
\n" + << "

\n" + << mdDate(download.updated_at()) + << " " + << mdUser(download.user()); + if (download.sizes().has_value()) { + of + << "
\n" + << " " << mdSizes(download.sizes().value()) << ", " + ; + } + of + << mdGear(download.gear()) << ", " + << mdPlatform(download.platform()) + << "
\n"; + if (download.license() != "") { + of << " License: " << mdLicense(download.license()) << "\n"; + } + of +// << " " << mdTestLicenses() + << "

\n" + << "
\n" + << "
\n" + << "
\n"; + // clang-format on + of << "\n\n"; + mdEscapeString(of, download.description()); + + if (download.links().size() > 0) + { + of << std::endl; + of << "## Links" << std::endl + << std::endl; + for (auto &link : download.links()) + { + of << "- " << "[" << link << "](" << link << ")" << std::endl; + } + } +} + +std::string pipedal::tone3000::DownloadTone3000Bundle( + const std::filesystem::path &destinationFolder, + const std::string &downloadUrl, + int64_t handle, + std::function progressCallback, + std::function isCancelled) +{ + TemporaryFile downloadTemporaryFile{WEB_TEMP_DIR}; + fs::path downloadPath = downloadTemporaryFile.Path(); + + std::string resultDirectory; + Tone3000DownloadProgress progress; + progress.handle(handle); + progressCallback(progress); + + downloadFile(downloadUrl, downloadPath, isCancelled); + + if (isCancelled()) + { + + return resultDirectory; + } + + std::ifstream f{downloadPath}; + if (!f.is_open()) + { + throw std::runtime_error(SS("Can't open file " << downloadPath)); + } + json_reader reader(f); + + Tone3000Download tone3000Download; + reader.read(&tone3000Download); + + if (isCancelled()) + { + return resultDirectory; + } + progress.title(tone3000Download.title()); + progress.total(tone3000Download.models().size()); + progressCallback(progress); + + if (tone3000Download.platform() != "nam" && tone3000Download.platform() != "ir") + { + throw std::runtime_error(SS("Unexpected file format. (platform=\"" << tone3000Download.platform() << "\")")); + } + fs::path bundlePath = destinationFolder / StringToSafeFilename(tone3000Download.title()); + resultDirectory = bundlePath; + + fs::create_directories(bundlePath); + uint64_t nModel = 0; + for (auto &model : tone3000Download.models()) + { + if (isCancelled()) + { + return resultDirectory; + } + fs::path modelPath; + if (tone3000Download.platform() == "ir") + { + modelPath = bundlePath / SS(StringToSafeFilename(model.name()) << ".wav"); + + } else { + modelPath = bundlePath / SS(StringToSafeFilename(model.name()) << ".nam"); + } + downloadFile(model.model_url(), modelPath, isCancelled); + if (isCancelled()) + { + return resultDirectory; + } + + progress.progress(++nModel); + progressCallback(progress); + } + if (isCancelled()) + { + return resultDirectory; + } + fs::path readmePath = bundlePath / "README.md"; + writeReadme(readmePath, tone3000Download); + + return resultDirectory; +} + +Tone3000DownloadResult::Tone3000DownloadResult() + : success_(true) +{ +} +Tone3000DownloadResult::Tone3000DownloadResult(const std::exception &e) + : success_(false), errorMessage_(e.what()) +{ +} diff --git a/src/Tone3000Download.hpp b/src/Tone3000Download.hpp index 6cf2a25..c42d1bc 100644 --- a/src/Tone3000Download.hpp +++ b/src/Tone3000Download.hpp @@ -31,9 +31,11 @@ #include #include #include "Tone3000DownloadProgress.hpp" +#include namespace pipedal { + namespace tone3000 { using tone3000_time_point = std::string; // Date in ISO 8601 format. (C++ doesn't handle timezones) @@ -44,7 +46,7 @@ namespace pipedal std::string name_; std::string model_url_; tone3000_time_point created_at_; - std::string size_; + std::optional size_; std::string user_id_; public: @@ -85,13 +87,13 @@ namespace pipedal tone3000_time_point created_at_; tone3000_time_point updated_at_; std::string gear_; - std::vector images_; + std::optional> images_; bool is_public_ = true; std::vector links_; int64_t model_count_ = 0; int64_t favorites_count_ = 0; std::string license_; - std::vector sizes_; + std::optional> sizes_; Tone3000User user_; std::vector models_; diff --git a/src/Tone3000DownloadType.cpp b/src/Tone3000DownloadType.cpp new file mode 100644 index 0000000..0e2ed2d --- /dev/null +++ b/src/Tone3000DownloadType.cpp @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + #include "Tone3000DownloadType.hpp" + #include + #include + +namespace pipedal { + + // Must agree with Tone3000DownloadType.tsx + + static std::unordered_map downloadTypeEnums = + { + {"nam", Tone3000DownloadType::Nam}, + {"cabir", Tone3000DownloadType::CabIr}, + }; + Tone3000DownloadType StringToTone3000DownloadType(const std::string &value) + { + auto ff = downloadTypeEnums.find(value); + if (ff == downloadTypeEnums.end()) + { + throw std::runtime_error("Invalid argument."); + } + return ff->second; + } + std::string Tone3000DownloadTypeToString(Tone3000DownloadType value) + { + switch (value) + { + case Tone3000DownloadType::Nam: + return "nam"; + case Tone3000DownloadType::CabIr: + return "cabir"; + default: + throw std::runtime_error("Inalid argument."); + } + } + +} \ No newline at end of file diff --git a/src/Tone3000DownloadType.hpp b/src/Tone3000DownloadType.hpp new file mode 100644 index 0000000..3d24738 --- /dev/null +++ b/src/Tone3000DownloadType.hpp @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#pragma once +#include + +namespace pipedal { + enum class Tone3000DownloadType { + Nam, + CabIr + }; + + Tone3000DownloadType StringToTone3000DownloadType(const std::string &value); + std::string Tone3000DownloadTypeToString(Tone3000DownloadType value); + +} \ No newline at end of file diff --git a/src/Tone3000Downloader.cpp b/src/Tone3000Downloader.cpp index eba3bab..ed4c28b 100644 --- a/src/Tone3000Downloader.cpp +++ b/src/Tone3000Downloader.cpp @@ -60,6 +60,7 @@ Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::NextHandle() return result; } Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::RequestDownload( + Tone3000DownloadType downloadType, const std::string &path, const std::string &url) { @@ -67,7 +68,8 @@ Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::RequestDownload( { std::lock_guard lockGuard{this->mutex}; handle = NextHandle(); - std::shared_ptr request = std::make_shared(false, handle, path, url); + std::shared_ptr request = + std::make_shared(downloadType, false, handle, path, url); this->requestQueue.push_back(request); request = nullptr; @@ -144,6 +146,7 @@ Tone3000DownloadProgress Tone3000DownloaderImpl::GetDownloadStatus() void Tone3000DownloaderImpl::bgUpdateDownloadProgress(const Tone3000DownloadProgress &progress) { + std::lock_guard lockGuard{this->mutex}; if (closed) { @@ -160,6 +163,7 @@ void Tone3000DownloaderImpl::bgUpdateDownloadProgress(const Tone3000DownloadProg } std::string Tone3000DownloaderImpl::PerformDownload( + Tone3000DownloadType downloadType, const std::string &downloadPath, const std::string &downloadUrl, int64_t downloadHandle, @@ -199,7 +203,7 @@ std::string Tone3000DownloaderImpl::PerformDownload( if (isCancelled()) { // maybe there's a partial result. - return downloadedDirectory; + return downloadedDirectory; } return downloadedDirectory; } @@ -249,14 +253,19 @@ void Tone3000DownloaderImpl::ThreadProc() try { - std::string outputPath = PerformDownload(request->downloadPath, request->downloadUrl, request->handle, isCancelled); + std::string outputPath = PerformDownload( + request->downloadType, + request->downloadPath, + request->downloadUrl, + request->handle, + isCancelled); - // Notify completion if not cancelled + // Notify completion { std::lock_guard lockGuard{mutex}; - if (!request->cancelled.load() && currentListener) + if (currentListener) { - currentListener->OnTone3000DownloadComplete(request->handle,outputPath); + currentListener->OnTone3000DownloadComplete(request->handle, outputPath); } } } diff --git a/src/Tone3000Downloader.hpp b/src/Tone3000Downloader.hpp index e341c63..3dea6a0 100644 --- a/src/Tone3000Downloader.hpp +++ b/src/Tone3000Downloader.hpp @@ -26,6 +26,7 @@ #include #include #include "Tone3000DownloadProgress.hpp" +#include "Tone3000DownloadType.hpp" namespace pipedal { @@ -57,6 +58,8 @@ namespace pipedal { const std::string &errorMessage ) = 0; + virtual std::string Tone3000ThumbnailDirectory() = 0; + }; @@ -66,6 +69,7 @@ namespace pipedal { virtual void SetListener(Listener*listener) = 0; virtual handle_t RequestDownload( + Tone3000DownloadType downloadType, const std::string &path, const std::string &url ) = 0; diff --git a/src/Tone3000DownloaderImpl.hpp b/src/Tone3000DownloaderImpl.hpp index 083ebae..571b9ee 100644 --- a/src/Tone3000DownloaderImpl.hpp +++ b/src/Tone3000DownloaderImpl.hpp @@ -41,6 +41,7 @@ namespace pipedal { virtual void SetListener(Listener*listener) override; virtual handle_t RequestDownload( + Tone3000DownloadType downloadType, const std::string &path, const std::string &url ) override; @@ -59,6 +60,7 @@ namespace pipedal { // Performs the actual download with cancellation support std::string PerformDownload( + Tone3000DownloadType downloadType, const std::string &downloadPath, const std::string &downloadUrl, int64_t downloadHandle, @@ -66,6 +68,7 @@ namespace pipedal { ); struct DownloadRequest { + Tone3000DownloadType downloadType; std::atomic cancelled = false; handle_t handle; const std::string downloadPath; diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp index cc41471..9a977a2 100644 --- a/src/WebServerConfig.cpp +++ b/src/WebServerConfig.cpp @@ -127,6 +127,31 @@ private: std::vector extensions; }; + +static fs::path GetTone3000ThumbnailFile(const fs::path &thumbnailDirectory, const std::string & id) { + std::string stem = id; + // find a file in thumbnailDirectory which has a filename of {stem}.*. + if (!fs::exists(thumbnailDirectory) || !fs::is_directory(thumbnailDirectory)) + { + return {}; + } + + for (const auto &entry : fs::directory_iterator(thumbnailDirectory)) + { + if (!entry.is_regular_file()) + { + continue; + } + const auto &path = entry.path(); + if (path.stem() == stem) + { + return path; + } + } + return {}; + +} + class DownloadIntercept : public RequestHandler { PiPedalModel *model; @@ -146,6 +171,11 @@ public: } std::string segment = request_uri.segment(1); + if (segment == "tone3000_thumbnail") + { + return true; + } + if (segment == "downloadMediaFile") { return true; @@ -298,6 +328,30 @@ public: try { std::string segment = request_uri.segment(1); + + if (segment == "tone3000_thumbnail") + { + std::string id = request_uri.query("id"); + if (id == "") + { + throw PiPedalException("Invalid request."); + } + fs::path path = GetTone3000ThumbnailFile(this->model->Tone3000ThumbnailDirectory(), id); + if (!fs::exists(path)) + { + throw PiPedalException("File not found."); + } + auto mimeType = GetMimeType(path); + if (mimeType.empty()) + { + throw PiPedalException("Can't download files of this type."); + } + res.set(HttpField::content_type, mimeType); + size_t contentLength = std::filesystem::file_size(path); + res.setContentLength(contentLength); + return; + } + if (segment == "downloadMediaFile") { fs::path path = request_uri.query("path"); @@ -432,7 +486,26 @@ public: { std::string segment = request_uri.segment(1); - if (segment == "downloadMediaFile") + if (segment == "tone3000_thumbnail") + { + std::string id = request_uri.query("id"); + fs::path path = GetTone3000ThumbnailFile(this->model->Tone3000ThumbnailDirectory(), id); + if (!fs::exists(path)) + { + throw PiPedalException("File not found."); + } + auto mimeType = GetMimeType(path); + if (mimeType.empty()) + { + throw PiPedalException("Can't download files of this type."); + } + res.set(HttpField::content_type, mimeType); + size_t contentLength = std::filesystem::file_size(path); + res.setContentLength(contentLength); + res.setBodyFile(path, false); + return; + + } else if (segment == "downloadMediaFile") { fs::path path = request_uri.query("path"); diff --git a/todo.txt b/todo.txt index f5b26db..de596f7 100644 --- a/todo.txt +++ b/todo.txt @@ -1,4 +1,5 @@ - Refresh on download complete +Cab I/Rs: gear="ir" + // yyy: only if the property changed!. Tooltip on File Property Select: add to release notes. sAFEfILEnAMES: RENAME, &C. Safe bank names? diff --git a/vite/eslint.config.js b/vite/eslint.config.js index adef530..ff3fea7 100644 --- a/vite/eslint.config.js +++ b/vite/eslint.config.js @@ -24,5 +24,10 @@ export default tseslint.config( { allowConstantExport: true }, ], }, + server: { + proxy: { + '/var': 'http://localhost:8080' + } + } }, ) diff --git a/vite/src/pipedal/AppThemed.tsx b/vite/src/pipedal/AppThemed.tsx index fc1992b..d97b177 100644 --- a/vite/src/pipedal/AppThemed.tsx +++ b/vite/src/pipedal/AppThemed.tsx @@ -1039,6 +1039,7 @@ export defaultName={this.model_.banks.get().getSelectedEntryName()} title="Bank Name" acceptActionName={this.state.renameBankDialogOpen ? "Rename" : "Save as"} + useSafeFilenames={false} onClose={() => { this.setState({ renameBankDialogOpen: false, @@ -1100,7 +1101,7 @@ export - {/* Status of Tone3000 download */} + {/* Status of TONE3000 download */} {/* Fatal error mask */} = filename.length) { - // invalid encoding. just use the original - return originalFilename; - } - let hex = filename.charAt(pos+1) + filename.charAt(pos+2); - let byte = parseInt(hex, 16); - - // Handle UTF-8 to UTF-16 conversion - if (byte < 0x80) { - // Single-byte character (ASCII) - result += String.fromCharCode(byte); - filename = filename.substring(pos+3); - } else if ((byte & 0xE0) === 0xC0) { - // 2-byte sequence - if (pos+6 > filename.length || filename.charAt(pos+3) !== '%') { - return originalFilename; // invalid encoding - } - let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16); - let codePoint = ((byte & 0x1F) << 6) | (byte2 & 0x3F); - result += String.fromCharCode(codePoint); - filename = filename.substring(pos+6); - } else if ((byte & 0xF0) === 0xE0) { - // 3-byte sequence - if (pos+9 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%') { - return originalFilename; // invalid encoding - } - let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16); - let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16); - let codePoint = ((byte & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F); - result += String.fromCharCode(codePoint); - filename = filename.substring(pos+9); - } else if ((byte & 0xF8) === 0xF0) { - // 4-byte sequence (surrogate pair needed) - if (pos+12 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%' || filename.charAt(pos+9) !== '%') { - return originalFilename; // invalid encoding - } - let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16); - let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16); - let byte4 = parseInt(filename.charAt(pos+10) + filename.charAt(pos+11), 16); - let codePoint = ((byte & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F); - // Convert to UTF-16 surrogate pair - codePoint -= 0x10000; - result += String.fromCharCode(0xD800 + (codePoint >> 10)); - result += String.fromCharCode(0xDC00 + (codePoint & 0x3FF)); - filename = filename.substring(pos+12); - } else { - // Invalid UTF-8 start byte - return originalFilename; - } - pos = filename.indexOf('%'); - } -} - export function getTrackTitle(pathname: string, metadata: AudioFileMetadata | null | undefined): string { if (!metadata) { return safeFilenameDecode(pathFileName(pathname)); diff --git a/vite/src/pipedal/BankDialog.tsx b/vite/src/pipedal/BankDialog.tsx index 4080a42..303b500 100644 --- a/vite/src/pipedal/BankDialog.tsx +++ b/vite/src/pipedal/BankDialog.tsx @@ -469,6 +469,7 @@ const BankDialog = withStyles( title={this.state.filenameSaveAs ? "Save Bank As" : "Bank Name"} acceptActionName={this.state.filenameSaveAs ? "SAVE AS" : "RENAME"} onClose={() => { this.setState({ filenameDialogOpen: false }) }} + useSafeFilenames={false} onOk={(text: string) => { if (this.state.filenameSaveAs) { this.handleSaveAsOk(text); diff --git a/vite/src/pipedal/FilePropertyControl.tsx b/vite/src/pipedal/FilePropertyControl.tsx index df6be1a..37482cb 100644 --- a/vite/src/pipedal/FilePropertyControl.tsx +++ b/vite/src/pipedal/FilePropertyControl.tsx @@ -34,7 +34,7 @@ import ButtonBase from '@mui/material/ButtonBase' import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import { PedalboardItem } from './Pedalboard'; import { isDarkMode } from './DarkMode'; -import {safeFilenameDecode} from './AudioFileMetadata'; +import {safeFilenameDecode} from './SafeFilename'; import { withStyles } from "tss-react/mui"; diff --git a/vite/src/pipedal/FilePropertyDialog.tsx b/vite/src/pipedal/FilePropertyDialog.tsx index 3d73a30..e911e20 100644 --- a/vite/src/pipedal/FilePropertyDialog.tsx +++ b/vite/src/pipedal/FilePropertyDialog.tsx @@ -67,11 +67,17 @@ import OkCancelDialog from './OkCancelDialog'; import HomeIcon from '@mui/icons-material/Home'; import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog'; import { getAlbumArtUri, getTrackTitle } from './AudioFileMetadata'; -import { Tone3000DownloadDialog } from './Tone3000Dialog'; +import Tone3000DownloadType from './Tone3000DownloadType'; const ToobNamModelFileUrl = "http://two-play.com/plugins/toob-nam#modelFile"; const ToobMlModelFileUrl = "http://two-play.com/plugins/toob-ml#modelFile"; +const ToobCabIrFileUrls = [ + "http://two-play.com/plugins/toob-cab-ir#impulseFile", + "http://two-play.com/plugins/toob-cab-ir#impulseFile2", + "http://two-play.com/plugins/toob-cab-ir#impulseFile3", +]; + const AUTOSCROLL_TICK_DELAY = 30; const AUTOSCROLL_THRESHOLD = 48; @@ -171,7 +177,6 @@ export interface FilePropertyDialogState { previousSelection: string; multiSelect: boolean, selectedFiles: string[], - openTone3000Dialog: boolean, openTone3000Help: boolean, openGuitarMlHelp: boolean, @@ -249,7 +254,6 @@ export default withStyles( previousSelection: this.props.selectedFile, multiSelect: false, selectedFiles: [], - openTone3000Dialog: false, openTone3000Help: false, openGuitarMlHelp: false }; @@ -1086,7 +1090,21 @@ export default withStyles( handleTone3000Dialog(e: React.MouseEvent) { e.stopPropagation(); e.preventDefault(); - this.setState({ openTone3000Dialog: true }); + // Popup launch MUST be done from javascript, not react to avoid default popup blockers in browers. + // The dialog just provides a blocker dialog to cancel the popup if the TONE3000 Select APIs escape. + + let downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam; + if (this.props.fileProperty.patchProperty === ToobNamModelFileUrl) { + downloadType = Tone3000DownloadType.Nam; + } else if (ToobCabIrFileUrls.includes(this.props.fileProperty.patchProperty)) + { + downloadType = Tone3000DownloadType.CabIr; + } else { + throw new Error("Unsupported file property for Tone3000 dialog"); + } + this.model.showTone3000DownloadPopup( + downloadType, + this.state.currentDirectory); } @@ -1110,6 +1128,7 @@ export default withStyles( render() { const isTracksDirectory = this.isTracksDirectory(); const isToobNamModelFile = this.props.fileProperty.patchProperty === ToobNamModelFileUrl; + const isToobCabIrFile = ToobCabIrFileUrls.includes(this.props.fileProperty.patchProperty); const isToobMLModelFile = this.props.fileProperty.patchProperty === ToobMlModelFileUrl; const classes = withStyles.getClasses(this.props); @@ -1532,7 +1551,7 @@ export default withStyles( <>
- {isToobNamModelFile && ( + {(isToobNamModelFile || isToobCabIrFile) && (
{ this.handleTone3000Dialog(e); }} > - Download model files from Tone3000 + { + isToobNamModelFile + ? "Download model files from TONE3000" + : "Download I/R files from TONE3000" + } + { this.handleTone3000Help(e); }} aria-label="help" edge="end" color="inherit" style={{ opacity: 0.6, marginLeft: 8 }} @@ -1711,6 +1735,7 @@ export default withStyles( onClose={() => { this.setState({ newFolderDialogOpen: false }); }} title="New folder" acceptActionName="OK" + useSafeFilenames={true} /> ) } @@ -1721,6 +1746,7 @@ export default withStyles( onOk={(newName) => { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }} onClose={() => { this.setState({ renameDialogOpen: false }); }} acceptActionName="OK" + useSafeFilenames={true} /> ) @@ -1733,6 +1759,7 @@ export default withStyles( open={this.state.moveDialogOpen || this.state.copyDialogOpen} dialogTitle={this.state.moveDialogOpen ? "Move to" : "Copy Link to"} uiFileProperty={this.props.fileProperty} + useSafeFilenames={true} defaultPath={this.getDefaultPath()} selectedFile={this.state.selectedFile} excludeDirectory={ @@ -1755,20 +1782,11 @@ export default withStyles( ) ) } - {this.state.openTone3000Dialog && ( - this.setState({ openTone3000Dialog: false })} - downloadPath={this.state.currentDirectory} - onDownloadComplete={() => { - this.setState({ openTone3000Dialog: false }); - this.requestFiles(this.state.navDirectory); - }} - /> - )} {this.state.openTone3000Help && ( this.setState({ openTone3000Help: false })} + downloadType={isToobNamModelFile ? Tone3000DownloadType.Nam : Tone3000DownloadType.CabIr} /> )} {this.state.openGuitarMlHelp && ( diff --git a/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx b/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx index ad631d7..6c0e5fb 100644 --- a/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx +++ b/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx @@ -35,6 +35,7 @@ import FolderIcon from '@mui/icons-material/Folder'; import HomeIcon from '@mui/icons-material/Home'; import { isDarkMode } from './DarkMode'; import IconButton from '@mui/material/IconButton'; +import {safeFilenameDecode} from './SafeFilename'; @@ -126,6 +127,7 @@ export interface FilePropertyDirectorySelectDialogProps { defaultPath: string, excludeDirectory: string, selectedFile: string, + useSafeFilenames: boolean, onOk: (path: string) => void, onClose: () => void }; @@ -260,6 +262,11 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)"; } let showToggle = directoryTree.canExpand() && directoryTree.path.length !== 0; + let name = directoryTree.name === "" ? "Home" : directoryTree.name; + if (this.props.useSafeFilenames) { + name = safeFilenameDecode(name); + } + return (
@@ -292,7 +299,7 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC ) } - {directoryTree.name === "" ? "Home" : directoryTree.name} + {name}
diff --git a/vite/src/pipedal/ObservableProperty.tsx b/vite/src/pipedal/ObservableProperty.tsx index 3808bd6..12f2e00 100644 --- a/vite/src/pipedal/ObservableProperty.tsx +++ b/vite/src/pipedal/ObservableProperty.tsx @@ -60,6 +60,9 @@ export class ObservableProperty { set(value: VALUE_TYPE) : void { + if (this._value_type === value) { + return; + } this._value_type = value; let t = this._on_changed_handlers; // take an copy in case removes happen while iteratiing. diff --git a/vite/src/pipedal/PiPedalModel.tsx b/vite/src/pipedal/PiPedalModel.tsx index 4d414a5..f0815d9 100644 --- a/vite/src/pipedal/PiPedalModel.tsx +++ b/vite/src/pipedal/PiPedalModel.tsx @@ -28,6 +28,7 @@ import PluginClass from './PluginClass'; import ScreenOrientation from './ScreenOrientation'; import PiPedalSocket, { PiPedalMessageHeader } from './PiPedalSocket'; import {Tone3000DownloadHandler} from './Tone3000Dialog'; +import Tone3000DownloadType from './Tone3000DownloadType'; import { nullCast } from './Utility' import { JackConfiguration, JackChannelSelection } from './Jack'; import { BankIndex } from './Banks'; @@ -660,9 +661,23 @@ export class PiPedalModel //implements PiPedalModel } + async pingTone3000Server() : Promise { + if (this.webSocket === undefined) { + return false; + + } + try { + let result = await this.webSocket.request("pingTone3000Server", {}); + return result; + } catch (error) { + console.log("Error pinging TONE3000 Server on PiPedal server: " + getErrorMessage(error)); + return false; + } + } async downloadModelsFromTone3000( tone3000DownloadUrl: string, - downloadPath: string + downloadPath: string, + downloadType: Tone3000DownloadType ): Promise { try { if (!this.webSocket) { @@ -671,50 +686,59 @@ export class PiPedalModel //implements PiPedalModel this.webSocket.send("downloadModelsFromTone3000", { downloadPath: downloadPath, - tone3000Url: tone3000DownloadUrl + tone3000Url: tone3000DownloadUrl, + downloadType: downloadType }); } catch (error) { this.showAlert(getErrorMessage(error)); } } - cancelTone3000Download(): void { + private cancelTone3000Download(): void { let downloadProgress = this.tone3000DownloadProgress.get(); - if (downloadProgress === null) { - return; - } - if (downloadProgress.handle !== -1) { - if (!this.webSocket) { - return; + if (downloadProgress !== null) { + if (downloadProgress.handle !== -1) { + if (!this.webSocket) { + this.tone3000DownloadProgress.set(null); + this.tone3000Downloading.set(false); + return; + } + this.webSocket.send("cancelTone3000Download",downloadProgress.handle); } - this.webSocket.send("cancelTone3000Download",downloadProgress.handle); } } - showTone3000DownloadDialog( - downloadPath: string, - onClosed: () => void, - onDownloadStarted: () => void, - onDownloadComplete: () => void - + showTone3000DownloadPopup( + downloadType: Tone3000DownloadType, + downloadPath: string ): void { if (this.tone3000DownloadHandler === null) { this.tone3000DownloadHandler = new Tone3000DownloadHandler(this); } - this.tone3000DownloadHandler.launchTone3000Dialog( + this.tone3000DownloadHandler.launchTone3000Popup( + downloadType, downloadPath, - onClosed, - (errorMessage: string) => { - this.showAlert(errorMessage); - onClosed(); - }, - onDownloadStarted, - onDownloadComplete ); }; - closeTone3000DownloadDialog(): void { + + showTone3000DownloadStatus(): void { + this.tone3000Downloading.set(true); + this.tone3000DownloadProgress.set(null); + console.log("Showing TONE3000 download status popup"); + } + hideTone3000DownloadStatus(): void { + this.tone3000Downloading.set(false); + this.tone3000DownloadProgress.set(null); + console.log("Hiding TONE3000 download status popup"); + } + + closeTone3000DownloadPopup(): void { + this.cancelTone3000Download(); + this.tone3000Downloading.set(false); + this.tone3000DownloadProgress.set(null); + if (this.tone3000DownloadHandler) { - this.tone3000DownloadHandler.closeTone3000Dialog(); + this.tone3000DownloadHandler.closeTone3000Popup(); } } @@ -1057,7 +1081,7 @@ export class PiPedalModel //implements PiPedalModel } - // Tone3000 download notification handlers (stub implementations) + // TONE3000 download notification handlers (stub implementations) private onTone3000DownloadStarted(handle: number, title: string): void { this.tone3000Downloading.set(true); } @@ -1069,12 +1093,14 @@ export class PiPedalModel //implements PiPedalModel private onTone3000DownloadComplete(resultPath: string): void { this.tone3000Downloading.set(false); + this.tone3000DownloadProgress.set(null); this.onTone3000DownloadCompleteEvent.fire(resultPath); } private onTone3000DownloadError(handle: number, errorMessage: string): void { - console.error(`Tone3000 download error: handle=${handle}, error=${errorMessage}`); + console.error(`TONE3000 download error: handle=${handle}, error=${errorMessage}`); this.tone3000Downloading.set(false); + this.tone3000DownloadProgress.set(null); this.onTone3000DownloadCompleteEvent.fire(""); setTimeout(() => { @@ -1090,9 +1116,12 @@ export class PiPedalModel //implements PiPedalModel return "var/" + url; } - setState(state: State) { + private setState(state: State) { if (this.state.get() !== state) { this.state.set(state); + if (state === State.Error) { + this.closeTone3000DownloadPopup(); + } } } @@ -1397,7 +1426,7 @@ export class PiPedalModel //implements PiPedalModel m = message.toString(); } this.errorMessage.set(m); - this.state.set(State.Error); + this.setState(State.Error); } @@ -1458,6 +1487,7 @@ export class PiPedalModel //implements PiPedalModel return false; } + initialize(): void { this.setError(""); this.setState(State.Loading); @@ -1465,7 +1495,7 @@ export class PiPedalModel //implements PiPedalModel this.requestConfig() .then((succeeded) => { if (succeeded) { - this.state.set(State.Ready); + this.setState(State.Ready); if (this.androidHost) { this.hostVersion = new HostVersion(this.androidHost.getHostVersion()); if (!this.hostVersion.lessThan(1, 1, 16)) { @@ -2857,29 +2887,6 @@ export class PiPedalModel //implements PiPedalModel document.body.removeChild(link); } - async uploadTone3000File(url: string) : Promise { - try { - let response = await fetch( - url, - { - method: "GET", - } - ); - - let json = await response.json(); - - if (json.success === true) { - return true; - } - if (json.errorMessage !== undefined) - { - throw new Error(json.errorMessage); - } - throw new Error("Invalid server response."); - } catch (error) { - throw new Error("Upload to PiPedal server failed. " + getErrorMessage(error)); - } - } uploadUserFile(uploadPage: string, file: File, contentType: string = "application/octet-stream", abortController?: AbortController): Promise { let result = new Promise((resolve, reject) => { try { diff --git a/vite/src/pipedal/PluginPresetSelector.tsx b/vite/src/pipedal/PluginPresetSelector.tsx index d3036a3..4773d07 100644 --- a/vite/src/pipedal/PluginPresetSelector.tsx +++ b/vite/src/pipedal/PluginPresetSelector.tsx @@ -338,6 +338,7 @@ const PluginPresetSelector = title="Rename" defaultName={this.state.renameDialogDefaultName} acceptActionName={this.state.renameDialogActionName} + useSafeFilenames={false} onClose={() => this.handleRenameDialogClose()} onOk={(name: string) => this.handleRenameDialogOk(name)} />
diff --git a/vite/src/pipedal/PluginPresetsDialog.tsx b/vite/src/pipedal/PluginPresetsDialog.tsx index 27d848c..382883a 100644 --- a/vite/src/pipedal/PluginPresetsDialog.tsx +++ b/vite/src/pipedal/PluginPresetsDialog.tsx @@ -416,6 +416,7 @@ const PluginPresetsDialog = withStyles( title="Rename" open={this.state.renameOpen} defaultName={this.getSelectedName()} + useSafeFilenames={false} acceptActionName={"Rename"} onClose={() => { this.setState({ renameOpen: false }) }} onOk={(text: string) => { diff --git a/vite/src/pipedal/PresetDialog.tsx b/vite/src/pipedal/PresetDialog.tsx index 857a819..8e39926 100644 --- a/vite/src/pipedal/PresetDialog.tsx +++ b/vite/src/pipedal/PresetDialog.tsx @@ -526,6 +526,7 @@ const PresetDialog = withStyles( title="Rename" open={this.state.renameOpen} defaultName={this.getSelectedName()} + useSafeFilenames={false} acceptActionName={"Rename"} onClose={() => { this.setState({ renameOpen: false }) }} onOk={(text: string) => { diff --git a/vite/src/pipedal/PresetSelector.tsx b/vite/src/pipedal/PresetSelector.tsx index acb0579..2ec7979 100644 --- a/vite/src/pipedal/PresetSelector.tsx +++ b/vite/src/pipedal/PresetSelector.tsx @@ -467,6 +467,7 @@ const PresetSelector = title={this.state.renameDialogTitle} defaultName={this.state.renameDialogDefaultName} acceptActionName={this.state.renameDialogActionName} + useSafeFilenames={false} onClose={() => this.handleRenameDialogClose()} onOk={(name: string) => this.handleRenameDialogOk(name)} /> { onClose(); }; @@ -102,13 +110,19 @@ export default class RenameDialog extends ResizeResponsiveComponent { let text = nullCast(this.refText.current).value; text = text.trim(); - try { - this.checkForIllegalCharacters(text); - } catch (e:any) + if (props.useSafeFilenames) { - let model:PiPedalModel = PiPedalModelFactory.getInstance(); - model.showAlert(e.toString()); - return; + text = safeFilenameEncode(text); + } + else { + try { + this.checkForIllegalCharacters(text); + } catch (e:any) + { + let model:PiPedalModel = PiPedalModelFactory.getInstance(); + model.showAlert(e.toString()); + return; + } } if (text.length === 0 && props.allowEmpty !== true) return; onOk(text); @@ -154,7 +168,7 @@ export default class RenameDialog extends ResizeResponsiveComponent diff --git a/vite/src/pipedal/SafeFilename.tsx b/vite/src/pipedal/SafeFilename.tsx new file mode 100644 index 0000000..f7a48e0 --- /dev/null +++ b/vite/src/pipedal/SafeFilename.tsx @@ -0,0 +1,127 @@ +export function safeFilenameDecode(filename: string): string { + let originalFilename = filename; + let pos = filename.indexOf('%'); + if (pos < 0) { + return filename; + } + let result = ""; + while (true) { + if (pos < 0) { + result += filename; + return result; + } + result += filename.substring(0, pos); + if (pos+3 >= filename.length) { + // invalid encoding. just use the original + return originalFilename; + } + let hex = filename.charAt(pos+1) + filename.charAt(pos+2); + let byte = parseInt(hex, 16); + + // Handle UTF-8 to UTF-16 conversion + if (byte < 0x80) { + // Single-byte character (ASCII) + result += String.fromCharCode(byte); + filename = filename.substring(pos+3); + } else if ((byte & 0xE0) === 0xC0) { + // 2-byte sequence + if (pos+6 > filename.length || filename.charAt(pos+3) !== '%') { + return originalFilename; // invalid encoding + } + let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16); + let codePoint = ((byte & 0x1F) << 6) | (byte2 & 0x3F); + result += String.fromCharCode(codePoint); + filename = filename.substring(pos+6); + } else if ((byte & 0xF0) === 0xE0) { + // 3-byte sequence + if (pos+9 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%') { + return originalFilename; // invalid encoding + } + let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16); + let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16); + let codePoint = ((byte & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F); + result += String.fromCharCode(codePoint); + filename = filename.substring(pos+9); + } else if ((byte & 0xF8) === 0xF0) { + // 4-byte sequence (surrogate pair needed) + if (pos+12 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%' || filename.charAt(pos+9) !== '%') { + return originalFilename; // invalid encoding + } + let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16); + let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16); + let byte4 = parseInt(filename.charAt(pos+10) + filename.charAt(pos+11), 16); + let codePoint = ((byte & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F); + // Convert to UTF-16 surrogate pair + codePoint -= 0x10000; + result += String.fromCharCode(0xD800 + (codePoint >> 10)); + result += String.fromCharCode(0xDC00 + (codePoint & 0x3FF)); + filename = filename.substring(pos+12); + } else { + // Invalid UTF-8 start byte + return originalFilename; + } + pos = filename.indexOf('%'); + } +} + +export function safeFilenameEncode(filename: string): string { + let result = ""; + + for (let i = 0; i < filename.length; i++) { + let charCode = filename.charCodeAt(i); + + // Handle UTF-16 surrogate pairs + if (charCode >= 0xD800 && charCode <= 0xDBFF) { + // High surrogate - must be followed by low surrogate + if (i + 1 < filename.length) { + let lowSurrogate = filename.charCodeAt(i + 1); + if (lowSurrogate >= 0xDC00 && lowSurrogate <= 0xDFFF) { + // Combine surrogates to get the code point + let codePoint = 0x10000 + ((charCode & 0x3FF) << 10) + (lowSurrogate & 0x3FF); + + // Encode as 4-byte UTF-8 + let byte1 = 0xF0 | ((codePoint >> 18) & 0x07); + let byte2 = 0x80 | ((codePoint >> 12) & 0x3F); + let byte3 = 0x80 | ((codePoint >> 6) & 0x3F); + let byte4 = 0x80 | (codePoint & 0x3F); + + result += '%' + byte1.toString(16).padStart(2, '0'); + result += '%' + byte2.toString(16).padStart(2, '0'); + result += '%' + byte3.toString(16).padStart(2, '0'); + result += '%' + byte4.toString(16).padStart(2, '0'); + + i++; // Skip the low surrogate + continue; + } + } + // Invalid surrogate pair - encode as-is (shouldn't happen in valid strings) + } + + // Handle control characters (< 0x20) + if (charCode < 0x20) { + result += '%' + charCode.toString(16).padStart(2, '0'); + } + // Handle ASCII printable characters (0x20-0x7F), space is NOT encoded + else if (charCode <= 0x7F) { + result += filename.charAt(i); + } + // Handle 2-byte UTF-8 (0x80-0x7FF) + else if (charCode <= 0x7FF) { + let byte1 = 0xC0 | ((charCode >> 6) & 0x1F); + let byte2 = 0x80 | (charCode & 0x3F); + result += '%' + byte1.toString(16).padStart(2, '0'); + result += '%' + byte2.toString(16).padStart(2, '0'); + } + // Handle 3-byte UTF-8 (0x800-0xFFFF) + else { + let byte1 = 0xE0 | ((charCode >> 12) & 0x0F); + let byte2 = 0x80 | ((charCode >> 6) & 0x3F); + let byte3 = 0x80 | (charCode & 0x3F); + result += '%' + byte1.toString(16).padStart(2, '0'); + result += '%' + byte2.toString(16).padStart(2, '0'); + result += '%' + byte3.toString(16).padStart(2, '0'); + } + } + + return result; +} diff --git a/vite/src/pipedal/TextInfoDialog.tsx b/vite/src/pipedal/TextInfoDialog.tsx index 7daf0a7..0690e3e 100644 --- a/vite/src/pipedal/TextInfoDialog.tsx +++ b/vite/src/pipedal/TextInfoDialog.tsx @@ -27,7 +27,7 @@ import { PiPedalModel, PiPedalModelFactory, State } from './PiPedalModel'; import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ReactMarkdown from 'react-markdown'; +import ReactMarkdown, { defaultUrlTransform, UrlTransform } from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, {defaultSchema} from 'rehype-sanitize'; import rehypeExternalLinks from 'rehype-external-links' @@ -37,10 +37,28 @@ import { PiPedalError } from './PiPedalError'; // Extend the default schema to allow target and rel attributes on anchor tags const extendedSchema = { ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - a: [...(defaultSchema.attributes?.a || []), 'target', 'rel'] + tagNames: Array.from(new Set([...(defaultSchema.tagNames ?? []), 'img'])), + protocols: { + ...defaultSchema.protocols, + src: [...(defaultSchema.protocols?.src ?? []), ''], + }, + +}; + +const urlTransform: UrlTransform = (url, key, node) => { + if (key === 'src' && node.tagName === 'img' && url.startsWith('data:')) { + const allowedPrefixes = ['data:image/jpeg', 'data:image/png']; + for (const prefix of allowedPrefixes) { + if (url.startsWith(prefix)) { + const nextChar = url.charAt(prefix.length); + if (nextChar === ';' || nextChar === ',') { + return url; + } + } + } + return ''; } + return defaultUrlTransform(url); }; import DialogEx from './DialogEx'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; @@ -181,10 +199,10 @@ const TextInfoDialog = class extends ResizeResponsiveComponent
- diff --git a/vite/src/pipedal/Tone3000Dialog.tsx b/vite/src/pipedal/Tone3000Dialog.tsx index 5a862b1..adb09d0 100644 --- a/vite/src/pipedal/Tone3000Dialog.tsx +++ b/vite/src/pipedal/Tone3000Dialog.tsx @@ -1,34 +1,27 @@ import { JSX } from "@emotion/react/jsx-dev-runtime"; import { PiPedalModel, State, getErrorMessage } from "./PiPedalModel"; -import DialogEx from "./DialogEx"; import { Button, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import LinearProgress from "@mui/material/LinearProgress"; import Tone3000DownloadProgress from "./Tone3000DownloadProgress"; import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; import DialogActions from "@mui/material/DialogActions"; +import Tone3000DownloadType from "./Tone3000DownloadType"; + export type OnTone3000DownloadHandler = (tone3000DownloadUrl: string) => void; - // used to check for online status. let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt"; export class Tone3000DownloadHandler { - private async handleTone3000Download(tone3000DownloadUrl: string): Promise { + private async handleTone3000Download(tone3000DownloadUrl: string, downloadType: Tone3000DownloadType): Promise { try { - // Dialog can close. We'll use the following await to manage - // lifecycle from here onward. - this.stopTone3000DialogMonitor(); - - this.handleTone3000DownloadComplete(); - - await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath); + await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType); return; } catch (e) { this.handleTone3000DownloadError(getErrorMessage(e)); @@ -39,6 +32,7 @@ export class Tone3000DownloadHandler { private messageEventListener: ((event: MessageEvent) => void) | null = null; private model: PiPedalModel; + private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam; public constructor(model: PiPedalModel) { this.model = model; @@ -46,7 +40,7 @@ export class Tone3000DownloadHandler { if (event.data.type !== "tone3000Download") { return; } - this.handleTone3000Download(event.data.toneUrl as string); + this.handleTone3000Download(event.data.toneUrl as string, this.downloadType); }; window.addEventListener("message", this.messageEventListener); @@ -61,7 +55,7 @@ export class Tone3000DownloadHandler { private popupWindow: Window | null = null; - private appId: string = "pipedal_app3"; + private appId: string = "pipedal_app4"; private redirectUrl(): string { let hostname = window.location.hostname; let port = window.location.port; @@ -77,217 +71,136 @@ export class Tone3000DownloadHandler { return `${serverUrl}/public/handleTone3000download.html`; } - private tone3000SelectUrl(): string { - return `https://www.tone3000.com/api/v1/select?app_id=${this.appId}&redirect_url=${this.redirectUrl()}`; + private tone3000SelectUrl(downloadType: Tone3000DownloadType): string { + let redirectUrl_ = this.redirectUrl(); + let result = `https://www.tone3000.com/api/v1/select?app_id=${this.appId}&redirect_url=${redirectUrl_}`; + if (downloadType === Tone3000DownloadType.CabIr) { + result += "&gear=ir"; + } + return result; } - private handleTone3000DialogClosed() { - let t = this.onClosedCallback; - this.onClosedCallback = undefined; - this.onErrorCallback = undefined; - this.onDownloadCompleteCallback = undefined; - if (t) { - t(); - } - this.stopTone3000DialogMonitor(); - } - private handleTone3000Downloadstarted() { - if (this.onDownloadStartedCallback) { - this.onDownloadStartedCallback(); - } - } private handleTone3000DownloadComplete() { - let t = this.onDownloadCompleteCallback; - this.onClosedCallback = undefined; - this.onErrorCallback = undefined; - this.onDownloadCompleteCallback = undefined; - if (t) { - t(); + if (this.popupWindow) { + if (!this.popupWindow.closed) { + this.popupWindow.close(); + } + this.popupWindow = null; } - this.stopTone3000DialogMonitor(); + this.model.hideTone3000DownloadStatus(); + + } + closeTone3000Popup() { + this.handleTone3000DownloadComplete(); } private handleTone3000DownloadError(errorMessage: string) { - let t = this.onErrorCallback; - this.onClosedCallback = undefined; - this.onErrorCallback = undefined; - this.onDownloadCompleteCallback = undefined; - if (t) { - t(errorMessage); - } - this.stopTone3000DialogMonitor(); - } - - private tone3000DialogTimeout: number | undefined = undefined; - - private stopTone3000DialogMonitor() { - if (this.tone3000DialogTimeout !== undefined) { - clearTimeout(this.tone3000DialogTimeout); - this.tone3000DialogTimeout = undefined; + if (this.open) + { + this.handleTone3000DownloadComplete(); + this.model.showAlert(errorMessage); } } - private startTone3000DialogMonitor(delay: number = 2000) { - this.stopTone3000DialogMonitor(); - this.tone3000DialogTimeout = window.setTimeout(() => { - if (this.popupWindow == null || this.popupWindow.closed) { - this.stopTone3000DialogMonitor(); - this.popupWindow = null; - this.handleTone3000DialogClosed(); - this.tone3000DialogTimeout = undefined; - } else { - this.startTone3000DialogMonitor(500); - } - }, delay); - } + private downloadPath: string = ""; - private onClosedCallback: (() => void) | undefined = undefined; - private onErrorCallback: ((errorMessage: string) => void) | undefined = undefined; - private onDownloadStartedCallback: (() => void) | undefined = undefined; - private onDownloadCompleteCallback: (() => void) | undefined = undefined; + private open = false; - public launchTone3000Dialog( + private launchInstance = 0; + public async launchTone3000Popup( + downloadType: Tone3000DownloadType, downloadPath: string, - onClosed: () => void, - onError: (errorMessage: string) => void, - onDownloadStarted: () => void, - onDownloadComplete: () => void - ) { + + ): Promise { this.closeTone3000Dialog(); + // online + this.downloadPath = downloadPath; - this.onClosedCallback = onClosed; - this.onDownloadCompleteCallback = onDownloadComplete; - this.onDownloadStartedCallback = onDownloadStarted; - this.onErrorCallback = onError; + this.downloadType = downloadType; + + let popupWidth = Math.floor(window.innerWidth * 0.8); + let popupHeight = Math.floor(window.innerHeight * 0.8); + if (window.innerWidth < 410) { + popupWidth = 400; + } + + this.open = true; + + this.model.showTone3000DownloadStatus(); + + let launchInstance = ++this.launchInstance; + let cancelled = () => { + return (launchInstance !== this.launchInstance) || this.popupWindow == null || this.popupWindow.closed; + } try { - // online - let popupWidth = Math.floor(window.innerWidth * 0.8); - let popupHeight = Math.floor(window.innerHeight * 0.8); - if (window.innerWidth < 410) { - popupWidth = 400; - } - this.popupWindow = - window.open( - this.tone3000SelectUrl(), - "popup", - `innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes` - ); + this.popupWindow = + window.open( + this.tone3000SelectUrl(downloadType), + "popup", + `innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes` + ); - if (!this.popupWindow) { - console.error("Failed to open Tone3000 dialog popup window."); - this.handleTone3000DownloadError("Cannot open popup window."); - return; - } - this.startTone3000DialogMonitor(); - fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" }).then(response => { - if (!response.ok) { - // offline - this.handleTone3000DownloadError("Unable to connect to Tone3000 servers. An internet connection is required."); - } - }).catch(error => { - // offline - this.handleTone3000DownloadError("Not online. Unable to connect to Tone3000 servers."); - }); + if (!this.popupWindow) { + console.error("Failed to open TONE3000 dialog popup window."); + throw new Error("Cannot open popup window."); } - catch (error) { - // offline - this.handleTone3000DownloadError("Not online. Unable to connect to Tone3000 servers."); - return; + // No procedure for detecting 404, so let's ping the server to see if it's reachable, and cancel if it's not. + try { + let response = await fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" }); + if (cancelled()) return; + if (!response.ok) { + // offline + throw new Error("Unable to connect to the TONE3000 server."); + } + } catch (error) { + throw new Error("Not online. Unable to connect to the TONE3000 server. Your client device must have access to the internet to use this feature.."); }; - } - public closeTone3000Dialog(): void { - this.handleTone3000DialogClosed(); - this.stopTone3000DialogMonitor(); - if (this.popupWindow) { + let pipedalServerCanReachTone3000 = false; + + // check to see that the PiPedal server can reach TONE3000. try { - - let t = this.popupWindow; - this.popupWindow = null; - t.close(); - } catch (e) { - console.error("Error closing Tone3000 dialog popup:", e); + pipedalServerCanReachTone3000 = await this.model.pingTone3000Server(); + if (cancelled()) return; + } catch (error) { } + if (!pipedalServerCanReachTone3000) { + throw new Error("The PiPedal server cannot reach a TONE3000 server. The PiPedal server must have access to the internet to use this feature."); + } + while (true) { + await new Promise(resolve => setTimeout(resolve, 500)); + if (launchInstance != this.launchInstance) { + // A new dialog launch has been initiated. Stop monitoring this one. + return; + } + if (this.popupWindow == null || this.popupWindow.closed) { + this.closeTone3000Dialog(); + return; + } + } + } catch (error) { + this.handleTone3000DownloadError(getErrorMessage(error)); } } + + public closeTone3000Dialog(): void { + this.handleTone3000DownloadComplete(); + } } -export interface Tone3000DownloadDialogProps { - - onClose: () => void, - onDownloadComplete: () => void, - downloadPath: string -} -export function Tone3000DownloadDialog(props: Tone3000DownloadDialogProps): JSX.Element { - const model = PiPedalModel.getInstance(); - let { onClose, onDownloadComplete, downloadPath } = props; - let [downloading, setDownloading] = useState(false); - - useEffect(() => { - model.showTone3000DownloadDialog( - downloadPath, - () => { - onClose(); - }, - () => { - setDownloading(true); - }, - () => { - onDownloadComplete(); - } - ); - let onStateChanged = (state: State) => { - if (state !== State.Ready) { - onClose(); - } - } - model.state.addOnChangedHandler(onStateChanged); - return () => { - model.closeTone3000DownloadDialog(); - model.state.removeOnChangedHandler(onStateChanged); - }; - }); - return ( - { - onClose(); - }} - onEnterKey={() => { }} - > - - Downloading from Tone3000... - {downloading && -
- -
- } -
- - {!downloading && ( - - )} - -
- ); -} export function Tone3000DownloadStaus( props: { zindex: number; }): JSX.Element { const model = PiPedalModel.getInstance(); - const [downloading, setDownloading] = useState(false); - const [progress, setProgress] = useState(null); + const [downloading, setDownloading] = useState(model.tone3000Downloading.get()); + const [progress, setProgress] = useState(model.tone3000DownloadProgress.get()); useEffect(() => { let onDownloadingChanged = (value: boolean) => { + setDownloading(value); } model.tone3000Downloading.addOnChangedHandler(onDownloadingChanged); @@ -302,30 +215,39 @@ export function Tone3000DownloadStaus( model.tone3000DownloadProgress.removeOnChangedHandler(onProgressChanged); } }) - let open = downloading && progress !== null && progress.title.length > 0; + let open = downloading; if (model.state.get() !== State.Ready) { open = false; } + let filename = progress?.title ?? "\u00A0"; + if (filename === "") { + filename = "\u00A0"; + } return ( { /* Do nothing */ }} > - Downloading from Tone3000 - - {progress?.title ?? "\u00A0"} -
+ +
+ Downloading from TONE3000... + {filename}
diff --git a/vite/src/pipedal/Tone3000DownloadType.tsx b/vite/src/pipedal/Tone3000DownloadType.tsx new file mode 100644 index 0000000..aaefe62 --- /dev/null +++ b/vite/src/pipedal/Tone3000DownloadType.tsx @@ -0,0 +1,9 @@ + +// Must agree with Tone3000DownloadType.hpp +enum Tone3000DownloadType { + Nam = "nam", + CabIr = "cabir", +} + + +export default Tone3000DownloadType; \ No newline at end of file diff --git a/vite/src/pipedal/Tone3000HelpDialog.tsx b/vite/src/pipedal/Tone3000HelpDialog.tsx index 6d6c477..e92d896 100644 --- a/vite/src/pipedal/Tone3000HelpDialog.tsx +++ b/vite/src/pipedal/Tone3000HelpDialog.tsx @@ -6,19 +6,20 @@ import IconButtonEx from "./IconButtonEx"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; - +import Tone3000DownloadType from "./Tone3000DownloadType"; function Tone3000HelpDialog(props: { open: boolean; + downloadType: Tone3000DownloadType onClose: () => void; }) { - const { open, onClose } = props; + const { open, downloadType, onClose } = props; return ( { onClose(); }} onClose={() => { onClose(); }} open={open}> @@ -36,7 +37,7 @@ function Tone3000HelpDialog(props: { - + TONE3000 Help @@ -49,35 +50,82 @@ function Tone3000HelpDialog(props: { maxWidth: 500, marginLeft: "auto", marginRight: "auto", display: "flex", flexFlow: "column nowrap", gap: 16, alignItems: "start", }}> + {downloadType === Tone3000DownloadType.CabIr && ( + <> + + The TONE3000 website provides a + massive collection of high-quality Neural Amp Modeler models that can be used by TooB Neural Amp Modeler. It also + provides collections of cabinet Impulse Response (I/R) files that can be used with TooB Cab IR. + + + When you click on the button to download I/R files from TONE3000, + selected I/R bundles will be directly downloaded from the TONE3000 website and + uploaded to your PiPedal server. For this to work, the server must have + internet access, and your client (Android app or browser) must have internet + access as well. + + If either of your devices does not have internet access, you can still use TONE3000. + Use a browser on a device with internet access to download model files by connecting to + https://www.tone3000.com and + download model packs directly to your local device. You can then upload the model .zip + files to the PiPedal Server using the Upload button in the + File Properties dialog in the PiPedal app or web interface. + PiPedal will extract I/R files from the .zip file bundles automatically, so there is no + need to extract them first. + + + )} + {downloadType === Tone3000DownloadType.Nam && ( + <> + + The TONE3000 website provides a + massive collection of high-quality Neural Amp Modeler files that can be used by TooB Neural Amp Modeler to + perform high-quality simulations of amps and effect pedals. + + + When you click on the button to download Neural Amp Modeler files from TONE3000, + model file bundles will be directly downloaded from the TONE3000 website and + uploaded to your PiPedal server. For this to work, the server must have + internet access, and your client (Android app or browser) must have internet + access as well. + + Neural Amp Modeler models that simulate amp heads will typically need a speaker/cabinet simulation to + sound their best, or of course, need to be played through an actual physical amp speaker cabinet. The TONE3000 + website provides a broad selection of cabinet I/R files that can be used + with TooB Cab IR to provide high-quality cabinet simulation for your NAM models. TooB Cab IR allows + you to browse and download I/R files from TONE3000 directly within the PiPedal app. + + + + If either of your devices does not have internet access, you can still use TONE3000. + Use a browser on a device with internet access to download Neural Amp Modeler files by connecting to + https://www.tone3000.com and + download Neural Amp Modeler file packs directly to your local device as .zip file bundles. You can then + upload the .zip file bundle to the PiPedal Server using the Upload button in the + File Properties dialog. PiPedal will + extract model files from the .zip archives automatically, so there is no need to extract them first. + + + )} + + TONE3000 is a community-driven platform where community members share Neural Amp Modeler profiles. + The site showcases the collaborative spirit of the guitar modeling community, made + possible by Steven Atkins' Neural Amp Modeler library, which forms the foundation and + core of TooB Neural Amp Modeler and other NAM-based plugins. + + + If you would like to profile your own amplifiers and effects, the TONE3000 website allows you to + generate your own NAM profiles for free. Refer to + the TONE3000 Capture page for more details. + - The TONE3000 website provides a - massive collection of neural amp models that can be used by TooB Neural Amp Modeler. - - - Using TONE3000 model files in PiPedal is a two step process. First, download model files from - the website using your browser; and then upload - the files in your browser's Downloads directory to the PiPedal server using - the Upload button. PiPedal - automatically extracts bundles of model files from zip archives, so manual extraction is unnecessary. - - - TONE3000 is a community-driven platform where community members share Neural Amp Modeler profiles. - The site showcases the collaborative spirit of the guitar modeling community. And all of this is made - possible by Steven Atkins' Neural Amp Modeler library, which forms the foundation and core of TooB Neural Amp Modeler and other NAM-based plugins. - - - If you would like to profile your own amplifiers, and effects, the TONE3000 website allows you to - generate your own NAM profiles for free. Refer to - the TONE3000 website for more details. - - - When you click on the TONE3000 link, you will be taken to an external website. The TONE3000 - website is not part of PiPedal, and is not affiliated in any way with PiPedal. + When you click on the TONE3000 link, you will be taken to an external website. The TONE3000 + website is not part of PiPedal and is not affiliated in any way with PiPedal. Privacy statement: PiPedal has no access to personal data used by the TONE3000 website. Please refer to - the TONE3000 privacy policy for + the TONE3000 Privacy Policy for information on how your data is used by TONE3000.
diff --git a/vite/vite.config.ts b/vite/vite.config.ts index 62dcc1e..6c069b2 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -14,6 +14,10 @@ export default defineConfig({ target: 'http://localhost:8080', changeOrigin: false, }, + '/var': { + target: 'http://localhost:8080', + changeOrigin: false, + }, } } })