diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 167453e0..9d5e9faa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,6 +195,20 @@ jobs: ${{ github.workspace }}/build/bin/*.debug ${{ github.workspace }}/build/lib/*.debug + - name: Build Installer (Windows) + if: matrix.os == 'windows-2022' + shell: pwsh + run: | + cd build + cpack + + - name: Upload Installer (Windows) + if: matrix.os == 'windows-2022' + uses: actions/upload-artifact@v4 + with: + name: supercell-wx-installer-${{ matrix.artifact_suffix }} + path: ${{ github.workspace }}/build/supercell-wx-*.msi* + - name: Build AppImage (Linux) if: matrix.os == 'ubuntu-22.04' env: diff --git a/CMakeLists.txt b/CMakeLists.txt index 36db8bb6..3e698f73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,10 @@ cmake_minimum_required(VERSION 3.21) set(PROJECT_NAME supercell-wx) -project(${PROJECT_NAME} C CXX) +project(${PROJECT_NAME} + VERSION 0.4.3 + DESCRIPTION "Supercell Wx is a free, open source advanced weather radar viewer." + HOMEPAGE_URL "https://github.com/dpaulat/supercell-wx" + LANGUAGES C CXX) set(CMAKE_POLICY_DEFAULT_CMP0054 NEW) set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) diff --git a/LICENSE.txt b/LICENSE.txt index 70848a51..8c9c5fbd 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2021 Dan Paulat +Copyright (c) 2021-2024 Dan Paulat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/scwx-qt/res/images/scwx-banner.png b/scwx-qt/res/images/scwx-banner.png new file mode 100644 index 00000000..68722339 Binary files /dev/null and b/scwx-qt/res/images/scwx-banner.png differ diff --git a/scwx-qt/res/images/scwx-dialog.png b/scwx-qt/res/images/scwx-dialog.png new file mode 100644 index 00000000..781a39b2 Binary files /dev/null and b/scwx-qt/res/images/scwx-dialog.png differ diff --git a/scwx-qt/scwx-qt.cmake b/scwx-qt/scwx-qt.cmake index f444dd77..eb94efd3 100644 --- a/scwx-qt/scwx-qt.cmake +++ b/scwx-qt/scwx-qt.cmake @@ -86,6 +86,7 @@ set(SRC_GL_DRAW source/scwx/qt/gl/draw/draw_item.cpp source/scwx/qt/gl/draw/placefile_triangles.cpp source/scwx/qt/gl/draw/rectangle.cpp) set(HDR_MANAGER source/scwx/qt/manager/alert_manager.hpp + source/scwx/qt/manager/download_manager.hpp source/scwx/qt/manager/font_manager.hpp source/scwx/qt/manager/media_manager.hpp source/scwx/qt/manager/placefile_manager.hpp @@ -98,6 +99,7 @@ set(HDR_MANAGER source/scwx/qt/manager/alert_manager.hpp source/scwx/qt/manager/timeline_manager.hpp source/scwx/qt/manager/update_manager.hpp) set(SRC_MANAGER source/scwx/qt/manager/alert_manager.cpp + source/scwx/qt/manager/download_manager.cpp source/scwx/qt/manager/font_manager.cpp source/scwx/qt/manager/media_manager.cpp source/scwx/qt/manager/placefile_manager.cpp @@ -154,8 +156,10 @@ set(SRC_MODEL source/scwx/qt/model/alert_model.cpp source/scwx/qt/model/radar_site_model.cpp source/scwx/qt/model/tree_item.cpp source/scwx/qt/model/tree_model.cpp) -set(HDR_REQUEST source/scwx/qt/request/nexrad_file_request.hpp) -set(SRC_REQUEST source/scwx/qt/request/nexrad_file_request.cpp) +set(HDR_REQUEST source/scwx/qt/request/download_request.hpp + source/scwx/qt/request/nexrad_file_request.hpp) +set(SRC_REQUEST source/scwx/qt/request/download_request.cpp + source/scwx/qt/request/nexrad_file_request.cpp) set(HDR_SETTINGS source/scwx/qt/settings/audio_settings.hpp source/scwx/qt/settings/general_settings.hpp source/scwx/qt/settings/map_settings.hpp @@ -217,6 +221,7 @@ set(HDR_UI source/scwx/qt/ui/about_dialog.hpp source/scwx/qt/ui/animation_dock_widget.hpp source/scwx/qt/ui/collapsible_group.hpp source/scwx/qt/ui/county_dialog.hpp + source/scwx/qt/ui/download_dialog.hpp source/scwx/qt/ui/flow_layout.hpp source/scwx/qt/ui/imgui_debug_dialog.hpp source/scwx/qt/ui/imgui_debug_widget.hpp @@ -228,6 +233,7 @@ set(HDR_UI source/scwx/qt/ui/about_dialog.hpp source/scwx/qt/ui/open_url_dialog.hpp source/scwx/qt/ui/placefile_dialog.hpp source/scwx/qt/ui/placefile_settings_widget.hpp + source/scwx/qt/ui/progress_dialog.hpp source/scwx/qt/ui/radar_site_dialog.hpp source/scwx/qt/ui/settings_dialog.hpp source/scwx/qt/ui/update_dialog.hpp) @@ -237,6 +243,7 @@ set(SRC_UI source/scwx/qt/ui/about_dialog.cpp source/scwx/qt/ui/animation_dock_widget.cpp source/scwx/qt/ui/collapsible_group.cpp source/scwx/qt/ui/county_dialog.cpp + source/scwx/qt/ui/download_dialog.cpp source/scwx/qt/ui/flow_layout.cpp source/scwx/qt/ui/imgui_debug_dialog.cpp source/scwx/qt/ui/imgui_debug_widget.cpp @@ -248,6 +255,7 @@ set(SRC_UI source/scwx/qt/ui/about_dialog.cpp source/scwx/qt/ui/open_url_dialog.cpp source/scwx/qt/ui/placefile_dialog.cpp source/scwx/qt/ui/placefile_settings_widget.cpp + source/scwx/qt/ui/progress_dialog.cpp source/scwx/qt/ui/radar_site_dialog.cpp source/scwx/qt/ui/settings_dialog.cpp source/scwx/qt/ui/update_dialog.cpp) @@ -262,6 +270,7 @@ set(UI_UI source/scwx/qt/ui/about_dialog.ui source/scwx/qt/ui/open_url_dialog.ui source/scwx/qt/ui/placefile_dialog.ui source/scwx/qt/ui/placefile_settings_widget.ui + source/scwx/qt/ui/progress_dialog.ui source/scwx/qt/ui/radar_site_dialog.ui source/scwx/qt/ui/settings_dialog.ui source/scwx/qt/ui/update_dialog.ui) @@ -608,3 +617,25 @@ install(SCRIPT ${deploy_script_qmaplibre_core} install(SCRIPT ${deploy_script_scwx} COMPONENT supercell-wx) + +if (MSVC) + set(CPACK_PACKAGE_NAME "Supercell Wx") + set(CPACK_PACKAGE_VENDOR "Dan Paulat") + set(CPACK_PACKAGE_FILE_NAME "supercell-wx-v${SCWX_VERSION}-windows-x64") + set(CPACK_PACKAGE_INSTALL_DIRECTORY "Supercell Wx") + set(CPACK_PACKAGE_ICON "${CMAKE_CURRENT_SOURCE_DIR}/res/icons/scwx-256.ico") + set(CPACK_PACKAGE_CHECKSUM SHA256) + set(CPACK_RESOURCE_FILE_LICENSE "${SCWX_DIR}/LICENSE.txt") + set(CPACK_GENERATOR WIX) + set(CPACK_PACKAGE_EXECUTABLES "supercell-wx;Supercell Wx") + set(CPACK_WIX_UPGRADE_GUID 36AD0F51-4D4F-4B5D-AB61-94C6B4E4FE1C) + set(CPACK_WIX_UI_BANNER "${CMAKE_CURRENT_SOURCE_DIR}/res/images/scwx-banner.png") + set(CPACK_WIX_UI_DIALOG "${CMAKE_CURRENT_SOURCE_DIR}/res/images/scwx-dialog.png") + set(CPACK_WIX_TEMPLATE "${CMAKE_CURRENT_SOURCE_DIR}/wix.template.in") + set(CPACK_WIX_EXTENSIONS WixUIExtension WiXUtilExtension) + + set(CPACK_INSTALL_CMAKE_PROJECTS + "${CMAKE_CURRENT_BINARY_DIR};${CMAKE_PROJECT_NAME};supercell-wx;/") + + include(CPack) +endif() diff --git a/scwx-qt/source/scwx/qt/main/main_window.cpp b/scwx-qt/source/scwx/qt/main/main_window.cpp index 85cff2fe..3249577a 100644 --- a/scwx-qt/source/scwx/qt/main/main_window.cpp +++ b/scwx-qt/source/scwx/qt/main/main_window.cpp @@ -627,9 +627,13 @@ void MainWindowImpl::AsyncSetup() // Check for updates if (generalSettings.update_notifications_enabled().GetValue()) { - boost::asio::post( - threadPool_, - [this]() { updateManager_->CheckForUpdates(main::kVersionString_); }); + boost::asio::post(threadPool_, + [this]() + { + manager::UpdateManager::RemoveTemporaryReleases(); + updateManager_->CheckForUpdates( + main::kVersionString_); + }); } } diff --git a/scwx-qt/source/scwx/qt/manager/download_manager.cpp b/scwx-qt/source/scwx/qt/manager/download_manager.cpp new file mode 100644 index 00000000..276a3106 --- /dev/null +++ b/scwx-qt/source/scwx/qt/manager/download_manager.cpp @@ -0,0 +1,280 @@ +#include +#include +#include + +#include + +#include +#include +#include + +namespace scwx +{ +namespace qt +{ +namespace manager +{ + +static const std::string logPrefix_ = "scwx::qt::manager::download_manager"; +static const auto logger_ = scwx::util::Logger::Create(logPrefix_); + +class DownloadManager::Impl +{ +public: + explicit Impl(DownloadManager* self) : self_ {self} {} + + ~Impl() { threadPool_.join(); } + + boost::asio::thread_pool threadPool_ {1u}; + + DownloadManager* self_; +}; + +DownloadManager::DownloadManager() : p(std::make_unique(this)) {} +DownloadManager::~DownloadManager() = default; + +void DownloadManager::Download( + const std::shared_ptr& request) +{ + boost::asio::post( + p->threadPool_, + [=]() + { + // Prepare destination file + const std::filesystem::path& destinationPath = + request->destination_path(); + + if (!destinationPath.has_parent_path()) + { + logger_->error("Destination has no parent path: \"{}\""); + + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::IOError); + + return; + } + + const std::filesystem::path parentPath = destinationPath.parent_path(); + + // Create directory if it doesn't exist + if (!std::filesystem::exists(parentPath)) + { + if (!std::filesystem::create_directories(parentPath)) + { + logger_->error("Unable to create download directory: \"{}\"", + parentPath.string()); + + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::IOError); + + return; + } + } + + // Remove file if it exists + if (std::filesystem::exists(destinationPath)) + { + std::error_code error; + if (!std::filesystem::remove(destinationPath, error)) + { + logger_->error( + "Unable to remove existing destination file ({}): \"{}\"", + error.message(), + destinationPath.string()); + + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::IOError); + + return; + } + } + + // Open file for writing + std::ofstream ofs {destinationPath, + std::ios_base::out | std::ios_base::binary | + std::ios_base::trunc}; + if (!ofs.is_open() || !ofs.good()) + { + logger_->error( + "Unable to open destination file for writing: \"{}\"", + destinationPath.string()); + + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::IOError); + + return; + } + + std::chrono::system_clock::time_point lastUpdated {}; + cpr::cpr_off_t lastDownloadNow {}; + cpr::cpr_off_t lastDownloadTotal {}; + + // Download file + cpr::Response response = + cpr::Get(cpr::Url {request->url()}, + cpr::ProgressCallback( + [&](cpr::cpr_off_t downloadTotal, + cpr::cpr_off_t downloadNow, + cpr::cpr_off_t /* uploadTotal */, + cpr::cpr_off_t /* uploadNow */, + std::intptr_t /* userdata */) + { + using namespace std::chrono_literals; + + std::chrono::system_clock::time_point now = + std::chrono::system_clock::now(); + + // Only emit an update every 100ms + if ((now > lastUpdated + 100ms || + downloadNow == downloadTotal) && + (downloadNow != lastDownloadNow || + downloadTotal != lastDownloadTotal)) + { + logger_->trace("Downloaded: {} / {}", + downloadNow, + downloadTotal); + + Q_EMIT request->ProgressUpdated(downloadNow, + downloadTotal); + + lastUpdated = now; + lastDownloadNow = downloadNow; + lastDownloadTotal = downloadTotal; + } + + return !request->IsCanceled(); + }), + cpr::WriteCallback( + [&](std::string data, std::intptr_t /* userdata */) + { + // Write file + ofs << data; + return !request->IsCanceled(); + })); + + bool ofsGood = ofs.good(); + ofs.close(); + + // Handle error response + if (response.error.code != cpr::ErrorCode::OK || + request->IsCanceled() || !ofsGood) + { + request::DownloadRequest::CompleteReason reason = + request::DownloadRequest::CompleteReason::IOError; + + if (request->IsCanceled()) + { + logger_->info("Download request cancelled: {}", request->url()); + + reason = request::DownloadRequest::CompleteReason::Canceled; + } + else if (response.error.code != cpr::ErrorCode::OK) + { + logger_->error("Error downloading file ({}): {}", + response.error.message, + request->url()); + + reason = request::DownloadRequest::CompleteReason::RemoteError; + } + else if (!ofsGood) + { + logger_->error("File I/O error: {}", destinationPath.string()); + + reason = request::DownloadRequest::CompleteReason::IOError; + } + + std::error_code error; + if (!std::filesystem::remove(destinationPath, error)) + { + logger_->error("Unable to remove destination file: {}, {}", + destinationPath.string(), + error.message()); + } + + Q_EMIT request->RequestComplete(reason); + + return; + } + + // Handle response + const auto contentMd5 = response.header.find("content-md5"); + if (contentMd5 != response.header.cend() && + !contentMd5->second.empty()) + { + // Open file for reading + std::ifstream is {destinationPath, + std::ios_base::in | std::ios_base::binary}; + if (!is.is_open() || !is.good()) + { + logger_->error("Unable to open destination file for reading: {}", + destinationPath.string()); + + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::IOError); + + return; + } + + // Compute MD5 + std::vector digest {}; + if (!util::ComputeDigest(EVP_md5(), is, digest)) + { + logger_->error("Failed to compute MD5: {}", + destinationPath.string()); + + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::IOError); + + return; + } + + // Compare calculated MD5 with digest in response header + QByteArray expectedDigestArray = + QByteArray::fromBase64(contentMd5->second.c_str()); + std::vector expectedDigest( + expectedDigestArray.cbegin(), expectedDigestArray.cend()); + + if (digest != expectedDigest) + { + QByteArray calculatedDigest( + reinterpret_cast(digest.data()), digest.size()); + + logger_->error("Digest mismatch: {} != {}", + calculatedDigest.toBase64().toStdString(), + contentMd5->second); + + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::DigestError); + + return; + } + } + + logger_->info("Download complete: {}", request->url()); + Q_EMIT request->RequestComplete( + request::DownloadRequest::CompleteReason::OK); + }); +} + +std::shared_ptr DownloadManager::Instance() +{ + static std::weak_ptr downloadManagerReference_ {}; + static std::mutex instanceMutex_ {}; + + std::unique_lock lock(instanceMutex_); + + std::shared_ptr downloadManager = + downloadManagerReference_.lock(); + + if (downloadManager == nullptr) + { + downloadManager = std::make_shared(); + downloadManagerReference_ = downloadManager; + } + + return downloadManager; +} + +} // namespace manager +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/manager/download_manager.hpp b/scwx-qt/source/scwx/qt/manager/download_manager.hpp new file mode 100644 index 00000000..de5b61f8 --- /dev/null +++ b/scwx-qt/source/scwx/qt/manager/download_manager.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include + +#include + +namespace scwx +{ +namespace qt +{ +namespace manager +{ + +class DownloadManager : public QObject +{ + Q_OBJECT + +public: + explicit DownloadManager(); + ~DownloadManager(); + + void Download(const std::shared_ptr& request); + + static std::shared_ptr Instance(); + +private: + class Impl; + std::unique_ptr p; +}; + +} // namespace manager +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/manager/update_manager.cpp b/scwx-qt/source/scwx/qt/manager/update_manager.cpp index 08304263..d12874cb 100644 --- a/scwx-qt/source/scwx/qt/manager/update_manager.cpp +++ b/scwx-qt/source/scwx/qt/manager/update_manager.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace scwx { @@ -230,6 +231,34 @@ UpdateManager::Impl::FindLatestRelease() return {latestRelease, latestReleaseVersion}; } +void UpdateManager::RemoveTemporaryReleases() +{ +#if defined(_WIN32) + const std::string destination { + QStandardPaths::writableLocation(QStandardPaths::TempLocation) + .toStdString()}; + const std::filesystem::path destinationPath {destination}; + std::filesystem::directory_iterator it {destinationPath}; + + for (auto& file : it) + { + if (file.is_regular_file() && file.path().string().ends_with(".msi") && + file.path().stem().string().starts_with("supercell-wx-")) + { + logger_->info("Removing temporary installer: {}", + file.path().string()); + + std::error_code error; + if (!std::filesystem::remove(file.path(), error)) + { + logger_->warn("Error removing temporary installer: {}", + error.message()); + } + } + } +#endif +} + std::shared_ptr UpdateManager::Instance() { static std::weak_ptr updateManagerReference_ {}; diff --git a/scwx-qt/source/scwx/qt/manager/update_manager.hpp b/scwx-qt/source/scwx/qt/manager/update_manager.hpp index 10a97350..5995c237 100644 --- a/scwx-qt/source/scwx/qt/manager/update_manager.hpp +++ b/scwx-qt/source/scwx/qt/manager/update_manager.hpp @@ -27,6 +27,8 @@ class UpdateManager : public QObject bool CheckForUpdates(const std::string& currentVersion = {}); + static void RemoveTemporaryReleases(); + static std::shared_ptr Instance(); signals: diff --git a/scwx-qt/source/scwx/qt/request/download_request.cpp b/scwx-qt/source/scwx/qt/request/download_request.cpp new file mode 100644 index 00000000..56244d7f --- /dev/null +++ b/scwx-qt/source/scwx/qt/request/download_request.cpp @@ -0,0 +1,57 @@ +#include + +namespace scwx +{ +namespace qt +{ +namespace request +{ + +static const std::string logPrefix_ = "scwx::qt::request::download_request"; + +class DownloadRequest::Impl +{ +public: + explicit Impl(const std::string& url, + const std::filesystem::path& destinationPath) : + url_ {url}, destinationPath_ {destinationPath} + { + } + ~Impl() = default; + + const std::string url_; + const std::filesystem::path destinationPath_; + + bool canceled_ = false; +}; + +DownloadRequest::DownloadRequest(const std::string& url, + const std::filesystem::path& destinationPath) : + p(std::make_unique(url, destinationPath)) +{ +} +DownloadRequest::~DownloadRequest() = default; + +const std::string& DownloadRequest::url() const +{ + return p->url_; +} + +const std::filesystem::path& DownloadRequest::destination_path() const +{ + return p->destinationPath_; +} + +void DownloadRequest::Cancel() +{ + p->canceled_ = true; +} + +bool DownloadRequest::IsCanceled() const +{ + return p->canceled_; +} + +} // namespace request +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/request/download_request.hpp b/scwx-qt/source/scwx/qt/request/download_request.hpp new file mode 100644 index 00000000..aa73bd17 --- /dev/null +++ b/scwx-qt/source/scwx/qt/request/download_request.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include + +namespace scwx +{ +namespace qt +{ +namespace request +{ + +class DownloadRequest : public QObject +{ + Q_OBJECT + +public: + enum class CompleteReason + { + OK, + Canceled, + IOError, + RemoteError, + DigestError + }; + + explicit DownloadRequest(const std::string& url, + const std::filesystem::path& destinationPath); + ~DownloadRequest(); + + const std::string& url() const; + const std::filesystem::path& destination_path() const; + + void Cancel(); + + bool IsCanceled() const; + +private: + class Impl; + std::unique_ptr p; + +signals: + void ProgressUpdated(std::ptrdiff_t downloadedBytes, + std::ptrdiff_t totalBytes); + void RequestComplete(CompleteReason reason); +}; + +} // namespace request +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/types/github_types.cpp b/scwx-qt/source/scwx/qt/types/github_types.cpp index e741c23b..f0f241f1 100644 --- a/scwx-qt/source/scwx/qt/types/github_types.cpp +++ b/scwx-qt/source/scwx/qt/types/github_types.cpp @@ -11,10 +11,25 @@ namespace types namespace gh { +ReleaseAsset tag_invoke(boost::json::value_to_tag, + const boost::json::value& jv) +{ + auto& jo = jv.as_object(); + + ReleaseAsset asset {}; + + // Required parameters + asset.name_ = jo.at("name").as_string(); + asset.contentType_ = jo.at("content_type").as_string(); + asset.browserDownloadUrl_ = jo.at("browser_download_url").as_string(); + + return asset; +} + Release tag_invoke(boost::json::value_to_tag, const boost::json::value& jv) { - auto jo = jv.as_object(); + auto& jo = jv.as_object(); Release release {}; @@ -24,6 +39,9 @@ Release tag_invoke(boost::json::value_to_tag, release.draft_ = jo.at("draft").as_bool(); release.prerelease_ = jo.at("prerelease").as_bool(); + release.assets_ = + boost::json::value_to>(jo.at("assets")); + // Optional parameters if (jo.contains("body")) { diff --git a/scwx-qt/source/scwx/qt/types/github_types.hpp b/scwx-qt/source/scwx/qt/types/github_types.hpp index 829cfd28..b5fcfaee 100644 --- a/scwx-qt/source/scwx/qt/types/github_types.hpp +++ b/scwx-qt/source/scwx/qt/types/github_types.hpp @@ -13,6 +13,18 @@ namespace types namespace gh { +/** + * @brief GitHub Release Asset object + * + * + */ +struct ReleaseAsset +{ + std::string name_ {}; + std::string contentType_ {}; + std::string browserDownloadUrl_ {}; +}; + /** * @brief GitHub Release object * @@ -25,10 +37,14 @@ struct Release std::string body_ {}; bool draft_ {}; bool prerelease_ {}; + + std::vector assets_ {}; }; -Release tag_invoke(boost::json::value_to_tag, - const boost::json::value& jv); +ReleaseAsset tag_invoke(boost::json::value_to_tag, + const boost::json::value& jv); +Release tag_invoke(boost::json::value_to_tag, + const boost::json::value& jv); } // namespace gh } // namespace types diff --git a/scwx-qt/source/scwx/qt/ui/download_dialog.cpp b/scwx-qt/source/scwx/qt/ui/download_dialog.cpp new file mode 100644 index 00000000..235ad9bf --- /dev/null +++ b/scwx-qt/source/scwx/qt/ui/download_dialog.cpp @@ -0,0 +1,105 @@ +#include +#include + +#include +#include +#include +#include +#include + +namespace scwx +{ +namespace qt +{ +namespace ui +{ + +class DownloadDialog::Impl +{ +public: + explicit Impl() {}; + ~Impl() = default; + + boost::timer::cpu_timer timer_ {}; +}; + +DownloadDialog::DownloadDialog(QWidget* parent) : + ProgressDialog(parent), p {std::make_unique()} +{ + auto buttonBox = button_box(); + buttonBox->setStandardButtons(QDialogButtonBox::StandardButton::Ok | + QDialogButtonBox::StandardButton::Cancel); + buttonBox->button(QDialogButtonBox::StandardButton::Ok) + ->setText("Install Now"); + + setWindowTitle(tr("Download File")); + SetRange(0, 100); +} + +DownloadDialog::~DownloadDialog() {} + +void DownloadDialog::set_filename(const std::string& filename) +{ + QString label = tr("Downloading %1...").arg(filename.c_str()); + SetTopLabelText(label); +} + +void DownloadDialog::StartDownload() +{ + // Hide the OK button until the download is finished + button_box() + ->button(QDialogButtonBox::StandardButton::Ok) + ->setVisible(false); + + SetValue(0); + SetBottomLabelText(tr("Waiting for download to begin...")); + p->timer_.start(); + show(); +} + +void DownloadDialog::UpdateProgress(std::ptrdiff_t downloadedBytes, + std::ptrdiff_t totalBytes) +{ + using namespace std::chrono_literals; + + const std::chrono::nanoseconds elapsed {p->timer_.elapsed().wall}; + + const double percentComplete = + (totalBytes > 0.0) ? static_cast(downloadedBytes) / totalBytes : + 0.0; + const int progressValue = static_cast(percentComplete * 100.0); + + SetValue(progressValue); + + const std::chrono::seconds timeRemaining = + (percentComplete > 0.0) ? + std::chrono::duration_cast( + elapsed / percentComplete - elapsed) : + 0s; + const std::chrono::hours hoursRemaining = + std::chrono::duration_cast(timeRemaining); + + const std::string progressText = + fmt::format("{} of {} downloaded ({}:{:%M:%S} remaining)", + util::BytesToString(downloadedBytes), + util::BytesToString(totalBytes), + hoursRemaining.count(), + timeRemaining); + + SetBottomLabelText(QString::fromStdString(progressText)); +} + +void DownloadDialog::FinishDownload() +{ + button_box()->button(QDialogButtonBox::StandardButton::Ok)->setVisible(true); +} + +void DownloadDialog::CancelDownload() +{ + SetValue(0); + SetBottomLabelText(tr("Error occurred while downloading")); +} + +} // namespace ui +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/ui/download_dialog.hpp b/scwx-qt/source/scwx/qt/ui/download_dialog.hpp new file mode 100644 index 00000000..ecf4a0c0 --- /dev/null +++ b/scwx-qt/source/scwx/qt/ui/download_dialog.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include + +namespace scwx +{ +namespace qt +{ +namespace ui +{ +class DownloadDialog : public ProgressDialog +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(DownloadDialog) + +public: + explicit DownloadDialog(QWidget* parent = nullptr); + ~DownloadDialog(); + + void set_filename(const std::string& filename); + +public slots: + void StartDownload(); + void UpdateProgress(std::ptrdiff_t downloadedBytes, + std::ptrdiff_t totalBytes); + void FinishDownload(); + void CancelDownload(); + +private: + class Impl; + std::unique_ptr p; +}; + +} // namespace ui +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/ui/progress_dialog.cpp b/scwx-qt/source/scwx/qt/ui/progress_dialog.cpp new file mode 100644 index 00000000..70486805 --- /dev/null +++ b/scwx-qt/source/scwx/qt/ui/progress_dialog.cpp @@ -0,0 +1,66 @@ +#include "progress_dialog.hpp" +#include "ui_progress_dialog.h" + +namespace scwx +{ +namespace qt +{ +namespace ui +{ + +class ProgressDialog::Impl +{ +public: + explicit Impl() = default; + ~Impl() = default; +}; + +ProgressDialog::ProgressDialog(QWidget* parent) : + QDialog(parent), p {std::make_unique()}, ui(new Ui::ProgressDialog) +{ + ui->setupUi(this); +} + +ProgressDialog::~ProgressDialog() +{ + delete ui; +} + +QDialogButtonBox* ProgressDialog::button_box() const +{ + return ui->buttonBox; +} + +void ProgressDialog::SetTopLabelText(const QString& text) +{ + ui->topLabel->setText(text); +} + +void ProgressDialog::SetBottomLabelText(const QString& text) +{ + ui->bottomLabel->setText(text); +} + +void ProgressDialog::SetMinimum(int minimum) +{ + ui->progressBar->setMinimum(minimum); +} + +void ProgressDialog::SetMaximum(int maximum) +{ + ui->progressBar->setMaximum(maximum); +} + +void ProgressDialog::SetRange(int minimum, int maximum) +{ + ui->progressBar->setRange(minimum, maximum); +} + +void ProgressDialog::SetValue(int value) +{ + ui->progressBar->setValue(value); +} + +} // namespace ui +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/ui/progress_dialog.hpp b/scwx-qt/source/scwx/qt/ui/progress_dialog.hpp new file mode 100644 index 00000000..71e4ac26 --- /dev/null +++ b/scwx-qt/source/scwx/qt/ui/progress_dialog.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include + +class QDialogButtonBox; + +namespace Ui +{ +class ProgressDialog; +} + +namespace scwx +{ +namespace qt +{ +namespace ui +{ +class ProgressDialog : public QDialog +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(ProgressDialog) + +public: + explicit ProgressDialog(QWidget* parent = nullptr); + ~ProgressDialog(); + +protected: + QDialogButtonBox* button_box() const; + +public slots: + void SetTopLabelText(const QString& text); + void SetBottomLabelText(const QString& text); + void SetMinimum(int minimum); + void SetMaximum(int maximum); + void SetRange(int minimum, int maximum); + void SetValue(int value); + +private: + class Impl; + std::unique_ptr p; + Ui::ProgressDialog* ui; +}; + +} // namespace ui +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/ui/progress_dialog.ui b/scwx-qt/source/scwx/qt/ui/progress_dialog.ui new file mode 100644 index 00000000..b9b091b3 --- /dev/null +++ b/scwx-qt/source/scwx/qt/ui/progress_dialog.ui @@ -0,0 +1,85 @@ + + + ProgressDialog + + + + 0 + 0 + 394 + 116 + + + + Dialog + + + + + + Downloading supercell-wx-v0.4.4-windows-x64.msi... + + + + + + + 24 + + + + + + + 25.3 MB of 69.1 MB downloaded (00:00:04 remaining) + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + ProgressDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ProgressDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/scwx-qt/source/scwx/qt/ui/update_dialog.cpp b/scwx-qt/source/scwx/qt/ui/update_dialog.cpp index 4029fa9a..0ca61a18 100644 --- a/scwx-qt/source/scwx/qt/ui/update_dialog.cpp +++ b/scwx-qt/source/scwx/qt/ui/update_dialog.cpp @@ -1,10 +1,15 @@ #include "update_dialog.hpp" #include "ui_update_dialog.h" #include +#include #include +#include +#include #include #include +#include +#include namespace scwx { @@ -13,19 +18,29 @@ namespace qt namespace ui { -class UpdateDialogImpl +static const std::string logPrefix_ = "scwx::qt::ui::update_dialog"; +static const auto logger_ = scwx::util::Logger::Create(logPrefix_); + +class UpdateDialog::Impl { public: - explicit UpdateDialogImpl() = default; - ~UpdateDialogImpl() = default; + explicit Impl(UpdateDialog* self) : self_ {self} {}; + ~Impl() = default; + + void HandleAsset(const types::gh::ReleaseAsset& asset); + + UpdateDialog* self_; + + std::shared_ptr downloadManager_ { + manager::DownloadManager::Instance()}; std::string downloadUrl_ {}; + std::string installUrl_ {}; + std::string installFilename_ {}; }; UpdateDialog::UpdateDialog(QWidget* parent) : - QDialog(parent), - p {std::make_unique()}, - ui(new Ui::UpdateDialog) + QDialog(parent), p {std::make_unique(this)}, ui(new Ui::UpdateDialog) { ui->setupUi(this); @@ -37,6 +52,8 @@ UpdateDialog::UpdateDialog(QWidget* parent) : ui->bannerLabel->setFont(titleFont); ui->releaseNotesText->setOpenExternalLinks(true); + + ui->installUpdateButton->setVisible(false); } UpdateDialog::~UpdateDialog() @@ -56,6 +73,27 @@ void UpdateDialog::UpdateReleaseInfo(const std::string& latestVersion, QString::fromStdString(latestRelease.body_)); p->downloadUrl_ = latestRelease.htmlUrl_; + + ui->installUpdateButton->setVisible(false); + + for (auto& asset : latestRelease.assets_) + { + p->HandleAsset(asset); + } +} + +void UpdateDialog::Impl::HandleAsset(const types::gh::ReleaseAsset& asset) +{ +#if defined(_WIN32) + if (asset.name_.ends_with(".msi")) + { + self_->ui->installUpdateButton->setVisible(true); + installUrl_ = asset.browserDownloadUrl_; + installFilename_ = asset.name_; + } +#else + Q_UNUSED(asset) +#endif } void UpdateDialog::on_downloadButton_clicked() @@ -66,6 +104,86 @@ void UpdateDialog::on_downloadButton_clicked() } } +void UpdateDialog::on_installUpdateButton_clicked() +{ + if (!p->installUrl_.empty()) + { + ui->installUpdateButton->setEnabled(false); + + std::string destinationPath { + QStandardPaths::writableLocation(QStandardPaths::TempLocation) + .toStdString()}; + + std::shared_ptr request = + std::make_shared( + p->installUrl_, + std::filesystem::path(destinationPath) / p->installFilename_); + + DownloadDialog* downloadDialog = new DownloadDialog(this); + downloadDialog->setAttribute(Qt::WA_DeleteOnClose); + + // Connect request signals + connect(request.get(), + &request::DownloadRequest::ProgressUpdated, + downloadDialog, + &DownloadDialog::UpdateProgress); + connect(request.get(), + &request::DownloadRequest::RequestComplete, + downloadDialog, + [=](request::DownloadRequest::CompleteReason reason) + { + switch (reason) + { + case request::DownloadRequest::CompleteReason::OK: + downloadDialog->FinishDownload(); + break; + + default: + downloadDialog->CancelDownload(); + break; + } + }); + + // Connect dialog signals + connect( + downloadDialog, + &QDialog::accepted, + this, + [=, this]() + { + std::filesystem::path installerPackage = + request->destination_path(); + installerPackage.make_preferred(); + + logger_->info("Launching application installer: {}", + installerPackage.string()); + + if (!QProcess::startDetached( + "msiexec.exe", + {"/i", QString::fromStdString(installerPackage.string())})) + { + logger_->error("Failed to launch installer"); + } + + ui->installUpdateButton->setEnabled(true); + }); + connect(downloadDialog, + &QDialog::rejected, + this, + [=, this]() + { + request->Cancel(); + + ui->installUpdateButton->setEnabled(true); + }); + + downloadDialog->set_filename(p->installFilename_); + downloadDialog->StartDownload(); + + p->downloadManager_->Download(request); + } +} + } // namespace ui } // namespace qt } // namespace scwx diff --git a/scwx-qt/source/scwx/qt/ui/update_dialog.hpp b/scwx-qt/source/scwx/qt/ui/update_dialog.hpp index 0df39648..fee03e2c 100644 --- a/scwx-qt/source/scwx/qt/ui/update_dialog.hpp +++ b/scwx-qt/source/scwx/qt/ui/update_dialog.hpp @@ -16,11 +16,10 @@ namespace qt namespace ui { -class UpdateDialogImpl; - class UpdateDialog : public QDialog { Q_OBJECT + Q_DISABLE_COPY_MOVE(UpdateDialog) public: explicit UpdateDialog(QWidget* parent = nullptr); @@ -31,11 +30,12 @@ class UpdateDialog : public QDialog private slots: void on_downloadButton_clicked(); + void on_installUpdateButton_clicked(); private: - friend UpdateDialogImpl; - std::unique_ptr p; - Ui::UpdateDialog* ui; + class Impl; + std::unique_ptr p; + Ui::UpdateDialog* ui; }; } // namespace ui diff --git a/scwx-qt/source/scwx/qt/ui/update_dialog.ui b/scwx-qt/source/scwx/qt/ui/update_dialog.ui index 0facca2a..5aa8e054 100644 --- a/scwx-qt/source/scwx/qt/ui/update_dialog.ui +++ b/scwx-qt/source/scwx/qt/ui/update_dialog.ui @@ -139,6 +139,13 @@ + + + + Install Update + + + diff --git a/scwx-qt/wix.template.in b/scwx-qt/wix.template.in new file mode 100644 index 00000000..01a9b8df --- /dev/null +++ b/scwx-qt/wix.template.in @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + ProductIcon.ico + + + + + + + + + + + + + + + + + + WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed + + + + + + + + + + + + diff --git a/test/source/scwx/util/strings.test.cpp b/test/source/scwx/util/strings.test.cpp index e91c95d9..825b3999 100644 --- a/test/source/scwx/util/strings.test.cpp +++ b/test/source/scwx/util/strings.test.cpp @@ -7,6 +7,35 @@ namespace scwx namespace util { +class BytesToStringTest : + public testing::TestWithParam> +{ +}; + +TEST_P(BytesToStringTest, BytesToString) +{ + auto& [bytes, expected] = GetParam(); + + std::string s = BytesToString(bytes); + + EXPECT_EQ(s, expected); +} + +INSTANTIATE_TEST_SUITE_P(StringsTest, + BytesToStringTest, + testing::Values(std::make_pair(123, "123 bytes"), + std::make_pair(1000, "0.98 KB"), + std::make_pair(1018, "0.99 KB"), + std::make_pair(1024, "1.0 KB"), + std::make_pair(1127, "1.1 KB"), + std::make_pair(1260, "1.23 KB"), + std::make_pair(24012, "23.4 KB"), + std::make_pair(353974, "346 KB"), + std::make_pair(1024000, "0.98 MB"), + std::make_pair(1048576000, "0.98 GB"), + std::make_pair(1073741824000, + "0.98 TB"))); + TEST(StringsTest, ParseTokensColor) { static const std::string line {"Color: red green blue alpha discarded"}; diff --git a/wxdata/include/scwx/util/digest.hpp b/wxdata/include/scwx/util/digest.hpp new file mode 100644 index 00000000..e0bbc3c9 --- /dev/null +++ b/wxdata/include/scwx/util/digest.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +#include + +namespace scwx +{ +namespace util +{ + +bool ComputeDigest(const EVP_MD* mdtype, + std::istream& is, + std::vector& digest); + +} // namespace util +} // namespace scwx diff --git a/wxdata/include/scwx/util/strings.hpp b/wxdata/include/scwx/util/strings.hpp index ad0d1f9c..e2821331 100644 --- a/wxdata/include/scwx/util/strings.hpp +++ b/wxdata/include/scwx/util/strings.hpp @@ -9,6 +9,16 @@ namespace scwx namespace util { +/** + * @brief Print the number of bytes using a dynamic suffix and limited number of + * decimal points. + * + * @param [in] bytes Number of bytes + * + * @return Human readable size string + */ +std::string BytesToString(std::ptrdiff_t bytes); + /** * @brief Parse a list of tokens from a string * diff --git a/wxdata/source/scwx/util/digest.cpp b/wxdata/source/scwx/util/digest.cpp new file mode 100644 index 00000000..fe0c055d --- /dev/null +++ b/wxdata/source/scwx/util/digest.cpp @@ -0,0 +1,82 @@ +#include +#include + +namespace scwx +{ +namespace util +{ + +static const std::string logPrefix_ = "scwx::util::digest"; +static const auto logger_ = scwx::util::Logger::Create(logPrefix_); + +bool ComputeDigest(const EVP_MD* mdtype, + std::istream& is, + std::vector& digest) +{ + int mdsize; + EVP_MD_CTX* mdctx = nullptr; + + digest.clear(); + + if ((mdsize = EVP_MD_get_size(mdtype)) < 1) + { + logger_->error("Invalid digest"); + return false; + } + + if ((mdctx = EVP_MD_CTX_new()) == nullptr) + { + logger_->error("Error allocating a digest context"); + return false; + } + + if (!EVP_DigestInit_ex(mdctx, mdtype, nullptr)) + { + logger_->error("Message digest initialization failed"); + EVP_MD_CTX_free(mdctx); + return false; + } + + is.seekg(0, std::ios_base::end); + const std::size_t streamSize = is.tellg(); + is.seekg(0, std::ios_base::beg); + + std::size_t bytesRead = 0; + std::size_t chunkSize = 4096; + std::string fileData; + fileData.resize(chunkSize); + + while (bytesRead < streamSize) + { + const std::size_t bytesRemaining = streamSize - bytesRead; + const std::size_t readSize = std::min(chunkSize, bytesRemaining); + + is.read(fileData.data(), readSize); + + if (!is.good() || !EVP_DigestUpdate(mdctx, fileData.data(), readSize)) + { + logger_->error("Message digest update failed"); + EVP_MD_CTX_free(mdctx); + return false; + } + + bytesRead += readSize; + } + + digest.resize(mdsize); + + if (!EVP_DigestFinal_ex(mdctx, digest.data(), nullptr)) + { + logger_->error("Message digest finalization failed"); + EVP_MD_CTX_free(mdctx); + digest.clear(); + return false; + } + + EVP_MD_CTX_free(mdctx); + + return true; +} + +} // namespace util +} // namespace scwx diff --git a/wxdata/source/scwx/util/strings.cpp b/wxdata/source/scwx/util/strings.cpp index 7067d821..d5ac8cfc 100644 --- a/wxdata/source/scwx/util/strings.cpp +++ b/wxdata/source/scwx/util/strings.cpp @@ -4,12 +4,76 @@ #include #include +#include namespace scwx { namespace util { +std::string BytesToString(std::ptrdiff_t bytes) +{ + auto FormatNumber = [](double number) -> std::string + { + int precision; + + // Determine precision + if (number >= 100.0) + { + precision = 0; + } + else if (number >= 10.0) + { + precision = 1; + } + else + { + precision = 2; + } + + // Format the number + std::string formattedNum = fmt::format("{:.{}f}", number, precision); + + // Remove trailing zeroes + std::size_t found = formattedNum.find_last_not_of('0'); + if (found != std::string::npos && formattedNum[found] == '.') + { + // Keep one trailing zero if it's a decimal point + found++; + } + formattedNum.erase(found + 1, std::string::npos); + + return formattedNum; + }; + + // Print with appropriate suffix + if (bytes < 1000) + { + return fmt::format("{} bytes", bytes); + } + + double kilobytes = bytes / 1024.0; + if (kilobytes < 1000.0) + { + return fmt::format("{} KB", FormatNumber(kilobytes)); + } + + double megabytes = kilobytes / 1024.0; + if (megabytes < 1000.0) + { + return fmt::format("{} MB", FormatNumber(megabytes)); + } + + double gigabytes = megabytes / 1024.0; + if (gigabytes < 1000.0) + { + return fmt::format("{} GB", FormatNumber(gigabytes)); + } + + double terabytes = gigabytes / 1024.0; + return fmt::format("{} TB", FormatNumber(terabytes)); +} + std::vector ParseTokens(const std::string& s, std::vector delimiters, std::size_t pos) diff --git a/wxdata/wxdata.cmake b/wxdata/wxdata.cmake index 122786fd..0ce08cbb 100644 --- a/wxdata/wxdata.cmake +++ b/wxdata/wxdata.cmake @@ -67,7 +67,8 @@ set(SRC_PROVIDER source/scwx/provider/aws_level2_data_provider.cpp source/scwx/provider/nexrad_data_provider.cpp source/scwx/provider/nexrad_data_provider_factory.cpp source/scwx/provider/warnings_provider.cpp) -set(HDR_UTIL include/scwx/util/enum.hpp +set(HDR_UTIL include/scwx/util/digest.hpp + include/scwx/util/enum.hpp include/scwx/util/environment.hpp include/scwx/util/float.hpp include/scwx/util/hash.hpp @@ -80,7 +81,8 @@ set(HDR_UTIL include/scwx/util/enum.hpp include/scwx/util/threads.hpp include/scwx/util/time.hpp include/scwx/util/vectorbuf.hpp) -set(SRC_UTIL source/scwx/util/environment.cpp +set(SRC_UTIL source/scwx/util/digest.cpp + source/scwx/util/environment.cpp source/scwx/util/float.cpp source/scwx/util/hash.cpp source/scwx/util/logger.cpp