Complete except for throttling bug.
This commit is contained in:
Vendored
+2
-1
@@ -103,7 +103,8 @@
|
|||||||
"locale": "cpp",
|
"locale": "cpp",
|
||||||
"stdfloat": "cpp",
|
"stdfloat": "cpp",
|
||||||
"text_encoding": "cpp",
|
"text_encoding": "cpp",
|
||||||
"forward_list": "cpp"
|
"forward_list": "cpp",
|
||||||
|
"*.bak": "cpp"
|
||||||
},
|
},
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"Alsa",
|
"Alsa",
|
||||||
|
|||||||
@@ -394,6 +394,59 @@ std::string HtmlHelper::generateEtag(const std::filesystem::path &path)
|
|||||||
static std::map<int,std::string> httpErrorStrings =
|
static std::map<int,std::string> httpErrorStrings =
|
||||||
{
|
{
|
||||||
{200,"OK"},
|
{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 << " " << ff->second);
|
||||||
}
|
}
|
||||||
return SS(errorCode);
|
return SS(errorCode << " Unknown error");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ namespace pipedal
|
|||||||
|
|
||||||
std::string ToLower(const std::string&value);
|
std::string ToLower(const std::string&value);
|
||||||
|
|
||||||
std::string safeFilenameToString(const std::string &filename);
|
std::string SafeFilenameToString(const std::string &filename);
|
||||||
std::string stringToSafeFilename(const std::string &name);
|
std::string StringToSafeFilename(const std::string &name);
|
||||||
|
|
||||||
|
std::string ShellEscape(const std::string &input);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,13 +243,13 @@ bool pipedal::HasWritePermissions(const std::filesystem::path &path)
|
|||||||
return access(path.c_str(), W_OK) == 0;
|
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()
|
static std::array<bool, 256> makeSfBits()
|
||||||
{
|
{
|
||||||
std::array<bool, 256> result;
|
std::array<bool, 256> result;
|
||||||
|
|
||||||
for (char c : SF_SPECIAL_CHARS)
|
for (char c : SAFE_FILENAME_SPECIAL_CHARS)
|
||||||
{
|
{
|
||||||
result[(unsigned char)c] = true;
|
result[(unsigned char)c] = true;
|
||||||
}
|
}
|
||||||
@@ -257,6 +257,10 @@ static std::array<bool, 256> makeSfBits()
|
|||||||
{
|
{
|
||||||
result[i] = true;
|
result[i] = true;
|
||||||
}
|
}
|
||||||
|
for (size_t i = 0; i < 0x20; ++i)
|
||||||
|
{
|
||||||
|
result[i] = true;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +289,7 @@ static char fromHexChars(char c1, char c2)
|
|||||||
return (char)uc;
|
return (char)uc;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string pipedal::safeFilenameToString(const std::string &filename)
|
std::string pipedal::SafeFilenameToString(const std::string &filename)
|
||||||
{
|
{
|
||||||
size_t ix = 0;
|
size_t ix = 0;
|
||||||
std::ostringstream os;
|
std::ostringstream os;
|
||||||
@@ -315,7 +319,7 @@ std::string pipedal::safeFilenameToString(const std::string &filename)
|
|||||||
}
|
}
|
||||||
return os.str();
|
return os.str();
|
||||||
}
|
}
|
||||||
std::string pipedal::stringToSafeFilename(const std::string &name)
|
std::string pipedal::StringToSafeFilename(const std::string &name)
|
||||||
{
|
{
|
||||||
std::ostringstream os;
|
std::ostringstream os;
|
||||||
for (char c : name)
|
for (char c : name)
|
||||||
@@ -338,3 +342,24 @@ std::string pipedal::stringToSafeFilename(const std::string &name)
|
|||||||
}
|
}
|
||||||
return os.str();
|
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
@@ -157,7 +157,7 @@ namespace pipedal
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
virtual void OnNotifyLv2StateChanged(uint64_t instanceId) = 0;
|
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 OnNotifyVusSubscription(const std::vector<VuUpdate> &updates) = 0;
|
||||||
virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) = 0;
|
virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) = 0;
|
||||||
virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) = 0;
|
virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) = 0;
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ else()
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
set (PIPEDAL_SOURCES
|
set (PIPEDAL_SOURCES
|
||||||
|
Curl.cpp Curl.hpp
|
||||||
|
Tone3000DownloadType.cpp Tone3000DownloadType.hpp
|
||||||
Tone3000DownloadProgress.cpp Tone3000DownloadProgress.hpp
|
Tone3000DownloadProgress.cpp Tone3000DownloadProgress.hpp
|
||||||
Tone3000Download.cpp Tone3000Download.hpp
|
Tone3000Download.cpp Tone3000Download.hpp
|
||||||
Tone3000Downloader.cpp Tone3000Downloader.hpp
|
Tone3000Downloader.cpp Tone3000Downloader.hpp
|
||||||
|
|||||||
+706
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -234,6 +234,7 @@ void PiPedalModel::Init(const PiPedalConfiguration &configuration)
|
|||||||
|
|
||||||
int64_t PiPedalModel::DownloadModelsFromTone3000(
|
int64_t PiPedalModel::DownloadModelsFromTone3000(
|
||||||
int64_t clientId,
|
int64_t clientId,
|
||||||
|
Tone3000DownloadType downloadType,
|
||||||
const std::string &downloadPath,
|
const std::string &downloadPath,
|
||||||
const std::string &tone3000Url)
|
const std::string &tone3000Url)
|
||||||
{
|
{
|
||||||
@@ -261,6 +262,7 @@ int64_t PiPedalModel::DownloadModelsFromTone3000(
|
|||||||
tone3000Downloader->SetListener(this);
|
tone3000Downloader->SetListener(this);
|
||||||
}
|
}
|
||||||
return tone3000Downloader->RequestDownload(
|
return tone3000Downloader->RequestDownload(
|
||||||
|
downloadType,
|
||||||
downloadPath,
|
downloadPath,
|
||||||
tone3000Url
|
tone3000Url
|
||||||
);
|
);
|
||||||
@@ -508,11 +510,10 @@ void PiPedalModel::OnNotifyLv2StateChanged(uint64_t instanceId)
|
|||||||
{
|
{
|
||||||
// a sent PATCH_Set, or an explicit state changed notification.
|
// a sent PATCH_Set, or an explicit state changed notification.
|
||||||
OnNotifyMaybeLv2StateChanged(instanceId);
|
OnNotifyMaybeLv2StateChanged(instanceId);
|
||||||
this->SetPresetChanged(-1, true, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plugin notified us that a path path property changed. The state *purrobably changed.
|
// 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.
|
// one or more received PATCH_Sets, which MAY change the state.
|
||||||
std::lock_guard<std::recursive_mutex> lock(mutex);
|
std::lock_guard<std::recursive_mutex> lock(mutex);
|
||||||
@@ -521,7 +522,7 @@ void PiPedalModel::OnNotifyMaybeLv2StateChanged(uint64_t instanceId)
|
|||||||
{
|
{
|
||||||
if (!audioHost)
|
if (!audioHost)
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
bool changed = this->audioHost->UpdatePluginState(*item);
|
bool changed = this->audioHost->UpdatePluginState(*item);
|
||||||
if (changed)
|
if (changed)
|
||||||
@@ -532,8 +533,11 @@ void PiPedalModel::OnNotifyMaybeLv2StateChanged(uint64_t instanceId)
|
|||||||
Lv2PluginState newState = item->lv2State();
|
Lv2PluginState newState = item->lv2State();
|
||||||
|
|
||||||
FireLv2StateChanged(instanceId, newState);
|
FireLv2StateChanged(instanceId, newState);
|
||||||
|
this->SetPresetChanged(-1, true, false);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PiPedalModel::SetInputVolume(float value)
|
void PiPedalModel::SetInputVolume(float value)
|
||||||
@@ -3403,3 +3407,8 @@ int64_t PiPedalModel::CopyPresetsToBank(int64_t bankInstanceId, const std::vecto
|
|||||||
return lastAdded;
|
return lastAdded;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string PiPedalModel::Tone3000ThumbnailDirectory()
|
||||||
|
{
|
||||||
|
return "/var/pipedal/tone3000_thumbnails";
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ namespace pipedal
|
|||||||
virtual void OnTone3000DownloadComplete(int64_t handle, const std::string &resultPath) override;
|
virtual void OnTone3000DownloadComplete(int64_t handle, const std::string &resultPath) override;
|
||||||
virtual void OnTone3000DownloadError(int64_t handle, const std::string &errorMessage) override;
|
virtual void OnTone3000DownloadError(int64_t handle, const std::string &errorMessage) override;
|
||||||
|
|
||||||
|
|
||||||
std::shared_ptr<Tone3000Downloader> tone3000Downloader;
|
std::shared_ptr<Tone3000Downloader> tone3000Downloader;
|
||||||
void CancelAudioRetry();
|
void CancelAudioRetry();
|
||||||
clock::time_point lastRestartTime = clock::time_point::min();
|
clock::time_point lastRestartTime = clock::time_point::min();
|
||||||
@@ -246,7 +247,7 @@ namespace pipedal
|
|||||||
|
|
||||||
private: // IAudioHostCallbacks
|
private: // IAudioHostCallbacks
|
||||||
virtual void OnNotifyLv2StateChanged(uint64_t instanceId) override;
|
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 OnNotifyVusSubscription(const std::vector<VuUpdate> &updates) override;
|
||||||
virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) override;
|
virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) override;
|
||||||
virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) override;
|
virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) override;
|
||||||
@@ -291,7 +292,13 @@ namespace pipedal
|
|||||||
Decrease
|
Decrease
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Storage &GetStorage() { return storage; }
|
Storage &GetStorage() { return storage; }
|
||||||
|
|
||||||
|
virtual std::string Tone3000ThumbnailDirectory() override;
|
||||||
|
|
||||||
|
|
||||||
bool GetHasWifi();
|
bool GetHasWifi();
|
||||||
|
|
||||||
void NextBank(Direction direction = Direction::Increase);
|
void NextBank(Direction direction = Direction::Increase);
|
||||||
@@ -301,6 +308,7 @@ namespace pipedal
|
|||||||
|
|
||||||
int64_t DownloadModelsFromTone3000(
|
int64_t DownloadModelsFromTone3000(
|
||||||
int64_t clientId,
|
int64_t clientId,
|
||||||
|
Tone3000DownloadType downloadType,
|
||||||
const std::string &downloadPath,
|
const std::string &downloadPath,
|
||||||
const std::string &tone3000Url);
|
const std::string &tone3000Url);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
#include "pch.h"
|
#include "pch.h"
|
||||||
|
|
||||||
|
#include "Curl.hpp"
|
||||||
#include "PiPedalSocket.hpp"
|
#include "PiPedalSocket.hpp"
|
||||||
#include "Updater.hpp"
|
#include "Updater.hpp"
|
||||||
#include "json.hpp"
|
#include "json.hpp"
|
||||||
@@ -51,11 +52,13 @@ class DownloadModelsFromTone3000Body {
|
|||||||
public:
|
public:
|
||||||
std::string downloadPath_;
|
std::string downloadPath_;
|
||||||
std::string tone3000Url_;
|
std::string tone3000Url_;
|
||||||
|
std::string downloadType_;
|
||||||
DECLARE_JSON_MAP(DownloadModelsFromTone3000Body);
|
DECLARE_JSON_MAP(DownloadModelsFromTone3000Body);
|
||||||
};
|
};
|
||||||
JSON_MAP_BEGIN(DownloadModelsFromTone3000Body)
|
JSON_MAP_BEGIN(DownloadModelsFromTone3000Body)
|
||||||
JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, downloadPath)
|
JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, downloadPath)
|
||||||
JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, tone3000Url)
|
JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, tone3000Url)
|
||||||
|
JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, downloadType)
|
||||||
JSON_MAP_END()
|
JSON_MAP_END()
|
||||||
|
|
||||||
|
|
||||||
@@ -693,6 +696,8 @@ private:
|
|||||||
int64_t subscriptionHandle;
|
int64_t subscriptionHandle;
|
||||||
int64_t instanceId;
|
int64_t instanceId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool PingTone3000Server();
|
||||||
std::vector<VuSubscription> activeVuSubscriptions;
|
std::vector<VuSubscription> activeVuSubscriptions;
|
||||||
|
|
||||||
struct PortMonitorSubscription
|
struct PortMonitorSubscription
|
||||||
@@ -1904,6 +1909,7 @@ public:
|
|||||||
pReader->read(&args);
|
pReader->read(&args);
|
||||||
auto result = this->model.DownloadModelsFromTone3000(
|
auto result = this->model.DownloadModelsFromTone3000(
|
||||||
this->clientId,
|
this->clientId,
|
||||||
|
StringToTone3000DownloadType(args.downloadType_),
|
||||||
args.downloadPath_,
|
args.downloadPath_,
|
||||||
args.tone3000Url_
|
args.tone3000Url_
|
||||||
);
|
);
|
||||||
@@ -1913,6 +1919,13 @@ public:
|
|||||||
int64_t handle = -1;
|
int64_t handle = -1;
|
||||||
pReader->read(&handle);
|
pReader->read(&handle);
|
||||||
model.CancelTone3000Download(clientId,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
|
else
|
||||||
{
|
{
|
||||||
@@ -2451,3 +2464,19 @@ std::shared_ptr<ISocketFactory> pipedal::MakePiPedalSocketFactory(PiPedalModel &
|
|||||||
{
|
{
|
||||||
return std::make_shared<PiPedalSocketFactory>(model);
|
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
@@ -2135,13 +2135,13 @@ static void AddFilesToResult(
|
|||||||
if (match && !name.starts_with("."))
|
if (match && !name.starts_with("."))
|
||||||
{
|
{
|
||||||
resultFiles.push_back(
|
resultFiles.push_back(
|
||||||
FileEntry(path, safeFilenameToString(name), false, false));
|
FileEntry(path, SafeFilenameToString(name), false, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (dir_entry.is_directory())
|
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();
|
modDirectoryPath = uploadsDirectory / fileProperty.directory();
|
||||||
result.breadcrumbs_.push_back({
|
result.breadcrumbs_.push_back({
|
||||||
modDirectoryPath.string(),
|
modDirectoryPath.string(),
|
||||||
safeFilenameToString(fs::path(fileProperty.directory()).filename().string())});
|
SafeFilenameToString(fs::path(fileProperty.directory()).filename().string())});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2361,7 +2361,7 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons
|
|||||||
while (iRp != rp.end())
|
while (iRp != rp.end())
|
||||||
{
|
{
|
||||||
cumulativePath /= (*iRp);
|
cumulativePath /= (*iRp);
|
||||||
result.breadcrumbs_.push_back({cumulativePath, safeFilenameToString(*iRp)});
|
result.breadcrumbs_.push_back({cumulativePath, SafeFilenameToString(*iRp)});
|
||||||
++iRp;
|
++iRp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2451,7 +2451,7 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const
|
|||||||
while (iAbsolutePath != fsAbsolutePath.end())
|
while (iAbsolutePath != fsAbsolutePath.end())
|
||||||
{
|
{
|
||||||
cumulativePath /= (*iAbsolutePath);
|
cumulativePath /= (*iAbsolutePath);
|
||||||
result.breadcrumbs_.push_back({cumulativePath.string(), safeFilenameToString(iAbsolutePath->string())});
|
result.breadcrumbs_.push_back({cumulativePath.string(), SafeFilenameToString(iAbsolutePath->string())});
|
||||||
++iAbsolutePath;
|
++iAbsolutePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+275
-158
@@ -30,6 +30,8 @@
|
|||||||
#include "Tone3000DownloadProgress.hpp"
|
#include "Tone3000DownloadProgress.hpp"
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include "HtmlHelper.hpp"
|
#include "HtmlHelper.hpp"
|
||||||
|
#include "Curl.hpp"
|
||||||
|
#include "PiPedalModel.hpp"
|
||||||
|
|
||||||
using namespace pipedal;
|
using namespace pipedal;
|
||||||
using namespace pipedal::tone3000;
|
using namespace pipedal::tone3000;
|
||||||
@@ -77,28 +79,7 @@ JSON_MAP_REFERENCE(Tone3000DownloadResult, errorMessage)
|
|||||||
JSON_MAP_END()
|
JSON_MAP_END()
|
||||||
|
|
||||||
static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"};
|
static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"};
|
||||||
|
static const std::filesystem::path TONE3000_THUMBNAIL_PATH{"/var/pipedal/tone3000_thumbnails"};
|
||||||
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 std::string getCurlErrorFromExitCode(int exitCode)
|
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 = "")
|
static std::string mdSanitize(const std::string &str, const std::string &illegalCharacters = "")
|
||||||
{
|
{
|
||||||
std::ostringstream os;
|
std::ostringstream os;
|
||||||
@@ -254,6 +142,80 @@ static std::string mdSanitize(const std::string &str, const std::string &illegal
|
|||||||
return os.str();
|
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_)
|
static std::string mdDate(const tone3000_time_point &date_)
|
||||||
{
|
{
|
||||||
std::ostringstream os;
|
std::ostringstream os;
|
||||||
@@ -376,7 +338,7 @@ namespace
|
|||||||
|
|
||||||
static std::vector<LicenseInfo> licenses{
|
static std::vector<LicenseInfo> licenses{
|
||||||
{.key = "t3k",
|
{.key = "t3k",
|
||||||
.displayName = "T3K",
|
.displayName = "T3K",
|
||||||
.url = "licenses/t3k_license.html",
|
.url = "licenses/t3k_license.html",
|
||||||
.licenseFlags = LicenseFlags::None},
|
.licenseFlags = LicenseFlags::None},
|
||||||
{.key = "cc-by",
|
{.key = "cc-by",
|
||||||
@@ -485,7 +447,7 @@ static std::string mdHref(const std::string &url)
|
|||||||
{
|
{
|
||||||
std::ostringstream os;
|
std::ostringstream os;
|
||||||
os << '\'';
|
os << '\'';
|
||||||
for (char c: url)
|
for (char c : url)
|
||||||
{
|
{
|
||||||
if (c == '\'')
|
if (c == '\'')
|
||||||
{
|
{
|
||||||
@@ -498,7 +460,7 @@ static std::string mdHref(const std::string &url)
|
|||||||
}
|
}
|
||||||
static std::string mdLicense(const std::string &license)
|
static std::string mdLicense(const std::string &license)
|
||||||
{
|
{
|
||||||
|
|
||||||
auto ff = licenseEnum.find(license);
|
auto ff = licenseEnum.find(license);
|
||||||
LicenseInfo licenseInfo;
|
LicenseInfo licenseInfo;
|
||||||
if (ff != licenseEnum.end())
|
if (ff != licenseEnum.end())
|
||||||
@@ -551,8 +513,7 @@ static std::map<std::string, std::string> platformEnumValues =
|
|||||||
{"ir", "I/R"},
|
{"ir", "I/R"},
|
||||||
{"aida-x", "Aida X"},
|
{"aida-x", "Aida X"},
|
||||||
{"aa-snapshot", "aa-snapshot"},
|
{"aa-snapshot", "aa-snapshot"},
|
||||||
{"proteus", "Proteus"}
|
{"proteus", "Proteus"}};
|
||||||
};
|
|
||||||
static std::string mdPlatform(const std::string &platform)
|
static std::string mdPlatform(const std::string &platform)
|
||||||
{
|
{
|
||||||
return mdEnum(platform, platformEnumValues);
|
return mdEnum(platform, platformEnumValues);
|
||||||
@@ -569,37 +530,30 @@ static std::string mdUser(const tone3000::Tone3000User &user)
|
|||||||
// clang-format on
|
// clang-format on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static void mdLink(std::ostream &f, const std::string &label, const std::string &url)
|
static void mdLink(std::ostream &f, const std::string &label, const std::string &url)
|
||||||
{
|
{
|
||||||
f << "[" << mdSanitize(label, "]") << "](" << mdSanitize(url, ")") << ")";
|
f << "[" << mdSanitize(label, "]") << "](" << mdSanitize(url, ")") << ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
static void mdImage(std::ostream &f, const std::string url)
|
static void writeReadme(const fs::path &path, const std::string &thumbnailUrl, const Tone3000Download &download)
|
||||||
{
|
|
||||||
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};
|
std::ofstream of{path};
|
||||||
if (!of.is_open())
|
if (!of.is_open())
|
||||||
{
|
{
|
||||||
throw std::runtime_error(SS("Unable to create file " << path));
|
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
|
// clang-format off
|
||||||
of <<
|
of <<
|
||||||
|
|
||||||
"<div id='tone3000_float' >\n"
|
"<div id='tone3000_float' >\n";
|
||||||
<< " <img id='tone3000_thumbnail' src='"
|
if (!thumbnailUrl.empty())
|
||||||
<< imageLink << "' alt='' />\n"
|
{
|
||||||
<< " <div id='tone3000_float_content' >\n"
|
|
||||||
|
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"
|
<< " <h3 id='tone3000_title'>" << mdSanitize(download.title()) << "</h3>\n"
|
||||||
<< " <div>\n"
|
<< " <div>\n"
|
||||||
<< " <p id='tone3000_subtitle'>\n"
|
<< " <p id='tone3000_subtitle'>\n"
|
||||||
@@ -607,10 +561,22 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download)
|
|||||||
<< " "
|
<< " "
|
||||||
<< mdUser(download.user())
|
<< mdUser(download.user())
|
||||||
<< " <br/>\n"
|
<< " <br/>\n"
|
||||||
<< " " << mdSizes(download.sizes()) << ", "
|
<< " ";
|
||||||
<< mdGear(download.gear()) << ", "
|
if (download.sizes().has_value()) {
|
||||||
<< mdPlatform(download.platform())
|
of
|
||||||
<< "<br/>\n";
|
<< 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() != "") {
|
if (download.license() != "") {
|
||||||
of << " License: " << mdLicense(download.license()) << "\n";
|
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(
|
std::string pipedal::tone3000::DownloadTone3000Bundle(
|
||||||
const std::filesystem::path &destinationFolder,
|
const std::filesystem::path &destinationFolder,
|
||||||
const std::string &downloadUrl,
|
const std::string &downloadUrl,
|
||||||
@@ -645,13 +677,18 @@ std::string pipedal::tone3000::DownloadTone3000Bundle(
|
|||||||
{
|
{
|
||||||
TemporaryFile downloadTemporaryFile{WEB_TEMP_DIR};
|
TemporaryFile downloadTemporaryFile{WEB_TEMP_DIR};
|
||||||
fs::path downloadPath = downloadTemporaryFile.Path();
|
fs::path downloadPath = downloadTemporaryFile.Path();
|
||||||
|
std::vector<std::string> headers;
|
||||||
|
|
||||||
std::string resultDirectory;
|
std::string resultDirectory;
|
||||||
Tone3000DownloadProgress progress;
|
Tone3000DownloadProgress progress;
|
||||||
progress.handle(handle);
|
progress.handle(handle);
|
||||||
progressCallback(progress);
|
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())
|
if (isCancelled())
|
||||||
{
|
{
|
||||||
@@ -677,40 +714,120 @@ std::string pipedal::tone3000::DownloadTone3000Bundle(
|
|||||||
progress.total(tone3000Download.models().size());
|
progress.total(tone3000Download.models().size());
|
||||||
progressCallback(progress);
|
progressCallback(progress);
|
||||||
|
|
||||||
if (tone3000Download.platform() != "nam")
|
if (tone3000Download.platform() != "nam" && tone3000Download.platform() != "ir")
|
||||||
{
|
{
|
||||||
throw std::runtime_error(SS("Unexpected file format. (platform=\"" << tone3000Download.platform() << "\")"));
|
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;
|
resultDirectory = bundlePath;
|
||||||
|
|
||||||
fs::create_directories(bundlePath);
|
fs::create_directories(bundlePath);
|
||||||
uint64_t nModel = 0;
|
|
||||||
|
std::vector<CurlDownloadRequest> requests;
|
||||||
|
|
||||||
for (auto &model : tone3000Download.models())
|
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");
|
else
|
||||||
downloadFile(model.model_url(), modelPath, isCancelled);
|
|
||||||
if (isCancelled())
|
|
||||||
{
|
{
|
||||||
return resultDirectory;
|
modelPath = bundlePath / SS(StringToSafeFilename(model.name()) << ".nam");
|
||||||
}
|
}
|
||||||
|
requests.push_back(CurlDownloadRequest{url : model.model_url(), outputFile : modelPath});
|
||||||
progress.progress(++nModel);
|
|
||||||
progressCallback(progress);
|
|
||||||
}
|
}
|
||||||
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;
|
return resultDirectory;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Tone3000DownloadResult::Tone3000DownloadResult()
|
Tone3000DownloadResult::Tone3000DownloadResult()
|
||||||
|
|||||||
@@ -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 << "<";
|
||||||
|
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())
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -31,9 +31,11 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include "Tone3000DownloadProgress.hpp"
|
#include "Tone3000DownloadProgress.hpp"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
namespace pipedal
|
namespace pipedal
|
||||||
{
|
{
|
||||||
|
|
||||||
namespace tone3000
|
namespace tone3000
|
||||||
{
|
{
|
||||||
using tone3000_time_point = std::string; // Date in ISO 8601 format. (C++ doesn't handle timezones)
|
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 name_;
|
||||||
std::string model_url_;
|
std::string model_url_;
|
||||||
tone3000_time_point created_at_;
|
tone3000_time_point created_at_;
|
||||||
std::string size_;
|
std::optional<std::string> size_;
|
||||||
std::string user_id_;
|
std::string user_id_;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -85,13 +87,13 @@ namespace pipedal
|
|||||||
tone3000_time_point created_at_;
|
tone3000_time_point created_at_;
|
||||||
tone3000_time_point updated_at_;
|
tone3000_time_point updated_at_;
|
||||||
std::string gear_;
|
std::string gear_;
|
||||||
std::vector<std::string> images_;
|
std::optional<std::vector<std::string>> images_;
|
||||||
bool is_public_ = true;
|
bool is_public_ = true;
|
||||||
std::vector<std::string> links_;
|
std::vector<std::string> links_;
|
||||||
int64_t model_count_ = 0;
|
int64_t model_count_ = 0;
|
||||||
int64_t favorites_count_ = 0;
|
int64_t favorites_count_ = 0;
|
||||||
std::string license_;
|
std::string license_;
|
||||||
std::vector<std::string> sizes_;
|
std::optional<std::vector<std::string>> sizes_;
|
||||||
Tone3000User user_;
|
Tone3000User user_;
|
||||||
std::vector<Tone3000Model> models_;
|
std::vector<Tone3000Model> models_;
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::NextHandle()
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::RequestDownload(
|
Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::RequestDownload(
|
||||||
|
Tone3000DownloadType downloadType,
|
||||||
const std::string &path,
|
const std::string &path,
|
||||||
const std::string &url)
|
const std::string &url)
|
||||||
{
|
{
|
||||||
@@ -67,7 +68,8 @@ Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::RequestDownload(
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lockGuard{this->mutex};
|
std::lock_guard<std::mutex> lockGuard{this->mutex};
|
||||||
handle = NextHandle();
|
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);
|
this->requestQueue.push_back(request);
|
||||||
request = nullptr;
|
request = nullptr;
|
||||||
@@ -144,6 +146,7 @@ Tone3000DownloadProgress Tone3000DownloaderImpl::GetDownloadStatus()
|
|||||||
|
|
||||||
void Tone3000DownloaderImpl::bgUpdateDownloadProgress(const Tone3000DownloadProgress &progress)
|
void Tone3000DownloaderImpl::bgUpdateDownloadProgress(const Tone3000DownloadProgress &progress)
|
||||||
{
|
{
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lockGuard{this->mutex};
|
std::lock_guard<std::mutex> lockGuard{this->mutex};
|
||||||
if (closed)
|
if (closed)
|
||||||
{
|
{
|
||||||
@@ -160,6 +163,7 @@ void Tone3000DownloaderImpl::bgUpdateDownloadProgress(const Tone3000DownloadProg
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string Tone3000DownloaderImpl::PerformDownload(
|
std::string Tone3000DownloaderImpl::PerformDownload(
|
||||||
|
Tone3000DownloadType downloadType,
|
||||||
const std::string &downloadPath,
|
const std::string &downloadPath,
|
||||||
const std::string &downloadUrl,
|
const std::string &downloadUrl,
|
||||||
int64_t downloadHandle,
|
int64_t downloadHandle,
|
||||||
@@ -199,7 +203,7 @@ std::string Tone3000DownloaderImpl::PerformDownload(
|
|||||||
if (isCancelled())
|
if (isCancelled())
|
||||||
{
|
{
|
||||||
// maybe there's a partial result.
|
// maybe there's a partial result.
|
||||||
return downloadedDirectory;
|
return downloadedDirectory;
|
||||||
}
|
}
|
||||||
return downloadedDirectory;
|
return downloadedDirectory;
|
||||||
}
|
}
|
||||||
@@ -249,14 +253,19 @@ void Tone3000DownloaderImpl::ThreadProc()
|
|||||||
|
|
||||||
try
|
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};
|
std::lock_guard lockGuard{mutex};
|
||||||
if (!request->cancelled.load() && currentListener)
|
if (currentListener)
|
||||||
{
|
{
|
||||||
currentListener->OnTone3000DownloadComplete(request->handle,outputPath);
|
currentListener->OnTone3000DownloadComplete(request->handle, outputPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include "Tone3000DownloadProgress.hpp"
|
#include "Tone3000DownloadProgress.hpp"
|
||||||
|
#include "Tone3000DownloadType.hpp"
|
||||||
|
|
||||||
namespace pipedal {
|
namespace pipedal {
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ namespace pipedal {
|
|||||||
const std::string &errorMessage
|
const std::string &errorMessage
|
||||||
) = 0;
|
) = 0;
|
||||||
|
|
||||||
|
virtual std::string Tone3000ThumbnailDirectory() = 0;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ namespace pipedal {
|
|||||||
virtual void SetListener(Listener*listener) = 0;
|
virtual void SetListener(Listener*listener) = 0;
|
||||||
|
|
||||||
virtual handle_t RequestDownload(
|
virtual handle_t RequestDownload(
|
||||||
|
Tone3000DownloadType downloadType,
|
||||||
const std::string &path,
|
const std::string &path,
|
||||||
const std::string &url
|
const std::string &url
|
||||||
) = 0;
|
) = 0;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ namespace pipedal {
|
|||||||
virtual void SetListener(Listener*listener) override;
|
virtual void SetListener(Listener*listener) override;
|
||||||
|
|
||||||
virtual handle_t RequestDownload(
|
virtual handle_t RequestDownload(
|
||||||
|
Tone3000DownloadType downloadType,
|
||||||
const std::string &path,
|
const std::string &path,
|
||||||
const std::string &url
|
const std::string &url
|
||||||
) override;
|
) override;
|
||||||
@@ -59,6 +60,7 @@ namespace pipedal {
|
|||||||
|
|
||||||
// Performs the actual download with cancellation support
|
// Performs the actual download with cancellation support
|
||||||
std::string PerformDownload(
|
std::string PerformDownload(
|
||||||
|
Tone3000DownloadType downloadType,
|
||||||
const std::string &downloadPath,
|
const std::string &downloadPath,
|
||||||
const std::string &downloadUrl,
|
const std::string &downloadUrl,
|
||||||
int64_t downloadHandle,
|
int64_t downloadHandle,
|
||||||
@@ -66,6 +68,7 @@ namespace pipedal {
|
|||||||
);
|
);
|
||||||
|
|
||||||
struct DownloadRequest {
|
struct DownloadRequest {
|
||||||
|
Tone3000DownloadType downloadType;
|
||||||
std::atomic<bool> cancelled = false;
|
std::atomic<bool> cancelled = false;
|
||||||
handle_t handle;
|
handle_t handle;
|
||||||
const std::string downloadPath;
|
const std::string downloadPath;
|
||||||
|
|||||||
+74
-1
@@ -127,6 +127,31 @@ private:
|
|||||||
std::vector<std::string> extensions;
|
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
|
class DownloadIntercept : public RequestHandler
|
||||||
{
|
{
|
||||||
PiPedalModel *model;
|
PiPedalModel *model;
|
||||||
@@ -146,6 +171,11 @@ public:
|
|||||||
}
|
}
|
||||||
std::string segment = request_uri.segment(1);
|
std::string segment = request_uri.segment(1);
|
||||||
|
|
||||||
|
if (segment == "tone3000_thumbnail")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (segment == "downloadMediaFile")
|
if (segment == "downloadMediaFile")
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -298,6 +328,30 @@ public:
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
std::string segment = request_uri.segment(1);
|
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")
|
if (segment == "downloadMediaFile")
|
||||||
{
|
{
|
||||||
fs::path path = request_uri.query("path");
|
fs::path path = request_uri.query("path");
|
||||||
@@ -432,7 +486,26 @@ public:
|
|||||||
{
|
{
|
||||||
std::string segment = request_uri.segment(1);
|
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");
|
fs::path path = request_uri.query("path");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
Refresh on download complete
|
Cab I/Rs: gear="ir"
|
||||||
|
|
||||||
// yyy: only if the property changed!.
|
// yyy: only if the property changed!.
|
||||||
Tooltip on File Property Select: add to release notes.
|
Tooltip on File Property Select: add to release notes.
|
||||||
sAFEfILEnAMES: RENAME, &C. Safe bank names?
|
sAFEfILEnAMES: RENAME, &C. Safe bank names?
|
||||||
|
|||||||
@@ -24,5 +24,10 @@ export default tseslint.config(
|
|||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/var': 'http://localhost:8080'
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1039,6 +1039,7 @@ export
|
|||||||
defaultName={this.model_.banks.get().getSelectedEntryName()}
|
defaultName={this.model_.banks.get().getSelectedEntryName()}
|
||||||
title="Bank Name"
|
title="Bank Name"
|
||||||
acceptActionName={this.state.renameBankDialogOpen ? "Rename" : "Save as"}
|
acceptActionName={this.state.renameBankDialogOpen ? "Rename" : "Save as"}
|
||||||
|
useSafeFilenames={false}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
this.setState({
|
this.setState({
|
||||||
renameBankDialogOpen: false,
|
renameBankDialogOpen: false,
|
||||||
@@ -1100,7 +1101,7 @@ export
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</DialogEx>
|
</DialogEx>
|
||||||
|
|
||||||
{/* Status of Tone3000 download */}
|
{/* Status of TONE3000 download */}
|
||||||
<Tone3000DownloadStatus zindex={2000}/>
|
<Tone3000DownloadStatus zindex={2000}/>
|
||||||
{/* Fatal error mask */}
|
{/* Fatal error mask */}
|
||||||
<Modal open={this.state.displayState === State.Error}
|
<Modal open={this.state.displayState === State.Error}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
import { pathConcat, pathParentDirectory, pathFileName } from "./FileUtils";
|
import { pathConcat, pathParentDirectory, pathFileName } from "./FileUtils";
|
||||||
import { PiPedalModel } from "./PiPedalModel";
|
import { PiPedalModel } from "./PiPedalModel";
|
||||||
|
import { safeFilenameDecode } from "./SafeFilename";
|
||||||
|
|
||||||
export class ThumbnailType {
|
export class ThumbnailType {
|
||||||
static Unknown = 0;
|
static Unknown = 0;
|
||||||
@@ -38,72 +39,6 @@ function getVersionSuffix(filePath: string): string | null {
|
|||||||
return match ? match[1] : 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 {
|
export function getTrackTitle(pathname: string, metadata: AudioFileMetadata | null | undefined): string {
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
return safeFilenameDecode(pathFileName(pathname));
|
return safeFilenameDecode(pathFileName(pathname));
|
||||||
|
|||||||
@@ -469,6 +469,7 @@ const BankDialog = withStyles(
|
|||||||
title={this.state.filenameSaveAs ? "Save Bank As" : "Bank Name"}
|
title={this.state.filenameSaveAs ? "Save Bank As" : "Bank Name"}
|
||||||
acceptActionName={this.state.filenameSaveAs ? "SAVE AS" : "RENAME"}
|
acceptActionName={this.state.filenameSaveAs ? "SAVE AS" : "RENAME"}
|
||||||
onClose={() => { this.setState({ filenameDialogOpen: false }) }}
|
onClose={() => { this.setState({ filenameDialogOpen: false }) }}
|
||||||
|
useSafeFilenames={false}
|
||||||
onOk={(text: string) => {
|
onOk={(text: string) => {
|
||||||
if (this.state.filenameSaveAs) {
|
if (this.state.filenameSaveAs) {
|
||||||
this.handleSaveAsOk(text);
|
this.handleSaveAsOk(text);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import ButtonBase from '@mui/material/ButtonBase'
|
|||||||
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
||||||
import { PedalboardItem } from './Pedalboard';
|
import { PedalboardItem } from './Pedalboard';
|
||||||
import { isDarkMode } from './DarkMode';
|
import { isDarkMode } from './DarkMode';
|
||||||
import {safeFilenameDecode} from './AudioFileMetadata';
|
import {safeFilenameDecode} from './SafeFilename';
|
||||||
|
|
||||||
|
|
||||||
import { withStyles } from "tss-react/mui";
|
import { withStyles } from "tss-react/mui";
|
||||||
|
|||||||
@@ -67,11 +67,17 @@ import OkCancelDialog from './OkCancelDialog';
|
|||||||
import HomeIcon from '@mui/icons-material/Home';
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog';
|
import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog';
|
||||||
import { getAlbumArtUri, getTrackTitle } from './AudioFileMetadata';
|
import { getAlbumArtUri, getTrackTitle } from './AudioFileMetadata';
|
||||||
import { Tone3000DownloadDialog } from './Tone3000Dialog';
|
import Tone3000DownloadType from './Tone3000DownloadType';
|
||||||
|
|
||||||
|
|
||||||
const ToobNamModelFileUrl = "http://two-play.com/plugins/toob-nam#modelFile";
|
const ToobNamModelFileUrl = "http://two-play.com/plugins/toob-nam#modelFile";
|
||||||
const ToobMlModelFileUrl = "http://two-play.com/plugins/toob-ml#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_TICK_DELAY = 30;
|
||||||
const AUTOSCROLL_THRESHOLD = 48;
|
const AUTOSCROLL_THRESHOLD = 48;
|
||||||
@@ -171,7 +177,6 @@ export interface FilePropertyDialogState {
|
|||||||
previousSelection: string;
|
previousSelection: string;
|
||||||
multiSelect: boolean,
|
multiSelect: boolean,
|
||||||
selectedFiles: string[],
|
selectedFiles: string[],
|
||||||
openTone3000Dialog: boolean,
|
|
||||||
openTone3000Help: boolean,
|
openTone3000Help: boolean,
|
||||||
openGuitarMlHelp: boolean,
|
openGuitarMlHelp: boolean,
|
||||||
|
|
||||||
@@ -249,7 +254,6 @@ export default withStyles(
|
|||||||
previousSelection: this.props.selectedFile,
|
previousSelection: this.props.selectedFile,
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
openTone3000Dialog: false,
|
|
||||||
openTone3000Help: false,
|
openTone3000Help: false,
|
||||||
openGuitarMlHelp: false
|
openGuitarMlHelp: false
|
||||||
};
|
};
|
||||||
@@ -1086,7 +1090,21 @@ export default withStyles(
|
|||||||
handleTone3000Dialog(e: React.MouseEvent<HTMLButtonElement>) {
|
handleTone3000Dialog(e: React.MouseEvent<HTMLButtonElement>) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
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() {
|
render() {
|
||||||
const isTracksDirectory = this.isTracksDirectory();
|
const isTracksDirectory = this.isTracksDirectory();
|
||||||
const isToobNamModelFile = this.props.fileProperty.patchProperty === ToobNamModelFileUrl;
|
const isToobNamModelFile = this.props.fileProperty.patchProperty === ToobNamModelFileUrl;
|
||||||
|
const isToobCabIrFile = ToobCabIrFileUrls.includes(this.props.fileProperty.patchProperty);
|
||||||
const isToobMLModelFile = this.props.fileProperty.patchProperty === ToobMlModelFileUrl;
|
const isToobMLModelFile = this.props.fileProperty.patchProperty === ToobMlModelFileUrl;
|
||||||
|
|
||||||
const classes = withStyles.getClasses(this.props);
|
const classes = withStyles.getClasses(this.props);
|
||||||
@@ -1532,7 +1551,7 @@ export default withStyles(
|
|||||||
<>
|
<>
|
||||||
<DialogActions style={{ justifyContent: "stretch", width: "100%" }}>
|
<DialogActions style={{ justifyContent: "stretch", width: "100%" }}>
|
||||||
<div style={{ display: "flex", flexFlow: "column nowrap", width: "100%", alignItems: "stretch", }}>
|
<div style={{ display: "flex", flexFlow: "column nowrap", width: "100%", alignItems: "stretch", }}>
|
||||||
{isToobNamModelFile && (
|
{(isToobNamModelFile || isToobCabIrFile) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", flexFlow: "row nowrap", justifyContent: "center",
|
display: "flex", flexFlow: "row nowrap", justifyContent: "center",
|
||||||
alignItems: "center", width: "100%"
|
alignItems: "center", width: "100%"
|
||||||
@@ -1540,7 +1559,12 @@ export default withStyles(
|
|||||||
<Button
|
<Button
|
||||||
onClick={(e) => { this.handleTone3000Dialog(e); }}
|
onClick={(e) => { this.handleTone3000Dialog(e); }}
|
||||||
>
|
>
|
||||||
Download model files from Tone3000
|
{
|
||||||
|
isToobNamModelFile
|
||||||
|
? "Download model files from TONE3000"
|
||||||
|
: "Download I/R files from TONE3000"
|
||||||
|
}
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
<IconButtonEx tooltip="Help"
|
<IconButtonEx tooltip="Help"
|
||||||
onClick={(e) => { this.handleTone3000Help(e); }} aria-label="help" edge="end" color="inherit" style={{ opacity: 0.6, marginLeft: 8 }}
|
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 }); }}
|
onClose={() => { this.setState({ newFolderDialogOpen: false }); }}
|
||||||
title="New folder"
|
title="New folder"
|
||||||
acceptActionName="OK"
|
acceptActionName="OK"
|
||||||
|
useSafeFilenames={true}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1721,6 +1746,7 @@ export default withStyles(
|
|||||||
onOk={(newName) => { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }}
|
onOk={(newName) => { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }}
|
||||||
onClose={() => { this.setState({ renameDialogOpen: false }); }}
|
onClose={() => { this.setState({ renameDialogOpen: false }); }}
|
||||||
acceptActionName="OK"
|
acceptActionName="OK"
|
||||||
|
useSafeFilenames={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)
|
)
|
||||||
@@ -1733,6 +1759,7 @@ export default withStyles(
|
|||||||
open={this.state.moveDialogOpen || this.state.copyDialogOpen}
|
open={this.state.moveDialogOpen || this.state.copyDialogOpen}
|
||||||
dialogTitle={this.state.moveDialogOpen ? "Move to" : "Copy Link to"}
|
dialogTitle={this.state.moveDialogOpen ? "Move to" : "Copy Link to"}
|
||||||
uiFileProperty={this.props.fileProperty}
|
uiFileProperty={this.props.fileProperty}
|
||||||
|
useSafeFilenames={true}
|
||||||
defaultPath={this.getDefaultPath()}
|
defaultPath={this.getDefaultPath()}
|
||||||
selectedFile={this.state.selectedFile}
|
selectedFile={this.state.selectedFile}
|
||||||
excludeDirectory={
|
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 && (
|
{this.state.openTone3000Help && (
|
||||||
<Tone3000HelpDialog
|
<Tone3000HelpDialog
|
||||||
open={this.state.openTone3000Help}
|
open={this.state.openTone3000Help}
|
||||||
onClose={() => this.setState({ openTone3000Help: false })}
|
onClose={() => this.setState({ openTone3000Help: false })}
|
||||||
|
downloadType={isToobNamModelFile ? Tone3000DownloadType.Nam : Tone3000DownloadType.CabIr}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{this.state.openGuitarMlHelp && (
|
{this.state.openGuitarMlHelp && (
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import FolderIcon from '@mui/icons-material/Folder';
|
|||||||
import HomeIcon from '@mui/icons-material/Home';
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
import { isDarkMode } from './DarkMode';
|
import { isDarkMode } from './DarkMode';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import {safeFilenameDecode} from './SafeFilename';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ export interface FilePropertyDirectorySelectDialogProps {
|
|||||||
defaultPath: string,
|
defaultPath: string,
|
||||||
excludeDirectory: string,
|
excludeDirectory: string,
|
||||||
selectedFile: string,
|
selectedFile: string,
|
||||||
|
useSafeFilenames: boolean,
|
||||||
onOk: (path: string) => void,
|
onOk: (path: string) => void,
|
||||||
onClose: () => 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)";
|
selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)";
|
||||||
}
|
}
|
||||||
let showToggle = directoryTree.canExpand() && directoryTree.path.length !== 0;
|
let showToggle = directoryTree.canExpand() && directoryTree.path.length !== 0;
|
||||||
|
let name = directoryTree.name === "" ? "Home" : directoryTree.name;
|
||||||
|
if (this.props.useSafeFilenames) {
|
||||||
|
name = safeFilenameDecode(name);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={directoryTree.path} style={{flexGrow: 1}}>
|
<div key={directoryTree.path} style={{flexGrow: 1}}>
|
||||||
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center" }}>
|
<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>
|
</div>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export class ObservableProperty<VALUE_TYPE> {
|
|||||||
|
|
||||||
set(value: VALUE_TYPE) : void
|
set(value: VALUE_TYPE) : void
|
||||||
{
|
{
|
||||||
|
if (this._value_type === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._value_type = value;
|
this._value_type = value;
|
||||||
|
|
||||||
let t = this._on_changed_handlers; // take an copy in case removes happen while iteratiing.
|
let t = this._on_changed_handlers; // take an copy in case removes happen while iteratiing.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import PluginClass from './PluginClass';
|
|||||||
import ScreenOrientation from './ScreenOrientation';
|
import ScreenOrientation from './ScreenOrientation';
|
||||||
import PiPedalSocket, { PiPedalMessageHeader } from './PiPedalSocket';
|
import PiPedalSocket, { PiPedalMessageHeader } from './PiPedalSocket';
|
||||||
import {Tone3000DownloadHandler} from './Tone3000Dialog';
|
import {Tone3000DownloadHandler} from './Tone3000Dialog';
|
||||||
|
import Tone3000DownloadType from './Tone3000DownloadType';
|
||||||
import { nullCast } from './Utility'
|
import { nullCast } from './Utility'
|
||||||
import { JackConfiguration, JackChannelSelection } from './Jack';
|
import { JackConfiguration, JackChannelSelection } from './Jack';
|
||||||
import { BankIndex } from './Banks';
|
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(
|
async downloadModelsFromTone3000(
|
||||||
tone3000DownloadUrl: string,
|
tone3000DownloadUrl: string,
|
||||||
downloadPath: string
|
downloadPath: string,
|
||||||
|
downloadType: Tone3000DownloadType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!this.webSocket) {
|
if (!this.webSocket) {
|
||||||
@@ -671,50 +686,59 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
this.webSocket.send("downloadModelsFromTone3000",
|
this.webSocket.send("downloadModelsFromTone3000",
|
||||||
{
|
{
|
||||||
downloadPath: downloadPath,
|
downloadPath: downloadPath,
|
||||||
tone3000Url: tone3000DownloadUrl
|
tone3000Url: tone3000DownloadUrl,
|
||||||
|
downloadType: downloadType
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showAlert(getErrorMessage(error));
|
this.showAlert(getErrorMessage(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cancelTone3000Download(): void {
|
private cancelTone3000Download(): void {
|
||||||
let downloadProgress = this.tone3000DownloadProgress.get();
|
let downloadProgress = this.tone3000DownloadProgress.get();
|
||||||
if (downloadProgress === null) {
|
if (downloadProgress !== null) {
|
||||||
return;
|
if (downloadProgress.handle !== -1) {
|
||||||
}
|
if (!this.webSocket) {
|
||||||
if (downloadProgress.handle !== -1) {
|
this.tone3000DownloadProgress.set(null);
|
||||||
if (!this.webSocket) {
|
this.tone3000Downloading.set(false);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
this.webSocket.send("cancelTone3000Download",downloadProgress.handle);
|
||||||
}
|
}
|
||||||
this.webSocket.send("cancelTone3000Download",downloadProgress.handle);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showTone3000DownloadDialog(
|
showTone3000DownloadPopup(
|
||||||
downloadPath: string,
|
downloadType: Tone3000DownloadType,
|
||||||
onClosed: () => void,
|
downloadPath: string
|
||||||
onDownloadStarted: () => void,
|
|
||||||
onDownloadComplete: () => void
|
|
||||||
|
|
||||||
): void {
|
): void {
|
||||||
if (this.tone3000DownloadHandler === null) {
|
if (this.tone3000DownloadHandler === null) {
|
||||||
this.tone3000DownloadHandler = new Tone3000DownloadHandler(this);
|
this.tone3000DownloadHandler = new Tone3000DownloadHandler(this);
|
||||||
}
|
}
|
||||||
this.tone3000DownloadHandler.launchTone3000Dialog(
|
this.tone3000DownloadHandler.launchTone3000Popup(
|
||||||
|
downloadType,
|
||||||
downloadPath,
|
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) {
|
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 {
|
private onTone3000DownloadStarted(handle: number, title: string): void {
|
||||||
this.tone3000Downloading.set(true);
|
this.tone3000Downloading.set(true);
|
||||||
}
|
}
|
||||||
@@ -1069,12 +1093,14 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
|
|
||||||
private onTone3000DownloadComplete(resultPath: string): void {
|
private onTone3000DownloadComplete(resultPath: string): void {
|
||||||
this.tone3000Downloading.set(false);
|
this.tone3000Downloading.set(false);
|
||||||
|
this.tone3000DownloadProgress.set(null);
|
||||||
this.onTone3000DownloadCompleteEvent.fire(resultPath);
|
this.onTone3000DownloadCompleteEvent.fire(resultPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTone3000DownloadError(handle: number, errorMessage: string): void {
|
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.tone3000Downloading.set(false);
|
||||||
|
this.tone3000DownloadProgress.set(null);
|
||||||
this.onTone3000DownloadCompleteEvent.fire("");
|
this.onTone3000DownloadCompleteEvent.fire("");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1090,9 +1116,12 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
return "var/" + url;
|
return "var/" + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(state: State) {
|
private setState(state: State) {
|
||||||
if (this.state.get() !== state) {
|
if (this.state.get() !== state) {
|
||||||
this.state.set(state);
|
this.state.set(state);
|
||||||
|
if (state === State.Error) {
|
||||||
|
this.closeTone3000DownloadPopup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1397,7 +1426,7 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
m = message.toString();
|
m = message.toString();
|
||||||
}
|
}
|
||||||
this.errorMessage.set(m);
|
this.errorMessage.set(m);
|
||||||
this.state.set(State.Error);
|
this.setState(State.Error);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,6 +1487,7 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
this.setError("");
|
this.setError("");
|
||||||
this.setState(State.Loading);
|
this.setState(State.Loading);
|
||||||
@@ -1465,7 +1495,7 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
this.requestConfig()
|
this.requestConfig()
|
||||||
.then((succeeded) => {
|
.then((succeeded) => {
|
||||||
if (succeeded) {
|
if (succeeded) {
|
||||||
this.state.set(State.Ready);
|
this.setState(State.Ready);
|
||||||
if (this.androidHost) {
|
if (this.androidHost) {
|
||||||
this.hostVersion = new HostVersion(this.androidHost.getHostVersion());
|
this.hostVersion = new HostVersion(this.androidHost.getHostVersion());
|
||||||
if (!this.hostVersion.lessThan(1, 1, 16)) {
|
if (!this.hostVersion.lessThan(1, 1, 16)) {
|
||||||
@@ -2857,29 +2887,6 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
document.body.removeChild(link);
|
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> {
|
uploadUserFile(uploadPage: string, file: File, contentType: string = "application/octet-stream", abortController?: AbortController): Promise<string> {
|
||||||
let result = new Promise<string>((resolve, reject) => {
|
let result = new Promise<string>((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ const PluginPresetSelector =
|
|||||||
title="Rename"
|
title="Rename"
|
||||||
defaultName={this.state.renameDialogDefaultName}
|
defaultName={this.state.renameDialogDefaultName}
|
||||||
acceptActionName={this.state.renameDialogActionName}
|
acceptActionName={this.state.renameDialogActionName}
|
||||||
|
useSafeFilenames={false}
|
||||||
onClose={() => this.handleRenameDialogClose()}
|
onClose={() => this.handleRenameDialogClose()}
|
||||||
onOk={(name: string) => this.handleRenameDialogOk(name)} />
|
onOk={(name: string) => this.handleRenameDialogOk(name)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ const PluginPresetsDialog = withStyles(
|
|||||||
title="Rename"
|
title="Rename"
|
||||||
open={this.state.renameOpen}
|
open={this.state.renameOpen}
|
||||||
defaultName={this.getSelectedName()}
|
defaultName={this.getSelectedName()}
|
||||||
|
useSafeFilenames={false}
|
||||||
acceptActionName={"Rename"}
|
acceptActionName={"Rename"}
|
||||||
onClose={() => { this.setState({ renameOpen: false }) }}
|
onClose={() => { this.setState({ renameOpen: false }) }}
|
||||||
onOk={(text: string) => {
|
onOk={(text: string) => {
|
||||||
|
|||||||
@@ -526,6 +526,7 @@ const PresetDialog = withStyles(
|
|||||||
title="Rename"
|
title="Rename"
|
||||||
open={this.state.renameOpen}
|
open={this.state.renameOpen}
|
||||||
defaultName={this.getSelectedName()}
|
defaultName={this.getSelectedName()}
|
||||||
|
useSafeFilenames={false}
|
||||||
acceptActionName={"Rename"}
|
acceptActionName={"Rename"}
|
||||||
onClose={() => { this.setState({ renameOpen: false }) }}
|
onClose={() => { this.setState({ renameOpen: false }) }}
|
||||||
onOk={(text: string) => {
|
onOk={(text: string) => {
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ const PresetSelector =
|
|||||||
title={this.state.renameDialogTitle}
|
title={this.state.renameDialogTitle}
|
||||||
defaultName={this.state.renameDialogDefaultName}
|
defaultName={this.state.renameDialogDefaultName}
|
||||||
acceptActionName={this.state.renameDialogActionName}
|
acceptActionName={this.state.renameDialogActionName}
|
||||||
|
useSafeFilenames={false}
|
||||||
onClose={() => this.handleRenameDialogClose()}
|
onClose={() => this.handleRenameDialogClose()}
|
||||||
onOk={(name: string) => this.handleRenameDialogOk(name)} />
|
onOk={(name: string) => this.handleRenameDialogOk(name)} />
|
||||||
<UploadPresetDialog
|
<UploadPresetDialog
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ import ResizeResponsiveComponent from './ResizeResponsiveComponent';
|
|||||||
import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel';
|
import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel';
|
||||||
//import TextFieldEx from './TextFieldEx';
|
//import TextFieldEx from './TextFieldEx';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import {safeFilenameEncode, safeFilenameDecode} from "./SafeFilename";
|
||||||
|
|
||||||
|
|
||||||
export interface RenameDialogProps {
|
export interface RenameDialogProps {
|
||||||
open: boolean,
|
open: boolean,
|
||||||
|
useSafeFilenames: boolean,
|
||||||
defaultName: string,
|
defaultName: string,
|
||||||
acceptActionName: string,
|
acceptActionName: string,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -95,6 +97,12 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
|
|||||||
let props = this.props;
|
let props = this.props;
|
||||||
let { open, defaultName, acceptActionName, onClose, onOk } = props;
|
let { open, defaultName, acceptActionName, onClose, onOk } = props;
|
||||||
|
|
||||||
|
let name=defaultName;
|
||||||
|
if (props.useSafeFilenames)
|
||||||
|
{
|
||||||
|
name = safeFilenameDecode(name);
|
||||||
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -102,13 +110,19 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
|
|||||||
const handleOk = () => {
|
const handleOk = () => {
|
||||||
let text = nullCast(this.refText.current).value;
|
let text = nullCast(this.refText.current).value;
|
||||||
text = text.trim();
|
text = text.trim();
|
||||||
try {
|
if (props.useSafeFilenames)
|
||||||
this.checkForIllegalCharacters(text);
|
|
||||||
} catch (e:any)
|
|
||||||
{
|
{
|
||||||
let model:PiPedalModel = PiPedalModelFactory.getInstance();
|
text = safeFilenameEncode(text);
|
||||||
model.showAlert(e.toString());
|
}
|
||||||
return;
|
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;
|
if (text.length === 0 && props.allowEmpty !== true) return;
|
||||||
onOk(text);
|
onOk(text);
|
||||||
@@ -154,7 +168,7 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
|
|||||||
type="text"
|
type="text"
|
||||||
label={props.label ?? "Name"}
|
label={props.label ?? "Name"}
|
||||||
fullWidth
|
fullWidth
|
||||||
defaultValue={defaultName}
|
defaultValue={name}
|
||||||
inputRef={this.refText}
|
inputRef={this.refText}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import { PiPedalModel, PiPedalModelFactory, State } from './PiPedalModel';
|
|||||||
import AppBar from '@mui/material/AppBar';
|
import AppBar from '@mui/material/AppBar';
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
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 rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize, {defaultSchema} from 'rehype-sanitize';
|
import rehypeSanitize, {defaultSchema} from 'rehype-sanitize';
|
||||||
import rehypeExternalLinks from 'rehype-external-links'
|
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
|
// Extend the default schema to allow target and rel attributes on anchor tags
|
||||||
const extendedSchema = {
|
const extendedSchema = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
attributes: {
|
tagNames: Array.from(new Set([...(defaultSchema.tagNames ?? []), 'img'])),
|
||||||
...defaultSchema.attributes,
|
protocols: {
|
||||||
a: [...(defaultSchema.attributes?.a || []), 'target', 'rel']
|
...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 DialogEx from './DialogEx';
|
||||||
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
|
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
|
||||||
@@ -181,10 +199,10 @@ const TextInfoDialog = class extends ResizeResponsiveComponent<TextInfoDialogPro
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ flex: "1 1 0px" }} />
|
<div style={{ flex: "1 1 0px" }} />
|
||||||
<div className="text-info-dialog-content" style={{ flex: "1 1 auto",maxWidth: 650 }} >
|
<div className="text-info-dialog-content" style={{ flex: "1 1 auto",maxWidth: 650 }} >
|
||||||
<ReactMarkdown rehypePlugins={
|
<ReactMarkdown urlTransform={urlTransform} rehypePlugins={
|
||||||
[
|
[
|
||||||
[remarkGfm],
|
[remarkGfm],
|
||||||
rehypeRaw,
|
[rehypeRaw],
|
||||||
[rehypeSanitize, extendedSchema],
|
[rehypeSanitize, extendedSchema],
|
||||||
[rehypeExternalLinks, {target: '_blank'}]
|
[rehypeExternalLinks, {target: '_blank'}]
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
+118
-196
@@ -1,34 +1,27 @@
|
|||||||
import { JSX } from "@emotion/react/jsx-dev-runtime";
|
import { JSX } from "@emotion/react/jsx-dev-runtime";
|
||||||
import { PiPedalModel, State, getErrorMessage } from "./PiPedalModel";
|
import { PiPedalModel, State, getErrorMessage } from "./PiPedalModel";
|
||||||
import DialogEx from "./DialogEx";
|
|
||||||
import { Button, Typography } from "@mui/material";
|
import { Button, Typography } from "@mui/material";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import LinearProgress from "@mui/material/LinearProgress";
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
import Tone3000DownloadProgress from "./Tone3000DownloadProgress";
|
import Tone3000DownloadProgress from "./Tone3000DownloadProgress";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Tone3000DownloadType from "./Tone3000DownloadType";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type OnTone3000DownloadHandler = (tone3000DownloadUrl: string) => void;
|
export type OnTone3000DownloadHandler = (tone3000DownloadUrl: string) => void;
|
||||||
|
|
||||||
|
|
||||||
// used to check for online status.
|
// used to check for online status.
|
||||||
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
|
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
|
||||||
|
|
||||||
export class Tone3000DownloadHandler {
|
export class Tone3000DownloadHandler {
|
||||||
|
|
||||||
private async handleTone3000Download(tone3000DownloadUrl: string): Promise<void> {
|
private async handleTone3000Download(tone3000DownloadUrl: string, downloadType: Tone3000DownloadType): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Dialog can close. We'll use the following await to manage
|
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType);
|
||||||
// lifecycle from here onward.
|
|
||||||
this.stopTone3000DialogMonitor();
|
|
||||||
|
|
||||||
this.handleTone3000DownloadComplete();
|
|
||||||
|
|
||||||
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath);
|
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.handleTone3000DownloadError(getErrorMessage(e));
|
this.handleTone3000DownloadError(getErrorMessage(e));
|
||||||
@@ -39,6 +32,7 @@ export class Tone3000DownloadHandler {
|
|||||||
private messageEventListener: ((event: MessageEvent) => void) | null = null;
|
private messageEventListener: ((event: MessageEvent) => void) | null = null;
|
||||||
|
|
||||||
private model: PiPedalModel;
|
private model: PiPedalModel;
|
||||||
|
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
|
||||||
|
|
||||||
public constructor(model: PiPedalModel) {
|
public constructor(model: PiPedalModel) {
|
||||||
this.model = model;
|
this.model = model;
|
||||||
@@ -46,7 +40,7 @@ export class Tone3000DownloadHandler {
|
|||||||
if (event.data.type !== "tone3000Download") {
|
if (event.data.type !== "tone3000Download") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.handleTone3000Download(event.data.toneUrl as string);
|
this.handleTone3000Download(event.data.toneUrl as string, this.downloadType);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("message", this.messageEventListener);
|
window.addEventListener("message", this.messageEventListener);
|
||||||
@@ -61,7 +55,7 @@ export class Tone3000DownloadHandler {
|
|||||||
|
|
||||||
private popupWindow: Window | null = null;
|
private popupWindow: Window | null = null;
|
||||||
|
|
||||||
private appId: string = "pipedal_app3";
|
private appId: string = "pipedal_app4";
|
||||||
private redirectUrl(): string {
|
private redirectUrl(): string {
|
||||||
let hostname = window.location.hostname;
|
let hostname = window.location.hostname;
|
||||||
let port = window.location.port;
|
let port = window.location.port;
|
||||||
@@ -77,217 +71,136 @@ export class Tone3000DownloadHandler {
|
|||||||
return `${serverUrl}/public/handleTone3000download.html`;
|
return `${serverUrl}/public/handleTone3000download.html`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private tone3000SelectUrl(): string {
|
private tone3000SelectUrl(downloadType: Tone3000DownloadType): string {
|
||||||
return `https://www.tone3000.com/api/v1/select?app_id=${this.appId}&redirect_url=${this.redirectUrl()}`;
|
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() {
|
private handleTone3000DownloadComplete() {
|
||||||
let t = this.onDownloadCompleteCallback;
|
if (this.popupWindow) {
|
||||||
this.onClosedCallback = undefined;
|
if (!this.popupWindow.closed) {
|
||||||
this.onErrorCallback = undefined;
|
this.popupWindow.close();
|
||||||
this.onDownloadCompleteCallback = undefined;
|
}
|
||||||
if (t) {
|
this.popupWindow = null;
|
||||||
t();
|
|
||||||
}
|
}
|
||||||
this.stopTone3000DialogMonitor();
|
this.model.hideTone3000DownloadStatus();
|
||||||
|
|
||||||
|
}
|
||||||
|
closeTone3000Popup() {
|
||||||
|
this.handleTone3000DownloadComplete();
|
||||||
}
|
}
|
||||||
private handleTone3000DownloadError(errorMessage: string) {
|
private handleTone3000DownloadError(errorMessage: string) {
|
||||||
let t = this.onErrorCallback;
|
if (this.open)
|
||||||
this.onClosedCallback = undefined;
|
{
|
||||||
this.onErrorCallback = undefined;
|
this.handleTone3000DownloadComplete();
|
||||||
this.onDownloadCompleteCallback = undefined;
|
this.model.showAlert(errorMessage);
|
||||||
if (t) {
|
|
||||||
t(errorMessage);
|
|
||||||
}
|
|
||||||
this.stopTone3000DialogMonitor();
|
|
||||||
}
|
|
||||||
|
|
||||||
private tone3000DialogTimeout: number | undefined = undefined;
|
|
||||||
|
|
||||||
private stopTone3000DialogMonitor() {
|
|
||||||
if (this.tone3000DialogTimeout !== undefined) {
|
|
||||||
clearTimeout(this.tone3000DialogTimeout);
|
|
||||||
this.tone3000DialogTimeout = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 downloadPath: string = "";
|
||||||
private onClosedCallback: (() => void) | undefined = undefined;
|
private open = false;
|
||||||
private onErrorCallback: ((errorMessage: string) => void) | undefined = undefined;
|
|
||||||
private onDownloadStartedCallback: (() => void) | undefined = undefined;
|
|
||||||
private onDownloadCompleteCallback: (() => void) | undefined = undefined;
|
|
||||||
|
|
||||||
public launchTone3000Dialog(
|
private launchInstance = 0;
|
||||||
|
public async launchTone3000Popup(
|
||||||
|
downloadType: Tone3000DownloadType,
|
||||||
downloadPath: string,
|
downloadPath: string,
|
||||||
onClosed: () => void,
|
|
||||||
onError: (errorMessage: string) => void,
|
): Promise<void> {
|
||||||
onDownloadStarted: () => void,
|
|
||||||
onDownloadComplete: () => void
|
|
||||||
) {
|
|
||||||
this.closeTone3000Dialog();
|
this.closeTone3000Dialog();
|
||||||
|
// online
|
||||||
|
|
||||||
this.downloadPath = downloadPath;
|
this.downloadPath = downloadPath;
|
||||||
this.onClosedCallback = onClosed;
|
this.downloadType = downloadType;
|
||||||
this.onDownloadCompleteCallback = onDownloadComplete;
|
|
||||||
this.onDownloadStartedCallback = onDownloadStarted;
|
let popupWidth = Math.floor(window.innerWidth * 0.8);
|
||||||
this.onErrorCallback = onError;
|
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 {
|
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 =
|
this.popupWindow =
|
||||||
window.open(
|
window.open(
|
||||||
this.tone3000SelectUrl(),
|
this.tone3000SelectUrl(downloadType),
|
||||||
"popup",
|
"popup",
|
||||||
`innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes`
|
`innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!this.popupWindow) {
|
if (!this.popupWindow) {
|
||||||
console.error("Failed to open Tone3000 dialog popup window.");
|
console.error("Failed to open TONE3000 dialog popup window.");
|
||||||
this.handleTone3000DownloadError("Cannot open popup window.");
|
throw new Error("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.");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
// No procedure for detecting 404, so let's ping the server to see if it's reachable, and cancel if it's not.
|
||||||
// offline
|
try {
|
||||||
this.handleTone3000DownloadError("Not online. Unable to connect to Tone3000 servers.");
|
let response = await fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" });
|
||||||
return;
|
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..");
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
let pipedalServerCanReachTone3000 = false;
|
||||||
public closeTone3000Dialog(): void {
|
|
||||||
this.handleTone3000DialogClosed();
|
// check to see that the PiPedal server can reach TONE3000.
|
||||||
this.stopTone3000DialogMonitor();
|
|
||||||
if (this.popupWindow) {
|
|
||||||
try {
|
try {
|
||||||
|
pipedalServerCanReachTone3000 = await this.model.pingTone3000Server();
|
||||||
let t = this.popupWindow;
|
if (cancelled()) return;
|
||||||
this.popupWindow = null;
|
} catch (error) {
|
||||||
t.close();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error closing Tone3000 dialog popup:", e);
|
|
||||||
}
|
}
|
||||||
|
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(
|
export function Tone3000DownloadStaus(
|
||||||
props: {
|
props: {
|
||||||
zindex: number;
|
zindex: number;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const model = PiPedalModel.getInstance();
|
const model = PiPedalModel.getInstance();
|
||||||
const [downloading, setDownloading] = useState<boolean>(false);
|
const [downloading, setDownloading] = useState<boolean>(model.tone3000Downloading.get());
|
||||||
const [progress, setProgress] = useState<Tone3000DownloadProgress | null>(null);
|
const [progress, setProgress] = useState<Tone3000DownloadProgress | null>(model.tone3000DownloadProgress.get());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let onDownloadingChanged = (value: boolean) => {
|
let onDownloadingChanged = (value: boolean) => {
|
||||||
|
|
||||||
setDownloading(value);
|
setDownloading(value);
|
||||||
}
|
}
|
||||||
model.tone3000Downloading.addOnChangedHandler(onDownloadingChanged);
|
model.tone3000Downloading.addOnChangedHandler(onDownloadingChanged);
|
||||||
@@ -302,30 +215,39 @@ export function Tone3000DownloadStaus(
|
|||||||
model.tone3000DownloadProgress.removeOnChangedHandler(onProgressChanged);
|
model.tone3000DownloadProgress.removeOnChangedHandler(onProgressChanged);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let open = downloading && progress !== null && progress.title.length > 0;
|
let open = downloading;
|
||||||
if (model.state.get() !== State.Ready) {
|
if (model.state.get() !== State.Ready) {
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
let filename = progress?.title ?? "\u00A0";
|
||||||
|
if (filename === "") {
|
||||||
|
filename = "\u00A0";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => { /* Do nothing */ }}
|
onClose={() => { /* Do nothing */ }}
|
||||||
>
|
>
|
||||||
<DialogTitle>Downloading from Tone3000</DialogTitle>
|
<DialogContent style={{ marginBottom: 0, paddingBottom: 0 }}>
|
||||||
<DialogContent>
|
<div style={{ display: "flex", flexFlow: "column nowrap", alignItems: "stretch" }}>
|
||||||
<Typography noWrap variant="body2">{progress?.title ?? "\u00A0"}</Typography>
|
<Typography noWrap variant="body2">Downloading from TONE3000...</Typography>
|
||||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
|
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
style={{ marginTop: 16, flexGrow: 1 }}
|
style={{ visibility: progress !== null ? "visible" : "hidden", marginTop: 16 }}
|
||||||
variant={(progress?.total ?? 0) === 0 ? "indeterminate" : "determinate"}
|
variant="determinate"
|
||||||
value={(progress && progress.total !== 0) ? progress.progress / progress.total * 100 : 0}
|
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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button variant="dialogSecondary"
|
<Button variant="dialogSecondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
model.cancelTone3000Download();
|
model.closeTone3000DownloadPopup();
|
||||||
}}
|
}}
|
||||||
>Cancel</Button>
|
>Cancel</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
// Must agree with Tone3000DownloadType.hpp
|
||||||
|
enum Tone3000DownloadType {
|
||||||
|
Nam = "nam",
|
||||||
|
CabIr = "cabir",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default Tone3000DownloadType;
|
||||||
@@ -6,19 +6,20 @@ import IconButtonEx from "./IconButtonEx";
|
|||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Link from "@mui/material/Link";
|
import Link from "@mui/material/Link";
|
||||||
|
import Tone3000DownloadType from "./Tone3000DownloadType";
|
||||||
|
|
||||||
|
|
||||||
function Tone3000HelpDialog(props: {
|
function Tone3000HelpDialog(props: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
downloadType: Tone3000DownloadType
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { open, onClose } = props;
|
const { open, downloadType, onClose } = props;
|
||||||
return (
|
return (
|
||||||
<DialogEx
|
<DialogEx
|
||||||
tag="tone3000-help"
|
tag="tone3000-help"
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
onEnterKey={() => { onClose(); }}
|
onEnterKey={() => { onClose(); }}
|
||||||
onClose={() => { onClose(); }}
|
onClose={() => { onClose(); }}
|
||||||
open={open}>
|
open={open}>
|
||||||
@@ -36,7 +37,7 @@ function Tone3000HelpDialog(props: {
|
|||||||
<ArrowBackIcon style={{ width: 24, height: 24 }} />
|
<ArrowBackIcon style={{ width: 24, height: 24 }} />
|
||||||
</IconButtonEx>
|
</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
|
TONE3000 Help
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -49,35 +50,82 @@ function Tone3000HelpDialog(props: {
|
|||||||
maxWidth: 500, marginLeft: "auto", marginRight: "auto",
|
maxWidth: 500, marginLeft: "auto", marginRight: "auto",
|
||||||
display: "flex", flexFlow: "column nowrap", gap: 16, alignItems: "start",
|
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" >
|
<Typography variant="body1" component="div" display="block" >
|
||||||
The <Link href="https://www.tone3000.com" target="_blank">TONE3000 website</Link> provides a
|
When you click on the TONE3000 link, you will be taken to an external website. The TONE3000
|
||||||
massive collection of neural amp models that can be used by TooB Neural Amp Modeler.
|
website is not part of PiPedal and is not affiliated in any way with PiPedal.
|
||||||
</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.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body2" component="div" display="block" style={{ marginTop: 32 }}>
|
<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
|
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.
|
information on how your data is used by TONE3000.
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: false,
|
changeOrigin: false,
|
||||||
},
|
},
|
||||||
|
'/var': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user