diff --git a/PiPedalCommon/src/HtmlHelper.cpp b/PiPedalCommon/src/HtmlHelper.cpp index 52b1741..5aa26dd 100644 --- a/PiPedalCommon/src/HtmlHelper.cpp +++ b/PiPedalCommon/src/HtmlHelper.cpp @@ -21,6 +21,8 @@ #include #include #include +#include "ss.hpp" +#include 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 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); + +} diff --git a/PiPedalCommon/src/include/HtmlHelper.hpp b/PiPedalCommon/src/include/HtmlHelper.hpp index 0af60f2..07a0ec4 100644 --- a/PiPedalCommon/src/include/HtmlHelper.hpp +++ b/PiPedalCommon/src/include/HtmlHelper.hpp @@ -63,6 +63,8 @@ public: static std::string generateEtag(const std::filesystem::path &path); + static std::string httpErrorString(int errorCode); + }; } // namespace. \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3757c6d..19f7ce4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -204,7 +204,7 @@ else() endif() set (PIPEDAL_SOURCES - + Tone3000DownloadProgress.cpp Tone3000DownloadProgress.hpp Tone3000Download.cpp Tone3000Download.hpp Tone3000Downloader.cpp Tone3000Downloader.hpp diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 1b1807f..008f6e9 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -239,9 +239,26 @@ int64_t PiPedalModel::DownloadModelsFromTone3000( { std::lock_guard 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 lock(mutex); - + if (tone3000Downloader) { tone3000Downloader->CancelDownload(downloadHandle); } } +// Tone3000Downloader::Listener implementation +void PiPedalModel::OnStartTone3000Download(int64_t handle, const std::string &title) +{ + std::lock_guard lock(mutex); + for (auto subscriber : this->subscribers) + { + subscriber->OnTone3000DownloadStarted(handle, title); + } +} + +void PiPedalModel::OnTone3000Progress(const Tone3000DownloadProgress &progress) +{ + std::lock_guard lock(mutex); + for (auto subscriber : this->subscribers) + { + subscriber->OnTone3000DownloadProgress(progress); + } +} + +void PiPedalModel::OnTone3000DownloadComplete(int64_t handle, const std::string&resultPath) +{ + std::lock_guard lock(mutex); + for (auto subscriber : this->subscribers) + { + subscriber->OnTone3000DownloadComplete(handle,resultPath); + } +} + +void PiPedalModel::OnTone3000DownloadError(int64_t handle, const std::string &errorMessage) +{ + std::lock_guard 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(); diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index 840af13..c6d297e 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -41,6 +41,7 @@ #include "AtomConverter.hpp" #include "FileEntry.hpp" #include +#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; void CancelAudioRetry(); clock::time_point lastRestartTime = clock::time_point::min(); diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index fd02c8f..992f57e 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -24,6 +24,7 @@ #include "json.hpp" #include "viewstream.hpp" #include "PiPedalVersion.hpp" +#include "Tone3000Downloader.hpp" #include #include #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 CreateHandler(const uri &request) { - return std::make_shared(model); + return std::shared_ptr(new PiPedalSocketHandler(model)); } }; diff --git a/src/Tone3000Download.cpp b/src/Tone3000Download.cpp index 14bd553..30ca6ac 100644 --- a/src/Tone3000Download.cpp +++ b/src/Tone3000Download.cpp @@ -27,6 +27,9 @@ #include #include #include "util.hpp" +#include "Tone3000DownloadProgress.hpp" +#include +#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 &isCancelled) +{ + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + while (true) + { + if (std::chrono::steady_clock::now() - start >= duration) + { + break; + } + if (isCancelled()) + { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } +} + + +static void downloadFile( + const std::string &url, + const fs::path &path, + std::function &isCancelled) { // Create temporary file for HTTP status code TemporaryFile httpCodeFile{WEB_TEMP_DIR}; fs::path httpCodePath = httpCodeFile.Path(); - // 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 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 << ""; return os.str(); } -static std::string mdLicense(const std::string &license) -{ - auto ff = licenseEnum.find(license); - LicenseInfo licenseInfo; - if (ff != licenseEnum.end()) - { - licenseInfo = ff->second; - } - else - { - licenseInfo.displayName = license; - } - - std::ostringstream os; - - if (licenseInfo.licenseFlags != LicenseFlags::None) { - for (LicenseFlags licenseFlag: std::vector{LicenseFlags::Cc, LicenseFlags::By, LicenseFlags::Cc0, LicenseFlags::Nc, LicenseFlags::Sa, LicenseFlags::Nd}) - { - if (licenseInfo.licenseFlags & licenseFlag) - { - os << mdLicenseIcon(licenseFlag); - } - } - os << " "; - } - - if (licenseInfo.url != "") - { - os << "" << mdSanitize(licenseInfo.displayName) << ""; - } - else - { - os << mdSanitize(licenseInfo.displayName); - } - return os.str(); -} -static std::map platformEnumValues = - { - {"nam", "NAM"}, - {"ir", "I/R"}, - {"aida-x", "Aida X"}, - {"aa-snapshot", "aa-snapshot"}, - {"proteus", "Proteus"}}; -static std::string mdPlatform(const std::string &platform) -{ - return mdEnum(platform, platformEnumValues); -} -static std::string mdUser(const tone3000::Tone3000User &user) -{ - // clang-format off - std::ostringstream os; - os << - "" << mdSanitize(user.username()) << ""; - return os.str(); - // clang-format on -} static void 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::Cc, LicenseFlags::By, LicenseFlags::Cc0, LicenseFlags::Nc, LicenseFlags::Sa, LicenseFlags::Nd}) + { + if (licenseInfo.licenseFlags & licenseFlag) + { + os << mdLicenseIcon(licenseFlag); + } + } + os << " "; + } + + if (licenseInfo.url != "") + { + os << "" << mdSanitize(licenseInfo.displayName) << ""; + } + else + { + os << mdSanitize(licenseInfo.displayName); + } + return os.str(); +} +// static std::string mdTestLicenses() +// { +// std::ostringstream os; +// for (auto license: licenseEnum) +// { +// os << "
" << mdLicense(license.first) << "\n"; +// } +// return os.str(); +// } +static std::map platformEnumValues = + { + {"nam", "NAM"}, + {"ir", "I/R"}, + {"aida-x", "Aida X"}, + {"aa-snapshot", "aa-snapshot"}, + {"proteus", "Proteus"} + }; +static std::string mdPlatform(const std::string &platform) +{ + return mdEnum(platform, platformEnumValues); +} +static std::string mdUser(const tone3000::Tone3000User &user) +{ + // clang-format off + std::ostringstream os; + os << + "" << mdSanitize(user.username()) << ""; + return os.str(); + // clang-format on +} + + static void mdLink(std::ostream &f, const std::string &label, const std::string &url) { f << "[" << mdSanitize(label, "]") << "](" << mdSanitize(url, ")") << ")"; @@ -541,11 +608,14 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download) << mdUser(download.user()) << "
\n" << " " << mdSizes(download.sizes()) << ", " - << mdSizes(download.sizes()) << ", " << mdGear(download.gear()) << ", " << mdPlatform(download.platform()) - << "
\n" - << " License: xxx" << mdLicense(download.license()) << "\n" + << "
\n"; + if (download.license() != "") { + of << " License: " << mdLicense(download.license()) << "\n"; + } + of +// << " " << mdTestLicenses() << "

