Complete except for throttling bug.

This commit is contained in:
Robin E.R. Davies
2026-02-10 19:59:05 -05:00
parent aec88fa9f1
commit 22b63a32db
42 changed files with 2597 additions and 564 deletions
+2 -1
View File
@@ -103,7 +103,8 @@
"locale": "cpp",
"stdfloat": "cpp",
"text_encoding": "cpp",
"forward_list": "cpp"
"forward_list": "cpp",
"*.bak": "cpp"
},
"cSpell.words": [
"Alsa",
+54 -1
View File
@@ -394,6 +394,59 @@ std::string HtmlHelper::generateEtag(const std::filesystem::path &path)
static std::map<int,std::string> 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");
}
+4 -2
View File
@@ -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);
}
+29 -4
View File
@@ -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<bool, 256> makeSfBits()
{
std::array<bool, 256> 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<bool, 256> 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;
}
+1 -1
View File
@@ -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<VuUpdate> &updates) = 0;
virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) = 0;
virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) = 0;
+2
View File
@@ -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
+706
View File
@@ -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 <filesystem>
#include "SysExec.hpp"
#include <stdexcept>
#include "util.hpp"
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <algorithm>
#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<std::string>&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<std::string> &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<uint8_t> &output,
std::vector<std::string> *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_t>(size));
if (size > 0)
{
inputStream.read(reinterpret_cast<char *>(output.data()), size);
}
}
return rc;
}
int CurlGet(
const std::string&url,
std::vector<std::string> &output,
std::vector<std::string>*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<std::string> *headersOpt
)
{
TemporaryFile headersFile { WEB_TEMP_DIR};
std::vector<std::string> 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<std::string> &arguments, // commandline arguments.
const std::function<bool (const std::string &string)> &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<char*> argv;
argv.push_back(const_cast<char*>(commandPath.c_str()));
for (const auto& arg : arguments)
{
argv.push_back(const_cast<char*>(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<CurlDownloadRequest> &request,
const std::function<void(size_t completed, size_t total)> &progressCallback,
std::vector<std::string>*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<std::string> 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;
}
}
+60
View File
@@ -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 <string>
#include <vector>
#include <cstdint>
#include <filesystem>
#include <functional>
namespace pipedal {
extern int CurlGet(
const std::string &url,
const std::filesystem::path&outputFile,
std::vector<std::string> *headersOpt = nullptr
);
extern int CurlGet(
const std::string &url,
std::vector<uint8_t> &output,
std::vector<std::string> *headersOpt = nullptr
);
extern int CurlGet(
const std::string&url,
std::vector<std::string> &output,
std::vector<std::string> *headersOpt = nullptr
);
struct CurlDownloadRequest{
std::string url;
std::filesystem::path outputFile;
};
extern int CurlGet(
const std::vector<CurlDownloadRequest> &request,
const std::function<void(size_t completed, size_t total)> &progressCallback,
std::vector<std::string>*headersOpt
);
}
+12 -3
View File
@@ -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<std::recursive_mutex> 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";
}
+9 -1
View File
@@ -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> 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<VuUpdate> &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);
+29
View File
@@ -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<VuSubscription> 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<PiPedalSocketHandler> 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<ISocketFactory> pipedal::MakePiPedalSocketFactory(PiPedalModel &
{
return std::make_shared<PiPedalSocketFactory>(model);
}
bool PiPedalSocketHandler::PingTone3000Server()
{
constexpr const char* TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
std::vector<std::string> output;
std::vector<std::string> 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;
}
}
+5 -5
View File
@@ -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;
}
}
+275 -158
View File
@@ -30,6 +30,8 @@
#include "Tone3000DownloadProgress.hpp"
#include <thread>
#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<bool()> &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<char> 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<LicenseInfo> 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<std::string, std::string> 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 <<
"<div id='tone3000_float' >\n"
<< " <img id='tone3000_thumbnail' src='"
<< imageLink << "' alt='' />\n"
<< " <div id='tone3000_float_content' >\n"
"<div id='tone3000_float' >\n";
if (!thumbnailUrl.empty())
{
of << " <img id='tone3000_thumbnail' src='"
<< thumbnailUrl << "' alt='' />\n";
}
of << " <div id='tone3000_float_content' >\n"
<< " <h3 id='tone3000_title'>" << mdSanitize(download.title()) << "</h3>\n"
<< " <div>\n"
<< " <p id='tone3000_subtitle'>\n"
@@ -607,10 +561,22 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download)
<< " "
<< mdUser(download.user())
<< " <br/>\n"
<< " " << mdSizes(download.sizes()) << ", "
<< mdGear(download.gear()) << ", "
<< mdPlatform(download.platform())
<< "<br/>\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 << "<br/>\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<CurlDownloadRequest> &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<std::string> &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<std::string> 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<CurlDownloadRequest> 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<std::string> 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()
+711
View File
@@ -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 <array>
#include <ctime>
#include "util.hpp"
#include "Tone3000DownloadProgress.hpp"
#include <thread>
#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<bool()> &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<bool()> &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 << "&lt;";
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<std::string, std::string> &enumValues)
{
auto ff = enumValues.find(value);
if (ff == enumValues.end())
{
return value;
}
return ff->second;
}
static std::string mdEnumList(
const std::vector<std::string> &values,
const std::map<std::string, std::string> &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<std::string, std::string> sizeEnumValues =
{
{"standard", "Standard"},
{"lite", "Lite"},
{"feather", "Feather"},
{"nano", "Nano"},
{"custom", "Custom"}};
static std::string mdSizes(const std::vector<std::string> &sizes)
{
return mdEnumList(sizes, sizeEnumValues);
}
static std::map<std::string, std::string> 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<LicenseInfo> 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<std::string, LicenseInfo> makeLicenseEnum()
{
std::map<std::string, LicenseInfo> result;
for (const auto &license : licenses)
{
result[license.key] = license;
}
return result;
}
static std::map<std::string, LicenseInfo> 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 << "<img id='cc_img' src='" << url << "'/>";
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 <br> 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>{LicenseFlags::Cc, LicenseFlags::By, LicenseFlags::Cc0, LicenseFlags::Nc, LicenseFlags::Sa, LicenseFlags::Nd})
{
if (licenseInfo.licenseFlags & licenseFlag)
{
os << mdLicenseIcon(licenseFlag);
}
}
os << " ";
}
if (licenseInfo.url != "")
{
os << "<a href="
<< mdHref(licenseInfo.url)
<< " target='_blank' rel='noopener noreferrer' >" << mdSanitize(licenseInfo.displayName) << "</a>";
}
else
{
os << mdSanitize(licenseInfo.displayName);
}
return os.str();
}
// static std::string mdTestLicenses()
// {
// std::ostringstream os;
// for (auto license: licenseEnum)
// {
// os << "<br/>" << mdLicense(license.first) << "\n";
// }
// return os.str();
// }
static std::map<std::string, std::string> 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 <<
"<a target='_blank' rel='noopener noreferrer' href='"
<< mdSanitize(user.url(),"'")
<< "'>" << mdSanitize(user.username()) << "</a>";
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 <<
"<div id='tone3000_float' >\n"
<< " <img id='tone3000_thumbnail' src='"
<< imageLink << "' alt='' />\n"
<< " <div id='tone3000_float_content' >\n"
<< " <h3 id='tone3000_title'>" << mdSanitize(download.title()) << "</h3>\n"
<< " <div>\n"
<< " <p id='tone3000_subtitle'>\n"
<< mdDate(download.updated_at())
<< " "
<< mdUser(download.user());
if (download.sizes().has_value()) {
of
<< " <br/>\n"
<< " " << mdSizes(download.sizes().value()) << ", "
;
}
of
<< mdGear(download.gear()) << ", "
<< mdPlatform(download.platform())
<< "<br/>\n";
if (download.license() != "") {
of << " License: " << mdLicense(download.license()) << "\n";
}
of
// << " " << mdTestLicenses()
<< " </p>\n"
<< " </div>\n"
<< " </div>\n"
<< "</div>\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<void(const Tone3000DownloadProgress &progress)> progressCallback,
std::function<bool()> 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())
{
}
+5 -3
View File
@@ -31,9 +31,11 @@
#include <chrono>
#include <functional>
#include "Tone3000DownloadProgress.hpp"
#include <memory>
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<std::string> 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<std::string> images_;
std::optional<std::vector<std::string>> images_;
bool is_public_ = true;
std::vector<std::string> links_;
int64_t model_count_ = 0;
int64_t favorites_count_ = 0;
std::string license_;
std::vector<std::string> sizes_;
std::optional<std::vector<std::string>> sizes_;
Tone3000User user_;
std::vector<Tone3000Model> models_;
+59
View File
@@ -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 <stdexcept>
#include <unordered_map>
namespace pipedal {
// Must agree with Tone3000DownloadType.tsx
static std::unordered_map<std::string,Tone3000DownloadType> 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.");
}
}
}
+36
View File
@@ -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 <string>
namespace pipedal {
enum class Tone3000DownloadType {
Nam,
CabIr
};
Tone3000DownloadType StringToTone3000DownloadType(const std::string &value);
std::string Tone3000DownloadTypeToString(Tone3000DownloadType value);
}
+15 -6
View File
@@ -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<std::mutex> lockGuard{this->mutex};
handle = NextHandle();
std::shared_ptr<DownloadRequest> request = std::make_shared<DownloadRequest>(false, handle, path, url);
std::shared_ptr<DownloadRequest> request =
std::make_shared<DownloadRequest>(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<std::mutex> 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);
}
}
}
+4
View File
@@ -26,6 +26,7 @@
#include <memory>
#include <cstdint>
#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;
+3
View File
@@ -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<bool> cancelled = false;
handle_t handle;
const std::string downloadPath;
+74 -1
View File
@@ -127,6 +127,31 @@ private:
std::vector<std::string> 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");
+2 -1
View File
@@ -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?
+5
View File
@@ -24,5 +24,10 @@ export default tseslint.config(
{ allowConstantExport: true },
],
},
server: {
proxy: {
'/var': 'http://localhost:8080'
}
}
},
)
+2 -1
View File
@@ -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
</DialogActions>
</DialogEx>
{/* Status of Tone3000 download */}
{/* Status of TONE3000 download */}
<Tone3000DownloadStatus zindex={2000}/>
{/* Fatal error mask */}
<Modal open={this.state.displayState === State.Error}
+1 -66
View File
@@ -23,6 +23,7 @@
import { pathConcat, pathParentDirectory, pathFileName } from "./FileUtils";
import { PiPedalModel } from "./PiPedalModel";
import { safeFilenameDecode } from "./SafeFilename";
export class ThumbnailType {
static Unknown = 0;
@@ -38,72 +39,6 @@ function getVersionSuffix(filePath: string): string | null {
return match ? match[1] : null;
}
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 getTrackTitle(pathname: string, metadata: AudioFileMetadata | null | undefined): string {
if (!metadata) {
return safeFilenameDecode(pathFileName(pathname));
+1
View File
@@ -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);
+1 -1
View File
@@ -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";
+34 -16
View File
@@ -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<HTMLButtonElement>) {
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(
<>
<DialogActions style={{ justifyContent: "stretch", width: "100%" }}>
<div style={{ display: "flex", flexFlow: "column nowrap", width: "100%", alignItems: "stretch", }}>
{isToobNamModelFile && (
{(isToobNamModelFile || isToobCabIrFile) && (
<div style={{
display: "flex", flexFlow: "row nowrap", justifyContent: "center",
alignItems: "center", width: "100%"
@@ -1540,7 +1559,12 @@ export default withStyles(
<Button
onClick={(e) => { this.handleTone3000Dialog(e); }}
>
Download model files from Tone3000
{
isToobNamModelFile
? "Download model files from TONE3000"
: "Download I/R files from TONE3000"
}
</Button>
<IconButtonEx tooltip="Help"
onClick={(e) => { 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 && (
<Tone3000DownloadDialog
onClose={() => this.setState({ openTone3000Dialog: false })}
downloadPath={this.state.currentDirectory}
onDownloadComplete={() => {
this.setState({ openTone3000Dialog: false });
this.requestFiles(this.state.navDirectory);
}}
/>
)}
{this.state.openTone3000Help && (
<Tone3000HelpDialog
open={this.state.openTone3000Help}
onClose={() => this.setState({ openTone3000Help: false })}
downloadType={isToobNamModelFile ? Tone3000DownloadType.Nam : Tone3000DownloadType.CabIr}
/>
)}
{this.state.openGuitarMlHelp && (
@@ -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 (
<div key={directoryTree.path} style={{flexGrow: 1}}>
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center" }}>
@@ -292,7 +299,7 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC
)
}
<Typography noWrap style={{ marginLeft: 8, marginTop: 8, marginBottom: 8, textAlign: "left", flexGrow: 1, flexShrink: 1 }} variant="body2">{directoryTree.name === "" ? "Home" : directoryTree.name}</Typography>
<Typography noWrap style={{ marginLeft: 8, marginTop: 8, marginBottom: 8, textAlign: "left", flexGrow: 1, flexShrink: 1 }} variant="body2">{name}</Typography>
</div>
</ButtonBase>
</div>
+3
View File
@@ -60,6 +60,9 @@ export class ObservableProperty<VALUE_TYPE> {
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.
+61 -54
View File
@@ -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<boolean> {
if (this.webSocket === undefined) {
return false;
}
try {
let result = await this.webSocket.request<boolean>("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<void> {
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<boolean> {
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<string> {
let result = new Promise<string>((resolve, reject) => {
try {
@@ -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)} />
</div>
+1
View File
@@ -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) => {
+1
View File
@@ -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) => {
+1
View File
@@ -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)} />
<UploadPresetDialog
+21 -7
View File
@@ -28,10 +28,12 @@ import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel';
//import TextFieldEx from './TextFieldEx';
import TextField from '@mui/material/TextField';
import {safeFilenameEncode, safeFilenameDecode} from "./SafeFilename";
export interface RenameDialogProps {
open: boolean,
useSafeFilenames: boolean,
defaultName: string,
acceptActionName: string,
title: string,
@@ -95,6 +97,12 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
let props = this.props;
let { open, defaultName, acceptActionName, onClose, onOk } = props;
let name=defaultName;
if (props.useSafeFilenames)
{
name = safeFilenameDecode(name);
}
const handleClose = () => {
onClose();
};
@@ -102,13 +110,19 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
const handleOk = () => {
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<RenameDialog
type="text"
label={props.label ?? "Name"}
fullWidth
defaultValue={defaultName}
defaultValue={name}
inputRef={this.refText}
/>
</DialogContent>
+127
View File
@@ -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;
}
+24 -6
View File
@@ -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<TextInfoDialogPro
}}>
<div style={{ flex: "1 1 0px" }} />
<div className="text-info-dialog-content" style={{ flex: "1 1 auto",maxWidth: 650 }} >
<ReactMarkdown rehypePlugins={
<ReactMarkdown urlTransform={urlTransform} rehypePlugins={
[
[remarkGfm],
rehypeRaw,
[rehypeRaw],
[rehypeSanitize, extendedSchema],
[rehypeExternalLinks, {target: '_blank'}]
]}>
+118 -196
View File
@@ -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<void> {
private async handleTone3000Download(tone3000DownloadUrl: string, downloadType: Tone3000DownloadType): Promise<void> {
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<void> {
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 (
<DialogEx tag="tone3000-download"
open={true}
onClose={() => {
onClose();
}}
onEnterKey={() => { }}
>
<DialogContent>
<Typography style={{minWidth: 300, maxWidth: 300}} variant="body2">Downloading from Tone3000...</Typography>
{downloading &&
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<LinearProgress style={{ marginTop: 16, flexGrow: 1 }} />
</div>
}
</DialogContent>
<DialogActions >
{!downloading && (
<Button variant="dialogSecondary"
onClick={() => {
onClose();
}}>
Cancel
</Button>
)}
</DialogActions>
</DialogEx>
);
}
export function Tone3000DownloadStaus(
props: {
zindex: number;
}): JSX.Element {
const model = PiPedalModel.getInstance();
const [downloading, setDownloading] = useState<boolean>(false);
const [progress, setProgress] = useState<Tone3000DownloadProgress | null>(null);
const [downloading, setDownloading] = useState<boolean>(model.tone3000Downloading.get());
const [progress, setProgress] = useState<Tone3000DownloadProgress | null>(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 (
<Dialog
open={open}
onClose={() => { /* Do nothing */ }}
>
<DialogTitle>Downloading from Tone3000</DialogTitle>
<DialogContent>
<Typography noWrap variant="body2">{progress?.title ?? "\u00A0"}</Typography>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<DialogContent style={{ marginBottom: 0, paddingBottom: 0 }}>
<div style={{ display: "flex", flexFlow: "column nowrap", alignItems: "stretch" }}>
<Typography noWrap variant="body2">Downloading from TONE3000...</Typography>
<LinearProgress
style={{ marginTop: 16, flexGrow: 1 }}
variant={(progress?.total ?? 0) === 0 ? "indeterminate" : "determinate"}
style={{ visibility: progress !== null ? "visible" : "hidden", marginTop: 16 }}
variant="determinate"
value={(progress && progress.total !== 0) ? progress.progress / progress.total * 100 : 0}
/>
<Typography noWrap variant="caption"
style={{
width: 300, maxWidth: 300, paddingTop: 8, paddingLeft: 16, paddingRight: 16,
paddingBottom: 0, marginBottom: 0
}}
>{filename}</Typography>
</div>
</DialogContent>
<DialogActions>
<Button variant="dialogSecondary"
onClick={() => {
model.cancelTone3000Download();
model.closeTone3000DownloadPopup();
}}
>Cancel</Button>
</DialogActions>
@@ -0,0 +1,9 @@
// Must agree with Tone3000DownloadType.hpp
enum Tone3000DownloadType {
Nam = "nam",
CabIr = "cabir",
}
export default Tone3000DownloadType;
+77 -29
View File
@@ -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 (
<DialogEx
tag="tone3000-help"
fullWidth={true}
maxWidth="sm"
fullWidth={true}
maxWidth="sm"
onEnterKey={() => { onClose(); }}
onClose={() => { onClose(); }}
open={open}>
@@ -36,7 +37,7 @@ function Tone3000HelpDialog(props: {
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButtonEx>
<Typography id="tone3000-auth-dialog-title" noWrap component="div" sx={{ flexGrow: 1 }}>
<Typography id="tone3000-help-dialog-title" noWrap component="div" sx={{ flexGrow: 1 }}>
TONE3000 Help
</Typography>
@@ -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 && (
<>
<Typography variant="body1" component="div" display="block" >
The <Link href="https://www.tone3000.com" target="_blank">TONE3000 website</Link> 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 <i>TooB Cab IR</i>.
</Typography>
<Typography variant="body1" component="div" display="block">
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.</Typography>
<Typography variant="body1" component="div" display="block">
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
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> and
download model packs directly to your local device. You can then upload the model .zip
files to the PiPedal Server using the <strong><em>Upload</em></strong> button in the
<strong><em>File Properties</em></strong> 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.
</Typography>
</>
)}
{downloadType === Tone3000DownloadType.Nam && (
<>
<Typography variant="body1" component="div" display="block" >
The <Link href="https://www.tone3000.com" target="_blank">TONE3000 website</Link> 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.
</Typography>
<Typography variant="body1" component="div" display="block">
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.</Typography>
<Typography variant="body1" component="div" display="block">
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 <i>TooB Cab IR</i> to provide high-quality cabinet simulation for your NAM models. <i>TooB Cab IR</i> allows
you to browse and download I/R files from TONE3000 directly within the PiPedal app.
</Typography>
<Typography variant="body1" component="div" display="block">
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
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> 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 <strong><em>Upload</em></strong> button in the
<strong><em>File Properties</em></strong> dialog. PiPedal will
extract model files from the .zip archives automatically, so there is no need to extract them first.
</Typography>
</>
)}
<Typography variant="body1" component="div" display="block">
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' <Link href="https://www.neuralampmodeler.com/">Neural Amp Modeler library</Link>, which forms the foundation and
core of TooB Neural Amp Modeler and other NAM-based plugins.
</Typography>
<Typography variant="body1" component="div" display="block">
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 <Link href="https://www.tone3000.com/capture" target="_blank">TONE3000 Capture page</Link> for more details.
</Typography>
<Typography variant="body1" component="div" display="block" >
The <Link href="https://www.tone3000.com" target="_blank">TONE3000 website</Link> provides a
massive collection of neural amp models that can be used by TooB Neural Amp Modeler.
</Typography>
<Typography variant="body1" component="div" display="block">
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 <strong><em>Upload</em></strong> button. PiPedal
automatically extracts bundles of model files from zip archives, so manual extraction is unnecessary.
</Typography>
<Typography variant="body1" component="div" display="block">
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' <Link href="https://www.neuralampmodeler.com/">Neural Amp Modeler library</Link>, which forms the foundation and core of TooB Neural Amp Modeler and other NAM-based plugins.
</Typography>
<Typography variant="body1" component="div" display="block">
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 <Link href="https://www.tone3000.com/capture">TONE3000 website</Link> for more details.
</Typography>
<Typography variant="body1" component="div" display="block" >
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.
</Typography>
<Typography variant="body2" component="div" display="block" style={{ marginTop: 32 }}>
Privacy statement: PiPedal has no access to personal data used by the TONE3000 website. Please refer to
the <Link href="https://www.tone3000.com/privacy" target="_blank">TONE3000 privacy policy</Link> for
the <Link href="https://www.tone3000.com/privacy" target="_blank">TONE3000 Privacy Policy</Link> for
information on how your data is used by TONE3000.
</Typography>
</div>
+4
View File
@@ -14,6 +14,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: false,
},
'/var': {
target: 'http://localhost:8080',
changeOrigin: false,
},
}
}
})