Checkpoint

This commit is contained in:
Robin E.R. Davies
2026-02-05 11:51:43 -05:00
parent e5beb97466
commit aec88fa9f1
24 changed files with 949 additions and 306 deletions
+20
View File
@@ -21,6 +21,8 @@
#include <sstream>
#include <string>
#include <cstring>
#include "ss.hpp"
#include <map>
using namespace pipedal;
@@ -388,3 +390,21 @@ std::string HtmlHelper::generateEtag(const std::filesystem::path &path)
crc = crc64((uint8_t*)&fTime, sizeof(fTime));
return std::to_string(crc);
}
static std::map<int,std::string> httpErrorStrings =
{
{200,"OK"},
};
std::string HtmlHelper::httpErrorString(int errorCode)
{
auto ff = httpErrorStrings.find(errorCode);
if (ff != httpErrorStrings.end())
{
return SS(errorCode << " " << ff->second);
}
return SS(errorCode);
}
+2
View File
@@ -63,6 +63,8 @@ public:
static std::string generateEtag(const std::filesystem::path &path);
static std::string httpErrorString(int errorCode);
};
} // namespace.
+1 -1
View File
@@ -204,7 +204,7 @@ else()
endif()
set (PIPEDAL_SOURCES
Tone3000DownloadProgress.cpp Tone3000DownloadProgress.hpp
Tone3000Download.cpp Tone3000Download.hpp
Tone3000Downloader.cpp Tone3000Downloader.hpp
+54 -2
View File
@@ -239,9 +239,26 @@ int64_t PiPedalModel::DownloadModelsFromTone3000(
{
std::lock_guard<std::recursive_mutex> lock(mutex);
// Validate the download path
std::filesystem::path path{downloadPath};
if (!IsInUploadsDirectory(downloadPath))
{
throw PiPedalException("Invalid path: not in uploads directory.");
}
// Check for ".." in path components (security check)
for (const auto &component : path)
{
if (component == "..")
{
throw PiPedalException("Invalid path: contains '..' component.");
}
}
if (!tone3000Downloader)
{
tone3000Downloader = Tone3000Downloader::Create();
tone3000Downloader->SetListener(this);
}
return tone3000Downloader->RequestDownload(
downloadPath,
@@ -255,13 +272,49 @@ void PiPedalModel::CancelTone3000Download(
)
{
std::lock_guard<std::recursive_mutex> lock(mutex);
if (tone3000Downloader)
{
tone3000Downloader->CancelDownload(downloadHandle);
}
}
// Tone3000Downloader::Listener implementation
void PiPedalModel::OnStartTone3000Download(int64_t handle, const std::string &title)
{
std::lock_guard<std::recursive_mutex> lock(mutex);
for (auto subscriber : this->subscribers)
{
subscriber->OnTone3000DownloadStarted(handle, title);
}
}
void PiPedalModel::OnTone3000Progress(const Tone3000DownloadProgress &progress)
{
std::lock_guard<std::recursive_mutex> lock(mutex);
for (auto subscriber : this->subscribers)
{
subscriber->OnTone3000DownloadProgress(progress);
}
}
void PiPedalModel::OnTone3000DownloadComplete(int64_t handle, const std::string&resultPath)
{
std::lock_guard<std::recursive_mutex> lock(mutex);
for (auto subscriber : this->subscribers)
{
subscriber->OnTone3000DownloadComplete(handle,resultPath);
}
}
void PiPedalModel::OnTone3000DownloadError(int64_t handle, const std::string &errorMessage)
{
std::lock_guard<std::recursive_mutex> lock(mutex);
for (auto subscriber : this->subscribers)
{
subscriber->OnTone3000DownloadError(handle, errorMessage);
}
}
void PiPedalModel::LoadLv2PluginInfo()
{
@@ -317,7 +370,6 @@ void PiPedalModel::LoadLv2PluginInfo()
}
storage.SetPluginPresetIndexVersion(1);
}
void PiPedalModel::Load()
{
this->webRoot = configuration.GetWebRoot();
+13 -2
View File
@@ -41,6 +41,7 @@
#include "AtomConverter.hpp"
#include "FileEntry.hpp"
#include <unordered_map>
#include "Tone3000Downloader.hpp"
namespace pipedal
{
@@ -51,7 +52,6 @@ namespace pipedal
class Updater;
class AvahiService;
class Lv2PluginState;
class Tone3000Downloader;
class IPiPedalModelSubscriber
{
@@ -92,6 +92,11 @@ namespace pipedal
// virtual void OnPatchPropertyChanged(int64_t clientId, int64_t instanceId,const std::string& propertyUri,const json_variant& value) = 0;
virtual void OnErrorMessage(const std::string &message) = 0;
virtual void OnTone3000DownloadStarted(int64_t handle, const std::string &title) = 0;
virtual void OnTone3000DownloadProgress(const Tone3000DownloadProgress &progress) = 0;
virtual void OnTone3000DownloadComplete(int64_t handle, const std::string &resultPath) = 0;
virtual void OnTone3000DownloadError(int64_t handle, const std::string &errorMessage) = 0;
virtual void OnLv2PluginsChanging() = 0;
virtual void OnNetworkChanging(bool hotspotConnected) = 0;
@@ -102,7 +107,7 @@ namespace pipedal
class HotspotManager;
class PiPedalModel : private IAudioHostCallbacks
class PiPedalModel : private IAudioHostCallbacks, private Tone3000Downloader::Listener
{
public:
using clock = std::chrono::steady_clock;
@@ -114,6 +119,12 @@ namespace pipedal
private:
PedalboardItem *GetPedalboardItemForFileProperty(const UiFileProperty &fileProperty);
// Tone3000Downloader::Listener implementation
virtual void OnStartTone3000Download(int64_t handle, const std::string &title) override;
virtual void OnTone3000Progress(const Tone3000DownloadProgress &progress) override;
virtual void OnTone3000DownloadComplete(int64_t handle, const std::string &resultPath) override;
virtual void OnTone3000DownloadError(int64_t handle, const std::string &errorMessage) override;
std::shared_ptr<Tone3000Downloader> tone3000Downloader;
void CancelAudioRetry();
clock::time_point lastRestartTime = clock::time_point::min();
+72 -5
View File
@@ -24,6 +24,7 @@
#include "json.hpp"
#include "viewstream.hpp"
#include "PiPedalVersion.hpp"
#include "Tone3000Downloader.hpp"
#include <atomic>
#include <limits>
#include "Lv2Log.hpp"
@@ -46,6 +47,18 @@
using namespace std;
using namespace pipedal;
class DownloadModelsFromTone3000Body {
public:
std::string downloadPath_;
std::string tone3000Url_;
DECLARE_JSON_MAP(DownloadModelsFromTone3000Body);
};
JSON_MAP_BEGIN(DownloadModelsFromTone3000Body)
JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, downloadPath)
JSON_MAP_REFERENCE(DownloadModelsFromTone3000Body, tone3000Url)
JSON_MAP_END()
class CopyPresetsToBankBody {
public:
int64_t bankInstanceId_;
@@ -92,6 +105,32 @@ JSON_MAP_REFERENCE(DownloadTone3000Body, handle)
JSON_MAP_REFERENCE(DownloadTone3000Body, tone3000DownloadUrl)
JSON_MAP_END()
class Tone3000DownloadStartedBody {
public:
int64_t handle_;
std::string title_;
DECLARE_JSON_MAP(Tone3000DownloadStartedBody);
};
JSON_MAP_BEGIN(Tone3000DownloadStartedBody)
JSON_MAP_REFERENCE(Tone3000DownloadStartedBody, handle)
JSON_MAP_REFERENCE(Tone3000DownloadStartedBody, title)
JSON_MAP_END()
class Tone3000DownloadErrorBody {
public:
int64_t handle_;
std::string errorMessage_;
DECLARE_JSON_MAP(Tone3000DownloadErrorBody);
};
JSON_MAP_BEGIN(Tone3000DownloadErrorBody)
JSON_MAP_REFERENCE(Tone3000DownloadErrorBody, handle)
JSON_MAP_REFERENCE(Tone3000DownloadErrorBody, errorMessage)
JSON_MAP_END()
class PathPatchPropertyChangedBody
{
@@ -1861,10 +1900,6 @@ public:
}
else if (message == "downloadModelsFromTone3000")
{
struct DownloadModelsFromTone3000Body {
std::string downloadPath_;
std::string tone3000Url_;
};
DownloadModelsFromTone3000Body args;
pReader->read(&args);
auto result = this->model.DownloadModelsFromTone3000(
@@ -1873,6 +1908,11 @@ public:
args.tone3000Url_
);
this->Reply(replyTo,"downloadModelsFromTone3000",result);
} else if (message == "cancelTone3000Download")
{
int64_t handle = -1;
pReader->read(&handle);
model.CancelTone3000Download(clientId,handle);
}
else
{
@@ -1995,6 +2035,33 @@ private:
{
Send("onErrorMessage", message);
}
virtual void OnTone3000DownloadStarted(int64_t handle, const std::string &title) override
{
Tone3000DownloadStartedBody body;
body.handle_ = handle;
body.title_ = title;
Send("onTone3000DownloadStarted", body);
}
virtual void OnTone3000DownloadProgress(const Tone3000DownloadProgress &progress) override
{
Send("onTone3000DownloadProgress", progress);
}
virtual void OnTone3000DownloadComplete(int64_t handle, const std::string &resultPath) override
{
Send("onTone3000DownloadComplete", resultPath);
}
virtual void OnTone3000DownloadError(int64_t handle, const std::string &errorMessage) override
{
Tone3000DownloadErrorBody body;
body.handle_ = handle;
body.errorMessage_ = errorMessage;
Send("onTone3000DownloadError", body);
}
// virtual void OnPatchPropertyChanged(int64_t clientId, int64_t instanceId,const std::string& propertyUri,const json_variant& value)
// {
// PatchPropertyChangedBody body;
@@ -2376,7 +2443,7 @@ public:
}
virtual std::shared_ptr<SocketHandler> CreateHandler(const uri &request)
{
return std::make_shared<PiPedalSocketHandler>(model);
return std::shared_ptr<PiPedalSocketHandler>(new PiPedalSocketHandler(model));
}
};
+284 -173
View File
@@ -27,6 +27,9 @@
#include <array>
#include <ctime>
#include "util.hpp"
#include "Tone3000DownloadProgress.hpp"
#include <thread>
#include "HtmlHelper.hpp"
using namespace pipedal;
using namespace pipedal::tone3000;
@@ -75,6 +78,7 @@ JSON_MAP_END()
static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"};
static bool enableLogging = true;
static std::string shellEscape(const std::string &input)
{
// Escape string for safe use in shell commands by using single quotes
@@ -117,57 +121,107 @@ static std::string getCurlErrorFromExitCode(int exitCode)
return SS("Failed to download file (exit code: " << (exitCode / 256) << ")");
}
}
static void downloadFile(const std::string &url, const fs::path &path)
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();
// Build curl command with error detection and proper escaping
std::ostringstream os;
os << "/usr/bin/curl -f -s -S -L --max-time 300 --max-filesize 104857600";
os << " -w '%{http_code}' -o ";
os << shellEscape(path.string());
os << " ";
os << shellEscape(url);
os << " > ";
os << shellEscape(httpCodePath.string());
TemporaryFile headersFile{WEB_TEMP_DIR};
fs::path headersFilePath = headersFile.Path();
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))
for (size_t retry = 0; retry < 10; ++retry)
{
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
// 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))
{
throw std::runtime_error(
SS("Server was unabled to download the file. Http error: " << httpCode << " url: " << url));
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
{
throw std::runtime_error(
SS("Server was unable to download the file. "
<< getCurlErrorFromExitCode(exitCode)
<< " " << "url: " << url));
break;
}
}
@@ -178,7 +232,6 @@ static void downloadFile(const std::string &url, const fs::path &path)
}
}
static std::string mdSanitize(const std::string &str, const std::string &illegalCharacters = "")
{
std::ostringstream os;
@@ -201,7 +254,6 @@ static std::string mdSanitize(const std::string &str, const std::string &illegal
return os.str();
}
static std::string mdDate(const tone3000_time_point &date_)
{
std::ostringstream os;
@@ -323,54 +375,40 @@ namespace
}
static std::vector<LicenseInfo> licenses{
{
.key = "t3k",
.displayName = "t3k",
.url = "",
.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",
.url = "https://creativecommons.org/licenses/by-nd/4.0/",
.licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nd
},
{
.key = "cc-by-nc-nd",
.displayName = "CC BY-NC-ND",
.url = "https://creativecommons.org/licenses/by-nc-nd/4.0/",
.licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nc | LicenseFlags::Nd
},
{.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,
},
};
@@ -391,93 +429,33 @@ static std::string mdLicenseIcon(LicenseFlags licenseFlag)
std::ostringstream os;
std::string url;
switch (licenseFlag)
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::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.");
case LicenseFlags::None:
throw std::runtime_error("Invalid argument.");
}
os << "<img id='cc_img' src='" << url << "'/>";
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 target='_blank' rel='noopener noreferrer' href='"
<< licenseInfo.url
<< "'>" << mdSanitize(licenseInfo.displayName) << "</a>";
}
else
{
os << mdSanitize(licenseInfo.displayName);
}
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 mdEscapeString(std::ostream &f, const std::string &str)
{
@@ -503,6 +481,95 @@ static void mdEscapeString(std::ostream &f, const std::string &str)
}
}
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, ")") << ")";
@@ -541,11 +608,14 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download)
<< mdUser(download.user())
<< " <br/>\n"
<< " " << mdSizes(download.sizes()) << ", "
<< mdSizes(download.sizes()) << ", "
<< mdGear(download.gear()) << ", "
<< mdPlatform(download.platform())
<< "<br/>\n"
<< " License: xxx" << mdLicense(download.license()) << "\n"
<< "<br/>\n";
if (download.license() != "") {
of << " License: " << mdLicense(download.license()) << "\n";
}
of
// << " " << mdTestLicenses()
<< " </p>\n"
<< " </div>\n"
<< " </div>\n"
@@ -566,14 +636,28 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download)
}
}
void pipedal::tone3000::DownloadTone3000Bundle(
std::string pipedal::tone3000::DownloadTone3000Bundle(
const std::filesystem::path &destinationFolder,
const std::string &downloadUrl)
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();
downloadFile(downloadUrl, downloadPath);
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())
@@ -585,21 +669,48 @@ void pipedal::tone3000::DownloadTone3000Bundle(
Tone3000Download tone3000Download;
reader.read(&tone3000Download);
if (isCancelled())
{
return resultDirectory;
}
progress.title(tone3000Download.title());
progress.total(tone3000Download.models().size());
progressCallback(progress);
if (tone3000Download.platform() != "nam")
{
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 = bundlePath / SS(stringToSafeFilename(model.name()) << ".nam");
downloadFile(model.model_url(), modelPath);
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()
+9 -3
View File
@@ -25,9 +25,12 @@
#include <cstdint>
#include <string>
#include <vector>
#include <optional>
#include "json.hpp"
#include <filesystem>
#include <chrono>
#include <functional>
#include "Tone3000DownloadProgress.hpp"
namespace pipedal
{
@@ -59,7 +62,7 @@ namespace pipedal
{
private:
std::string id_;
std::string avatar_url_;
std::optional<std::string> avatar_url_;
std::string username_;
std::string url_;
@@ -128,10 +131,13 @@ namespace pipedal
DECLARE_JSON_MAP(Tone3000DownloadResult);
};
extern void DownloadTone3000Bundle
extern std::string DownloadTone3000Bundle
(
const std::filesystem::path&destinationFolder,
const std::string &downloadUrl
const std::string &downloadUrl,
int64_t downloadHandle,
std::function<void(const Tone3000DownloadProgress&progress)> progressCallback,
std::function<bool()> isCancelled
);
}
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 "Tone3000DownloadProgress.hpp"
using namespace pipedal;
JSON_MAP_BEGIN(Tone3000DownloadProgress)
JSON_MAP_REFERENCE(Tone3000DownloadProgress, handle)
JSON_MAP_REFERENCE(Tone3000DownloadProgress, title)
JSON_MAP_REFERENCE(Tone3000DownloadProgress, progress)
JSON_MAP_REFERENCE(Tone3000DownloadProgress, total)
JSON_MAP_END()
+50
View File
@@ -0,0 +1,50 @@
/*
* 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 <cstdint>
#include "json.hpp"
namespace pipedal {
class Tone3000DownloadProgress {
private:
int64_t handle_ = -1;
std::string title_;
uint64_t progress_ = 0;
uint64_t total_ = 0;
public:
JSON_GETTER_SETTER(handle);
JSON_GETTER_SETTER_REF(title);
JSON_GETTER_SETTER(progress);
JSON_GETTER_SETTER(total);
DECLARE_JSON_MAP(Tone3000DownloadProgress);
};
}
+127 -27
View File
@@ -22,6 +22,10 @@
*/
#include "Tone3000DownloaderImpl.hpp"
#include "Tone3000DownloadProgress.hpp"
#include "Tone3000Download.hpp"
#include "Uri.hpp"
#include <functional>
using namespace pipedal;
@@ -67,9 +71,10 @@ Tone3000DownloaderImpl::handle_t Tone3000DownloaderImpl::RequestDownload(
this->requestQueue.push_back(request);
request = nullptr;
if (!this->thread)
if (!this->thread)
{
this->thread = std::make_unique<std::jthread>([this]{ this->ThreadProc(); });
this->thread = std::make_unique<std::jthread>([this]
{ this->ThreadProc(); });
}
}
this->thread_cv.notify_one();
@@ -97,7 +102,8 @@ void Tone3000DownloaderImpl::CancelDownload(
}
}
void Tone3000DownloaderImpl::Close() {
void Tone3000DownloaderImpl::Close()
{
bool notify = false;
{
std::lock_guard<std::mutex> lockGuard{this->mutex};
@@ -109,7 +115,7 @@ void Tone3000DownloaderImpl::Close() {
this->requestQueue.resize(0);
DownloadProgress progress;
Tone3000DownloadProgress progress;
fgDownloadProgress = progress;
if (listener)
@@ -121,12 +127,14 @@ void Tone3000DownloaderImpl::Close() {
activeRequest->cancelled = true;
}
}
if (notify) {
if (notify)
{
thread_cv.notify_one();
}
}
Tone3000DownloaderImpl::DownloadProgress Tone3000DownloaderImpl::GetDownloadStatus() {
DownloadProgress result;
Tone3000DownloadProgress Tone3000DownloaderImpl::GetDownloadStatus()
{
Tone3000DownloadProgress result;
{
std::lock_guard<std::mutex> lockGuard{this->mutex};
result = fgDownloadProgress;
@@ -134,47 +142,139 @@ Tone3000DownloaderImpl::DownloadProgress Tone3000DownloaderImpl::GetDownloadStat
return result;
}
void Tone3000DownloaderImpl::bgUpdateDownloadProgress(const DownloadProgress &progress)
void Tone3000DownloaderImpl::bgUpdateDownloadProgress(const Tone3000DownloadProgress &progress)
{
std::lock_guard<std::mutex> lockGuard{this->mutex};
if (closed)
if (closed)
{
return;
}
this->fgDownloadProgress = progress;
if (listener)
if (activeRequest != nullptr && !activeRequest->cancelled)
{
listener->OnTone3000Progress(this->fgDownloadProgress);
this->fgDownloadProgress = progress;
if (listener)
{
listener->OnTone3000Progress(this->fgDownloadProgress);
}
}
}
std::string Tone3000DownloaderImpl::PerformDownload(
const std::string &downloadPath,
const std::string &downloadUrl,
int64_t downloadHandle,
std::function<bool()> isCancelled)
{
std::string downloadedDirectory;
// Validate Tone3000 URL
uri downloadUri{downloadUrl};
if (!downloadUri.authority().ends_with(".tone3000.com"))
{
throw std::runtime_error("Invalid Tone3000 URL address.");
}
// Check for cancellation before starting
if (isCancelled())
{
return downloadedDirectory;
}
Tone3000DownloadProgress progress;
this->bgUpdateDownloadProgress(progress);
// Perform the download using existing code
downloadedDirectory = tone3000::DownloadTone3000Bundle(
downloadPath,
downloadUrl,
downloadHandle,
[this](const Tone3000DownloadProgress &progress)
{
this->bgUpdateDownloadProgress(progress);
},
[isCancelled]()
{
return isCancelled();
});
// Check for cancellation after download
if (isCancelled())
{
// maybe there's a partial result.
return downloadedDirectory;
}
return downloadedDirectory;
}
void Tone3000DownloaderImpl::ThreadProc()
{
while (true)
{
std::shared_ptr<DownloadRequest> downloadRequest;
std::shared_ptr<DownloadRequest> request;
Listener *currentListener = nullptr;
Tone3000DownloadProgress progress;
{
std::unique_lock lock { this->mutex};
this->activeRequest = nullptr;
std::unique_lock<std::mutex> lock{this->mutex};
thread_cv.wait(lock, [this]()
{ return this->closed || this->requestQueue.size() != 0; });
this->thread_cv.wait(lock,[this] { return this->closed || this->requestQueue.size() != 0;});
if (this->closed) {
if (closed)
{
return;
}
if (this->requestQueue.size() != 0)
if (requestQueue.empty())
{
this->activeRequest = this->requestQueue[0];
this->requestQueue.erase(requestQueue.begin());
continue;
}
downloadRequest = this->activeRequest;
}
if (!downloadRequest)
{
continue;
// Dequeue the next request
request = requestQueue.front();
requestQueue.erase(requestQueue.begin());
activeRequest = request;
currentListener = listener;
}
// Notify download started
if (currentListener)
{
currentListener->OnStartTone3000Download(request->handle, request->downloadUrl);
}
// Create cancellation predicate that checks the atomic flag
auto isCancelled = [request]() -> bool
{
return request->cancelled.load();
};
try
{
std::string outputPath = PerformDownload(request->downloadPath, request->downloadUrl, request->handle, isCancelled);
// Notify completion if not cancelled
{
std::lock_guard lockGuard{mutex};
if (!request->cancelled.load() && currentListener)
{
currentListener->OnTone3000DownloadComplete(request->handle,outputPath);
}
}
}
catch (const std::exception &e)
{
{
std::lock_guard lockGuard{mutex};
// Notify error
if (!request->cancelled.load() && currentListener)
{
currentListener->OnTone3000DownloadError(request->handle, e.what());
}
}
}
{
std::lock_guard<std::mutex> lock{this->mutex};
activeRequest = nullptr;
}
}
}
+10 -14
View File
@@ -25,6 +25,7 @@
#include <memory>
#include <cstdint>
#include "Tone3000DownloadProgress.hpp"
namespace pipedal {
@@ -33,20 +34,9 @@ namespace pipedal {
Tone3000Downloader();
public:
using handle_t = int64_t;
static constexpr handle_t INVALID_HANDLE = (handle_t)(int64_t)-1;
virtual ~Tone3000Downloader();
class DownloadProgress {
public:
private:
handle_t handle_ = INVALID_HANDLE;
std::string title_;
uint64_t progress_ = 0;
uint64_t total_ = 0;
};
class Listener {
public:
virtual void OnStartTone3000Download
@@ -55,10 +45,16 @@ namespace pipedal {
const std::string &title
) = 0;
virtual void OnTone3000Progress(
const DownloadProgress& downloadProgress
const Tone3000DownloadProgress& downloadProgress
) = 0;
virtual void OnTone3000DownloadComplete(
handle_t handle
handle_t handle,
const std::string&resultPath
) = 0;
virtual void OnTone3000DownloadError(
handle_t handle,
const std::string &errorMessage
) = 0;
};
@@ -79,6 +75,6 @@ namespace pipedal {
) = 0;
virtual void Close() = 0;
virtual DownloadProgress GetDownloadStatus() = 0;
virtual Tone3000DownloadProgress GetDownloadStatus() = 0;
};
}
+13 -3
View File
@@ -30,6 +30,7 @@
#include <mutex>
#include <condition_variable>
#include <vector>
#include <functional>
namespace pipedal {
@@ -49,12 +50,21 @@ namespace pipedal {
) override;
virtual void Close() override;
virtual DownloadProgress GetDownloadStatus() override;
virtual Tone3000DownloadProgress GetDownloadStatus() override;
private:
Listener *listener = nullptr;
void ThreadProc();
// Performs the actual download with cancellation support
std::string PerformDownload(
const std::string &downloadPath,
const std::string &downloadUrl,
int64_t downloadHandle,
std::function<bool()> isCancelled
);
struct DownloadRequest {
std::atomic<bool> cancelled = false;
handle_t handle;
@@ -68,8 +78,8 @@ namespace pipedal {
std::vector<std::shared_ptr<DownloadRequest>> requestQueue;
DownloadProgress fgDownloadProgress;
void bgUpdateDownloadProgress(const DownloadProgress &progress);
Tone3000DownloadProgress fgDownloadProgress;
void bgUpdateDownloadProgress(const Tone3000DownloadProgress &progress);
std::shared_ptr<DownloadRequest> activeRequest;
-43
View File
@@ -40,7 +40,6 @@
#include "util.hpp"
#include "HtmlHelper.hpp"
#include "WebServerMod.hpp"
#include "Tone3000Download.hpp"
#define OLD_PRESET_EXTENSION ".piPreset"
#define PRESET_EXTENSION ".piPreset"
@@ -147,10 +146,6 @@ public:
}
std::string segment = request_uri.segment(1);
if (segment == "tone3000Upload")
{
return true;
}
if (segment == "downloadMediaFile")
{
return true;
@@ -437,44 +432,6 @@ public:
{
std::string segment = request_uri.segment(1);
if (segment == "tone3000Upload")
{
tone3000::Tone3000DownloadResult result;
try
{
fs::path downloadPath = request_uri.query("path");
if (downloadPath.empty())
{
throw std::runtime_error("Invalid query parameters.");
}
if (!this->model->IsInUploadsDirectory(downloadPath) || HasDotDot(downloadPath))
{
throw std::runtime_error("Invalid path.");
}
std::string downloadUrl = request_uri.query("url");
uri downloadUri{downloadUrl};
if (!downloadUri.authority().ends_with(".tone3000.com"))
{
throw std::runtime_error("Invalid Tone3000 URL address.");
}
tone3000::DownloadTone3000Bundle(downloadPath, downloadUrl);
}
catch (const std::exception &e)
{
result = tone3000::Tone3000DownloadResult(e);
}
std::ostringstream os;
json_writer writer(os);
writer.write(&result);
std::string strResult = os.str();
res.set(HttpField::content_type,"application/json");
res.set(HttpField::cache_control, "no-cache");
res.setBody(strResult);
return;
}
if (segment == "downloadMediaFile")
{
fs::path path = request_uri.query("path");
+2
View File
@@ -1,3 +1,5 @@
Refresh on download complete
// yyy: only if the property changed!.
Tooltip on File Property Select: add to release notes.
sAFEfILEnAMES: RENAME, &C. Safe bank names?
NEW BANK
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/roboto.css">
<title>T3k License</title>
<style>
body {
font-family: Roboto,Arial, sans-serif;
margin: 32px;
line-height: 1.6;
}
h6 {
font-size: 1.2em;
margin-bottom: 10px;
}
p {
font-size: 1em;
max-width: 600px;
}
</style>
</head>
<body>
<h6>T3K License</h6>
<p>Users may download and use the data file in software and publish the resulting outputs without royalties or restrictions. However, they may not upload, republish, or distribute the data file without the author's permission.</p>
</body>
</html>
-1
View File
@@ -19,7 +19,6 @@
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+4 -1
View File
@@ -52,6 +52,7 @@ import PresetSelector from './PresetSelector';
import SettingsDialog from './SettingsDialog';
import AboutDialog from './AboutDialog';
import BankDialog from './BankDialog';
import {Tone3000DownloadStaus as Tone3000DownloadStatus} from './Tone3000Dialog';
import { PiPedalModelFactory, PiPedalModel, State, ZoomedControlInfo, wantsReloadingScreen } from './PiPedalModel';
import ZoomedUiControl from './ZoomedUiControl'
@@ -1099,7 +1100,9 @@ export
</DialogActions>
</DialogEx>
{/* Status of Tone3000 download */}
<Tone3000DownloadStatus zindex={2000}/>
{/* Fatal error mask */}
<Modal open={this.state.displayState === State.Error}
aria-label="fatal-error"
aria-describedby="aria-error-text"
+16
View File
@@ -211,6 +211,7 @@ export default withStyles(
constructor(props: FilePropertyDialogProps) {
super(props);
this.handleTone3000DownloadComplete = this.handleTone3000DownloadComplete.bind(this);
this.model = PiPedalModelFactory.getInstance();
@@ -560,15 +561,30 @@ export default withStyles(
private requestScroll: boolean = false;
handleTone3000DownloadComplete(resultPath: string) {
if (resultPath === "") {
// unknown state. Just refresh anyway.
this.requestFiles(this.state.navDirectory);
return;
}
if (resultPath.startsWith(this.state.navDirectory)) {
this.setState({
selectedFile: resultPath,
});
this.requestFiles(this.state.navDirectory);
}
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
this.requestFiles(this.state.navDirectory)
this.model.onTone3000DownloadCompleteEvent.addEventHandler(this.handleTone3000DownloadComplete);
this.requestScroll = true;
}
componentWillUnmount() {
this.stopAutoScroll();
this.cancelProgressTimeout();
this.model.onTone3000DownloadCompleteEvent.removeEventHandler(this.handleTone3000DownloadComplete);
super.componentWillUnmount();
this.mounted = false;
+63
View File
@@ -47,6 +47,7 @@ import AudioFileMetadata from './AudioFileMetadata';
import { pathFileName } from './FileUtils';
import { AlsaSequencerConfiguration, AlsaSequencerPortSelection } from './AlsaSequencer';
import { getDefaultModGuiPreference } from './ModGuiHost';
import Tone3000DownloadProgress from './Tone3000DownloadProgress';
export enum State {
Loading,
@@ -505,6 +506,8 @@ export class PiPedalModel //implements PiPedalModel
hasWifiDevice: ObservableProperty<boolean> = new ObservableProperty<boolean>(false);
onSnapshotModified: ObservableEvent<SnapshotModifiedEvent> = new ObservableEvent<SnapshotModifiedEvent>();
onTone3000DownloadCompleteEvent: ObservableEvent<string> = new ObservableEvent<string>();
ui_plugins: ObservableProperty<UiPlugin[]>
= new ObservableProperty<UiPlugin[]>([]);
state: ObservableProperty<State> = new ObservableProperty<State>(State.Loading);
@@ -550,6 +553,9 @@ export class PiPedalModel //implements PiPedalModel
);
zoomedUiControl: ObservableProperty<ZoomedControlInfo | undefined> = new ObservableProperty<ZoomedControlInfo | undefined>(undefined);
tone3000Downloading : ObservableProperty<boolean> = new ObservableProperty<boolean>(false);
tone3000DownloadProgress : ObservableProperty<Tone3000DownloadProgress | null> = new ObservableProperty<Tone3000DownloadProgress | null>(null);
uiPluginsByUri: Map<string, UiPlugin> = new Map<string, UiPlugin>();
private tone3000DownloadHandler: Tone3000DownloadHandler | null = null;
@@ -671,6 +677,18 @@ export class PiPedalModel //implements PiPedalModel
this.showAlert(getErrorMessage(error));
}
}
cancelTone3000Download(): void {
let downloadProgress = this.tone3000DownloadProgress.get();
if (downloadProgress === null) {
return;
}
if (downloadProgress.handle !== -1) {
if (!this.webSocket) {
return;
}
this.webSocket.send("cancelTone3000Download",downloadProgress.handle);
}
}
showTone3000DownloadDialog(
downloadPath: string,
@@ -923,6 +941,22 @@ export class PiPedalModel //implements PiPedalModel
let hasWifi = body as boolean;
this.hasWifiDevice.set(hasWifi);
}
else if (message === "onTone3000DownloadStarted") {
let { handle, title } = body as { handle: number, title: string };
this.onTone3000DownloadStarted(handle, title);
}
else if (message === "onTone3000DownloadProgress") {
// body is DownloadProgress
this.onTone3000DownloadProgress(body);
}
else if (message === "onTone3000DownloadComplete") {
let resultPath = body as string;
this.onTone3000DownloadComplete(resultPath);
}
else if (message === "onTone3000DownloadError") {
let { handle, errorMessage } = body as { handle: number, errorMessage: string };
this.onTone3000DownloadError(handle, errorMessage);
}
}
@@ -1022,6 +1056,32 @@ export class PiPedalModel //implements PiPedalModel
// this.webSocket?.reconnect(); // let the server do it for us.
}
// Tone3000 download notification handlers (stub implementations)
private onTone3000DownloadStarted(handle: number, title: string): void {
this.tone3000Downloading.set(true);
}
private onTone3000DownloadProgress(progress: Tone3000DownloadProgress): void {
this.tone3000Downloading.set(true);
this.tone3000DownloadProgress.set(progress);
}
private onTone3000DownloadComplete(resultPath: string): void {
this.tone3000Downloading.set(false);
this.onTone3000DownloadCompleteEvent.fire(resultPath);
}
private onTone3000DownloadError(handle: number, errorMessage: string): void {
console.error(`Tone3000 download error: handle=${handle}, error=${errorMessage}`);
this.tone3000Downloading.set(false);
this.onTone3000DownloadCompleteEvent.fire("");
setTimeout(() => {
this.showAlert(errorMessage);
}, 100);
}
setError(message: string): void {
this.errorMessage.set(message);
this.setState(State.Error);
@@ -1084,6 +1144,9 @@ export class PiPedalModel //implements PiPedalModel
this.vuSubscriptions = [];
this.monitorPatchPropertyListeners = [];
this.tone3000Downloading.set(false);
this.tone3000DownloadProgress.set(null);
if (this.isAndroidHosted()) {
// if unexpected, go back to the device browser immediately.
if (this.reconnectReason === ReconnectReason.Disconnected) {
+5 -2
View File
@@ -30,8 +30,11 @@
}
.text-info-dialog-content #user-content-cc_img {
width: 24px;
height: 24px;
width: 16px;
height: 16px;
margin: 1px;
opacity: 0.6;
vertical-align: middle;
}
.text-info-dialog-content #user-content-tone3000_thumbnail {
width: 130px;
+11 -2
View File
@@ -33,6 +33,15 @@ import rehypeSanitize, {defaultSchema} from 'rehype-sanitize';
import rehypeExternalLinks from 'rehype-external-links'
import remarkGfm from 'remark-gfm';
import { PiPedalError } from './PiPedalError';
// Extend the default schema to allow target and rel attributes on anchor tags
const extendedSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
a: [...(defaultSchema.attributes?.a || []), 'target', 'rel']
}
};
import DialogEx from './DialogEx';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
@@ -175,9 +184,9 @@ const TextInfoDialog = class extends ResizeResponsiveComponent<TextInfoDialogPro
<ReactMarkdown rehypePlugins={
[
[remarkGfm],
[rehypeExternalLinks, {target: '_blank'}],
rehypeRaw,
[rehypeSanitize, defaultSchema]
[rehypeSanitize, extendedSchema],
[rehypeExternalLinks, {target: '_blank'}]
]}>
{this.state.textFileContent}
</ReactMarkdown>
+102 -27
View File
@@ -1,11 +1,15 @@
import { JSX } from "@emotion/react/jsx-dev-runtime";
import { PiPedalModel, getErrorMessage } from "./PiPedalModel";
import { PiPedalModel, State, getErrorMessage } from "./PiPedalModel";
import DialogEx from "./DialogEx";
import DialogContent from "@mui/material/DialogContent";
import DialogAction from "@mui/material/DialogActions";
import { Button, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import LinearProgress from "@mui/material/LinearProgress";
import Tone3000DownloadProgress from "./Tone3000DownloadProgress";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import DialogActions from "@mui/material/DialogActions";
export type OnTone3000DownloadHandler = (tone3000DownloadUrl: string) => void;
@@ -24,7 +28,7 @@ export class Tone3000DownloadHandler {
this.handleTone3000DownloadComplete();
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl,this.downloadPath);
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath);
return;
} catch (e) {
this.handleTone3000DownloadError(getErrorMessage(e));
@@ -57,7 +61,7 @@ export class Tone3000DownloadHandler {
private popupWindow: Window | null = null;
private appId: string = "pipedal_app";
private appId: string = "pipedal_app3";
private redirectUrl(): string {
let hostname = window.location.hostname;
let port = window.location.port;
@@ -114,22 +118,26 @@ export class Tone3000DownloadHandler {
this.stopTone3000DialogMonitor();
}
private tone3000DialogInterval: number | undefined = undefined;
private tone3000DialogTimeout: number | undefined = undefined;
private stopTone3000DialogMonitor() {
if (this.tone3000DialogInterval !== undefined) {
clearInterval(this.tone3000DialogInterval);
this.tone3000DialogInterval = undefined;
if (this.tone3000DialogTimeout !== undefined) {
clearTimeout(this.tone3000DialogTimeout);
this.tone3000DialogTimeout = undefined;
}
}
private startTone3000DialogMonitor() {
this.tone3000DialogInterval = setInterval(() => {
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);
}
}, 500);
}, delay);
}
private downloadPath: string = "";
private onClosedCallback: (() => void) | undefined = undefined;
@@ -151,13 +159,7 @@ export class Tone3000DownloadHandler {
this.onDownloadStartedCallback = onDownloadStarted;
this.onErrorCallback = onError;
fetch(TONE3000_PING_URL, { cache: "no-cache" })
.then((response) => {
if (!response.ok) {
this.handleTone3000DownloadError(`Unable to connect to Tone3000: ${response.status}${response.statusText}`);
return;
}
try {
// online
let popupWidth = Math.floor(window.innerWidth * 0.8);
let popupHeight = Math.floor(window.innerHeight * 0.8);
@@ -171,19 +173,28 @@ export class Tone3000DownloadHandler {
"popup",
`innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes`
);
this.startTone3000DialogMonitor();
if (!this.popupWindow) {
console.error("Failed to open Tone3000 dialog popup window.");
this.handleTone3000DownloadError("Cannot open popup window.");
return;
}
})
.catch((error) => {
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) {
// offline
this.handleTone3000DownloadError("Not online. Unable to connect to Tone3000 servers.");
return;
});
};
}
public closeTone3000Dialog(): void {
@@ -226,8 +237,15 @@ export function Tone3000DownloadDialog(props: Tone3000DownloadDialogProps): JSX.
onDownloadComplete();
}
);
let onStateChanged = (state: State) => {
if (state !== State.Ready) {
onClose();
}
}
model.state.addOnChangedHandler(onStateChanged);
return () => {
model.closeTone3000DownloadDialog();
model.state.removeOnChangedHandler(onStateChanged);
};
});
return (
@@ -239,23 +257,80 @@ export function Tone3000DownloadDialog(props: Tone3000DownloadDialogProps): JSX.
onEnterKey={() => { }}
>
<DialogContent>
<Typography variant="body2">Downloading from Tone3000...</Typography>
<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>
<DialogAction>
<DialogActions >
{!downloading && (
<Button
<Button variant="dialogSecondary"
onClick={() => {
onClose();
}}>
Cancel
</Button>
)}
</DialogAction>
</DialogActions>
</DialogEx>
);
}
export function Tone3000DownloadStaus(
props: {
zindex: number;
}): JSX.Element {
const model = PiPedalModel.getInstance();
const [downloading, setDownloading] = useState<boolean>(false);
const [progress, setProgress] = useState<Tone3000DownloadProgress | null>(null);
useEffect(() => {
let onDownloadingChanged = (value: boolean) => {
setDownloading(value);
}
model.tone3000Downloading.addOnChangedHandler(onDownloadingChanged);
let onProgressChanged = (value: Tone3000DownloadProgress | null) => {
setProgress(value);
}
model.tone3000DownloadProgress.addOnChangedHandler(onProgressChanged);
return () => {
model.tone3000Downloading.removeOnChangedHandler(onDownloadingChanged);
model.tone3000DownloadProgress.removeOnChangedHandler(onProgressChanged);
}
})
let open = downloading && progress !== null && progress.title.length > 0;
if (model.state.get() !== State.Ready) {
open = false;
}
return (
<Dialog
open={open}
onClose={() => { /* Do nothing */ }}
>
<DialogTitle>Downloading from Tone3000</DialogTitle>
<DialogContent>
<Typography noWrap variant="body2">{progress?.title ?? "\u00A0"}</Typography>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<LinearProgress
style={{ marginTop: 16, flexGrow: 1 }}
variant={(progress?.total ?? 0) === 0 ? "indeterminate" : "determinate"}
value={(progress && progress.total !== 0) ? progress.progress / progress.total * 100 : 0}
/>
</div>
</DialogContent>
<DialogActions>
<Button variant="dialogSecondary"
onClick={() => {
model.cancelTone3000Download();
}}
>Cancel</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,30 @@
/*
* 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.
*/
export default interface Tone3000DownloadProgress {
handle: number;
title: string;
progress: number;
total: number;
};