\n" << " \n" << " \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 progressCallback, + std::function 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() diff --git a/src/Tone3000Download.hpp b/src/Tone3000Download.hpp index 822f529..6cf2a25 100644 --- a/src/Tone3000Download.hpp +++ b/src/Tone3000Download.hpp @@ -25,9 +25,12 @@ #include #include #include +#include #include "json.hpp" #include #include +#include +#include "Tone3000DownloadProgress.hpp" namespace pipedal { @@ -59,7 +62,7 @@ namespace pipedal { private: std::string id_; - std::string avatar_url_; + std::optional 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 progressCallback, + std::function isCancelled ); } diff --git a/src/Tone3000DownloadProgress.cpp b/src/Tone3000DownloadProgress.cpp new file mode 100644 index 0000000..3227f4c --- /dev/null +++ b/src/Tone3000DownloadProgress.cpp @@ -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() diff --git a/src/Tone3000DownloadProgress.hpp b/src/Tone3000DownloadProgress.hpp new file mode 100644 index 0000000..7aa30b4 --- /dev/null +++ b/src/Tone3000DownloadProgress.hpp @@ -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 +#include +#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); + + }; + +} diff --git a/src/Tone3000Downloader.cpp b/src/Tone3000Downloader.cpp index 24f19eb..eba3bab 100644 --- a/src/Tone3000Downloader.cpp +++ b/src/Tone3000Downloader.cpp @@ -22,6 +22,10 @@ */ #include "Tone3000DownloaderImpl.hpp" +#include "Tone3000DownloadProgress.hpp" +#include "Tone3000Download.hpp" +#include "Uri.hpp" +#include 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([this]{ this->ThreadProc(); }); + this->thread = std::make_unique([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 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 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 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 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; + std::shared_ptr request; + Listener *currentListener = nullptr; + + Tone3000DownloadProgress progress; + { - std::unique_lock lock { this->mutex}; - this->activeRequest = nullptr; + std::unique_lock 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 lock{this->mutex}; + activeRequest = nullptr; + } } } \ No newline at end of file diff --git a/src/Tone3000Downloader.hpp b/src/Tone3000Downloader.hpp index f57eaf3..e341c63 100644 --- a/src/Tone3000Downloader.hpp +++ b/src/Tone3000Downloader.hpp @@ -25,6 +25,7 @@ #include #include +#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; }; } \ No newline at end of file diff --git a/src/Tone3000DownloaderImpl.hpp b/src/Tone3000DownloaderImpl.hpp index be4b538..083ebae 100644 --- a/src/Tone3000DownloaderImpl.hpp +++ b/src/Tone3000DownloaderImpl.hpp @@ -30,6 +30,7 @@ #include #include #include +#include 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 isCancelled + ); + struct DownloadRequest { std::atomic cancelled = false; handle_t handle; @@ -68,8 +78,8 @@ namespace pipedal { std::vector> requestQueue; - DownloadProgress fgDownloadProgress; - void bgUpdateDownloadProgress(const DownloadProgress &progress); + Tone3000DownloadProgress fgDownloadProgress; + void bgUpdateDownloadProgress(const Tone3000DownloadProgress &progress); std::shared_ptr activeRequest; diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp index 18f42fa..cc41471 100644 --- a/src/WebServerConfig.cpp +++ b/src/WebServerConfig.cpp @@ -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"); diff --git a/todo.txt b/todo.txt index 8431bd7..f5b26db 100644 --- a/todo.txt +++ b/todo.txt @@ -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 diff --git a/vite/public/licenses/t3k_license.html b/vite/public/licenses/t3k_license.html new file mode 100644 index 0000000..1d727f0 --- /dev/null +++ b/vite/public/licenses/t3k_license.html @@ -0,0 +1,28 @@ + + + + + + + T3k License + + + +
T3K License
+

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.

+ + \ No newline at end of file diff --git a/vite/public/manifest.json b/vite/public/manifest.json index a2d368b..105f57a 100644 --- a/vite/public/manifest.json +++ b/vite/public/manifest.json @@ -19,7 +19,6 @@ } ], "start_url": ".", - "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } diff --git a/vite/src/pipedal/AppThemed.tsx b/vite/src/pipedal/AppThemed.tsx index c2334c4..fc1992b 100644 --- a/vite/src/pipedal/AppThemed.tsx +++ b/vite/src/pipedal/AppThemed.tsx @@ -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 - + {/* Status of Tone3000 download */} + + {/* Fatal error mask */} = new ObservableProperty(false); onSnapshotModified: ObservableEvent = new ObservableEvent(); + onTone3000DownloadCompleteEvent: ObservableEvent = new ObservableEvent(); + ui_plugins: ObservableProperty = new ObservableProperty([]); state: ObservableProperty = new ObservableProperty(State.Loading); @@ -550,6 +553,9 @@ export class PiPedalModel //implements PiPedalModel ); zoomedUiControl: ObservableProperty = new ObservableProperty(undefined); + tone3000Downloading : ObservableProperty = new ObservableProperty(false); + tone3000DownloadProgress : ObservableProperty = new ObservableProperty(null); + uiPluginsByUri: Map = new Map(); 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) { diff --git a/vite/src/pipedal/TextInfoDialog.css b/vite/src/pipedal/TextInfoDialog.css index aa5a678..559bb57 100644 --- a/vite/src/pipedal/TextInfoDialog.css +++ b/vite/src/pipedal/TextInfoDialog.css @@ -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; diff --git a/vite/src/pipedal/TextInfoDialog.tsx b/vite/src/pipedal/TextInfoDialog.tsx index be9f32c..7daf0a7 100644 --- a/vite/src/pipedal/TextInfoDialog.tsx +++ b/vite/src/pipedal/TextInfoDialog.tsx @@ -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 {this.state.textFileContent} diff --git a/vite/src/pipedal/Tone3000Dialog.tsx b/vite/src/pipedal/Tone3000Dialog.tsx index 7d094d4..5a862b1 100644 --- a/vite/src/pipedal/Tone3000Dialog.tsx +++ b/vite/src/pipedal/Tone3000Dialog.tsx @@ -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={() => { }} > - Downloading from Tone3000... + Downloading from Tone3000... {downloading &&
}
- + {!downloading && ( - )} - + ); } + +export function Tone3000DownloadStaus( + props: { + zindex: number; + }): JSX.Element { + const model = PiPedalModel.getInstance(); + const [downloading, setDownloading] = useState(false); + const [progress, setProgress] = useState(null); + + 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 ( + { /* Do nothing */ }} + > + Downloading from Tone3000 + + {progress?.title ?? "\u00A0"} +
+ +
+
+ + + + +
+ + ); +} diff --git a/vite/src/pipedal/Tone3000DownloadProgress.tsx b/vite/src/pipedal/Tone3000DownloadProgress.tsx new file mode 100644 index 0000000..3dee7d0 --- /dev/null +++ b/vite/src/pipedal/Tone3000DownloadProgress.tsx @@ -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; +}; \ No newline at end of file