mirror of
				https://github.com/ciphervance/supercell-wx.git
				synced 2025-10-31 00:00:04 +00:00 
			
		
		
		
	Merge pull request #172 from dpaulat/feature/installer
Add Windows Installer and Updater
This commit is contained in:
		
						commit
						ef1101ac4b
					
				
					 30 changed files with 1304 additions and 23 deletions
				
			
		
							
								
								
									
										14
									
								
								.github/workflows/ci.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/ci.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -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: | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								scwx-qt/res/images/scwx-banner.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								scwx-qt/res/images/scwx-banner.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								scwx-qt/res/images/scwx-dialog.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								scwx-qt/res/images/scwx-dialog.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 88 KiB | 
|  | @ -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() | ||||
|  |  | |||
|  | @ -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_); | ||||
|                         }); | ||||
|    } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										280
									
								
								scwx-qt/source/scwx/qt/manager/download_manager.cpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								scwx-qt/source/scwx/qt/manager/download_manager.cpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,280 @@ | |||
| #include <scwx/qt/manager/download_manager.hpp> | ||||
| #include <scwx/util/digest.hpp> | ||||
| #include <scwx/util/logger.hpp> | ||||
| 
 | ||||
| #include <fstream> | ||||
| 
 | ||||
| #include <boost/asio/post.hpp> | ||||
| #include <boost/asio/thread_pool.hpp> | ||||
| #include <cpr/cpr.h> | ||||
| 
 | ||||
| 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<Impl>(this)) {} | ||||
| DownloadManager::~DownloadManager() = default; | ||||
| 
 | ||||
| void DownloadManager::Download( | ||||
|    const std::shared_ptr<request::DownloadRequest>& 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<std::uint8_t> 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<std::uint8_t> expectedDigest( | ||||
|                expectedDigestArray.cbegin(), expectedDigestArray.cend()); | ||||
| 
 | ||||
|             if (digest != expectedDigest) | ||||
|             { | ||||
|                QByteArray calculatedDigest( | ||||
|                   reinterpret_cast<char*>(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> DownloadManager::Instance() | ||||
| { | ||||
|    static std::weak_ptr<DownloadManager> downloadManagerReference_ {}; | ||||
|    static std::mutex                     instanceMutex_ {}; | ||||
| 
 | ||||
|    std::unique_lock lock(instanceMutex_); | ||||
| 
 | ||||
|    std::shared_ptr<DownloadManager> downloadManager = | ||||
|       downloadManagerReference_.lock(); | ||||
| 
 | ||||
|    if (downloadManager == nullptr) | ||||
|    { | ||||
|       downloadManager           = std::make_shared<DownloadManager>(); | ||||
|       downloadManagerReference_ = downloadManager; | ||||
|    } | ||||
| 
 | ||||
|    return downloadManager; | ||||
| } | ||||
| 
 | ||||
| } // namespace manager
 | ||||
| } // namespace qt
 | ||||
| } // namespace scwx
 | ||||
							
								
								
									
										36
									
								
								scwx-qt/source/scwx/qt/manager/download_manager.hpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								scwx-qt/source/scwx/qt/manager/download_manager.hpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | |||
| #pragma once | ||||
| 
 | ||||
| #include <scwx/qt/request/download_request.hpp> | ||||
| 
 | ||||
| #include <memory> | ||||
| #include <string> | ||||
| 
 | ||||
| #include <QObject> | ||||
| 
 | ||||
| namespace scwx | ||||
| { | ||||
| namespace qt | ||||
| { | ||||
| namespace manager | ||||
| { | ||||
| 
 | ||||
| class DownloadManager : public QObject | ||||
| { | ||||
|    Q_OBJECT | ||||
| 
 | ||||
| public: | ||||
|    explicit DownloadManager(); | ||||
|    ~DownloadManager(); | ||||
| 
 | ||||
|    void Download(const std::shared_ptr<request::DownloadRequest>& request); | ||||
| 
 | ||||
|    static std::shared_ptr<DownloadManager> Instance(); | ||||
| 
 | ||||
| private: | ||||
|    class Impl; | ||||
|    std::unique_ptr<Impl> p; | ||||
| }; | ||||
| 
 | ||||
| } // namespace manager
 | ||||
| } // namespace qt
 | ||||
| } // namespace scwx
 | ||||
|  | @ -6,6 +6,7 @@ | |||
| #include <boost/json.hpp> | ||||
| #include <cpr/cpr.h> | ||||
| #include <re2/re2.h> | ||||
| #include <QStandardPaths> | ||||
| 
 | ||||
| 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> UpdateManager::Instance() | ||||
| { | ||||
|    static std::weak_ptr<UpdateManager> updateManagerReference_ {}; | ||||
|  |  | |||
|  | @ -27,6 +27,8 @@ public: | |||
| 
 | ||||
|    bool CheckForUpdates(const std::string& currentVersion = {}); | ||||
| 
 | ||||
|    static void RemoveTemporaryReleases(); | ||||
| 
 | ||||
|    static std::shared_ptr<UpdateManager> Instance(); | ||||
| 
 | ||||
| signals: | ||||
|  |  | |||
							
								
								
									
										57
									
								
								scwx-qt/source/scwx/qt/request/download_request.cpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								scwx-qt/source/scwx/qt/request/download_request.cpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| #include <scwx/qt/request/download_request.hpp> | ||||
| 
 | ||||
| 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<Impl>(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
 | ||||
							
								
								
									
										52
									
								
								scwx-qt/source/scwx/qt/request/download_request.hpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								scwx-qt/source/scwx/qt/request/download_request.hpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,52 @@ | |||
| #pragma once | ||||
| 
 | ||||
| #include <filesystem> | ||||
| #include <memory> | ||||
| 
 | ||||
| #include <QObject> | ||||
| 
 | ||||
| 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<Impl> p; | ||||
| 
 | ||||
| signals: | ||||
|    void ProgressUpdated(std::ptrdiff_t downloadedBytes, | ||||
|                         std::ptrdiff_t totalBytes); | ||||
|    void RequestComplete(CompleteReason reason); | ||||
| }; | ||||
| 
 | ||||
| } // namespace request
 | ||||
| } // namespace qt
 | ||||
| } // namespace scwx
 | ||||
|  | @ -11,10 +11,25 @@ namespace types | |||
| namespace gh | ||||
| { | ||||
| 
 | ||||
| ReleaseAsset tag_invoke(boost::json::value_to_tag<ReleaseAsset>, | ||||
|                         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<Release>, | ||||
|                    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>, | |||
|    release.draft_      = jo.at("draft").as_bool(); | ||||
|    release.prerelease_ = jo.at("prerelease").as_bool(); | ||||
| 
 | ||||
|    release.assets_ = | ||||
|       boost::json::value_to<std::vector<ReleaseAsset>>(jo.at("assets")); | ||||
| 
 | ||||
|    // Optional parameters
 | ||||
|    if (jo.contains("body")) | ||||
|    { | ||||
|  |  | |||
|  | @ -13,6 +13,18 @@ namespace types | |||
| namespace gh | ||||
| { | ||||
| 
 | ||||
| /**
 | ||||
|  * @brief GitHub Release Asset object | ||||
|  * | ||||
|  * <https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28>
 | ||||
|  */ | ||||
| struct ReleaseAsset | ||||
| { | ||||
|    std::string name_ {}; | ||||
|    std::string contentType_ {}; | ||||
|    std::string browserDownloadUrl_ {}; | ||||
| }; | ||||
| 
 | ||||
| /**
 | ||||
|  * @brief GitHub Release object | ||||
|  * | ||||
|  | @ -25,8 +37,12 @@ struct Release | |||
|    std::string body_ {}; | ||||
|    bool        draft_ {}; | ||||
|    bool        prerelease_ {}; | ||||
| 
 | ||||
|    std::vector<ReleaseAsset> assets_ {}; | ||||
| }; | ||||
| 
 | ||||
| ReleaseAsset tag_invoke(boost::json::value_to_tag<ReleaseAsset>, | ||||
|                         const boost::json::value& jv); | ||||
| Release      tag_invoke(boost::json::value_to_tag<Release>, | ||||
|                         const boost::json::value& jv); | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										105
									
								
								scwx-qt/source/scwx/qt/ui/download_dialog.cpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								scwx-qt/source/scwx/qt/ui/download_dialog.cpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,105 @@ | |||
| #include <scwx/qt/ui/download_dialog.hpp> | ||||
| #include <scwx/util/strings.hpp> | ||||
| 
 | ||||
| #include <boost/timer/timer.hpp> | ||||
| #include <fmt/chrono.h> | ||||
| #include <fmt/format.h> | ||||
| #include <QDialogButtonBox> | ||||
| #include <QPushButton> | ||||
| 
 | ||||
| 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<Impl>()} | ||||
| { | ||||
|    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<double>(downloadedBytes) / totalBytes : | ||||
|                            0.0; | ||||
|    const int progressValue = static_cast<int>(percentComplete * 100.0); | ||||
| 
 | ||||
|    SetValue(progressValue); | ||||
| 
 | ||||
|    const std::chrono::seconds timeRemaining = | ||||
|       (percentComplete > 0.0) ? | ||||
|          std::chrono::duration_cast<std::chrono::seconds>( | ||||
|             elapsed / percentComplete - elapsed) : | ||||
|          0s; | ||||
|    const std::chrono::hours hoursRemaining = | ||||
|       std::chrono::duration_cast<std::chrono::hours>(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
 | ||||
							
								
								
									
										38
									
								
								scwx-qt/source/scwx/qt/ui/download_dialog.hpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								scwx-qt/source/scwx/qt/ui/download_dialog.hpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | |||
| #pragma once | ||||
| 
 | ||||
| #include <scwx/qt/ui/progress_dialog.hpp> | ||||
| 
 | ||||
| #include <cstddef> | ||||
| 
 | ||||
| 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<Impl> p; | ||||
| }; | ||||
| 
 | ||||
| } // namespace ui
 | ||||
| } // namespace qt
 | ||||
| } // namespace scwx
 | ||||
							
								
								
									
										66
									
								
								scwx-qt/source/scwx/qt/ui/progress_dialog.cpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								scwx-qt/source/scwx/qt/ui/progress_dialog.cpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<Impl>()}, 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
 | ||||
							
								
								
									
										46
									
								
								scwx-qt/source/scwx/qt/ui/progress_dialog.hpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								scwx-qt/source/scwx/qt/ui/progress_dialog.hpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| #pragma once | ||||
| 
 | ||||
| #include <QDialog> | ||||
| 
 | ||||
| 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<Impl> p; | ||||
|    Ui::ProgressDialog*   ui; | ||||
| }; | ||||
| 
 | ||||
| } // namespace ui
 | ||||
| } // namespace qt
 | ||||
| } // namespace scwx
 | ||||
							
								
								
									
										85
									
								
								scwx-qt/source/scwx/qt/ui/progress_dialog.ui
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								scwx-qt/source/scwx/qt/ui/progress_dialog.ui
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,85 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <ui version="4.0"> | ||||
|  <class>ProgressDialog</class> | ||||
|  <widget class="QDialog" name="ProgressDialog"> | ||||
|   <property name="geometry"> | ||||
|    <rect> | ||||
|     <x>0</x> | ||||
|     <y>0</y> | ||||
|     <width>394</width> | ||||
|     <height>116</height> | ||||
|    </rect> | ||||
|   </property> | ||||
|   <property name="windowTitle"> | ||||
|    <string>Dialog</string> | ||||
|   </property> | ||||
|   <layout class="QVBoxLayout" name="verticalLayout"> | ||||
|    <item> | ||||
|     <widget class="QLabel" name="topLabel"> | ||||
|      <property name="text"> | ||||
|       <string>Downloading supercell-wx-v0.4.4-windows-x64.msi...</string> | ||||
|      </property> | ||||
|     </widget> | ||||
|    </item> | ||||
|    <item> | ||||
|     <widget class="QProgressBar" name="progressBar"> | ||||
|      <property name="value"> | ||||
|       <number>24</number> | ||||
|      </property> | ||||
|     </widget> | ||||
|    </item> | ||||
|    <item> | ||||
|     <widget class="QLabel" name="bottomLabel"> | ||||
|      <property name="text"> | ||||
|       <string>25.3 MB of 69.1 MB downloaded (00:00:04 remaining)</string> | ||||
|      </property> | ||||
|     </widget> | ||||
|    </item> | ||||
|    <item> | ||||
|     <widget class="QDialogButtonBox" name="buttonBox"> | ||||
|      <property name="orientation"> | ||||
|       <enum>Qt::Horizontal</enum> | ||||
|      </property> | ||||
|      <property name="standardButtons"> | ||||
|       <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> | ||||
|      </property> | ||||
|     </widget> | ||||
|    </item> | ||||
|   </layout> | ||||
|  </widget> | ||||
|  <resources/> | ||||
|  <connections> | ||||
|   <connection> | ||||
|    <sender>buttonBox</sender> | ||||
|    <signal>accepted()</signal> | ||||
|    <receiver>ProgressDialog</receiver> | ||||
|    <slot>accept()</slot> | ||||
|    <hints> | ||||
|     <hint type="sourcelabel"> | ||||
|      <x>248</x> | ||||
|      <y>254</y> | ||||
|     </hint> | ||||
|     <hint type="destinationlabel"> | ||||
|      <x>157</x> | ||||
|      <y>274</y> | ||||
|     </hint> | ||||
|    </hints> | ||||
|   </connection> | ||||
|   <connection> | ||||
|    <sender>buttonBox</sender> | ||||
|    <signal>rejected()</signal> | ||||
|    <receiver>ProgressDialog</receiver> | ||||
|    <slot>reject()</slot> | ||||
|    <hints> | ||||
|     <hint type="sourcelabel"> | ||||
|      <x>316</x> | ||||
|      <y>260</y> | ||||
|     </hint> | ||||
|     <hint type="destinationlabel"> | ||||
|      <x>286</x> | ||||
|      <y>274</y> | ||||
|     </hint> | ||||
|    </hints> | ||||
|   </connection> | ||||
|  </connections> | ||||
| </ui> | ||||
|  | @ -1,10 +1,15 @@ | |||
| #include "update_dialog.hpp" | ||||
| #include "ui_update_dialog.h" | ||||
| #include <scwx/qt/main/versions.hpp> | ||||
| #include <scwx/qt/manager/download_manager.hpp> | ||||
| #include <scwx/qt/manager/font_manager.hpp> | ||||
| #include <scwx/qt/ui/download_dialog.hpp> | ||||
| #include <scwx/util/logger.hpp> | ||||
| 
 | ||||
| #include <QDesktopServices> | ||||
| #include <QFontDatabase> | ||||
| #include <QProcess> | ||||
| #include <QStandardPaths> | ||||
| 
 | ||||
| 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<manager::DownloadManager> downloadManager_ { | ||||
|       manager::DownloadManager::Instance()}; | ||||
| 
 | ||||
|    std::string downloadUrl_ {}; | ||||
|    std::string installUrl_ {}; | ||||
|    std::string installFilename_ {}; | ||||
| }; | ||||
| 
 | ||||
| UpdateDialog::UpdateDialog(QWidget* parent) : | ||||
|     QDialog(parent), | ||||
|     p {std::make_unique<UpdateDialogImpl>()}, | ||||
|     ui(new Ui::UpdateDialog) | ||||
|     QDialog(parent), p {std::make_unique<Impl>(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::DownloadRequest> request = | ||||
|          std::make_shared<request::DownloadRequest>( | ||||
|             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
 | ||||
|  |  | |||
|  | @ -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,10 +30,11 @@ public: | |||
| 
 | ||||
| private slots: | ||||
|    void on_downloadButton_clicked(); | ||||
|    void on_installUpdateButton_clicked(); | ||||
| 
 | ||||
| private: | ||||
|    friend UpdateDialogImpl; | ||||
|    std::unique_ptr<UpdateDialogImpl> p; | ||||
|    class Impl; | ||||
|    std::unique_ptr<Impl> p; | ||||
|    Ui::UpdateDialog*     ui; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -139,6 +139,13 @@ | |||
|         </property> | ||||
|        </widget> | ||||
|       </item> | ||||
|       <item> | ||||
|        <widget class="QPushButton" name="installUpdateButton"> | ||||
|         <property name="text"> | ||||
|          <string>Install Update</string> | ||||
|         </property> | ||||
|        </widget> | ||||
|       </item> | ||||
|       <item> | ||||
|        <spacer name="horizontalSpacer"> | ||||
|         <property name="orientation"> | ||||
|  |  | |||
							
								
								
									
										68
									
								
								scwx-qt/wix.template.in
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								scwx-qt/wix.template.in
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,68 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| 
 | ||||
| <?include "cpack_variables.wxi"?> | ||||
| 
 | ||||
| <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" | ||||
|     xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" | ||||
|     @CPACK_WIX_CUSTOM_XMLNS_EXPANDED@ | ||||
|     RequiredVersion="3.6.3303.0"> | ||||
| 
 | ||||
|     <Product Id="$(var.CPACK_WIX_PRODUCT_GUID)" | ||||
|         Name="$(var.CPACK_PACKAGE_NAME)" | ||||
|         Language="1033" | ||||
|         Version="$(var.CPACK_PACKAGE_VERSION)" | ||||
|         Manufacturer="$(var.CPACK_PACKAGE_VENDOR)" | ||||
|         UpgradeCode="$(var.CPACK_WIX_UPGRADE_GUID)"> | ||||
| 
 | ||||
|         <Package InstallerVersion="301" Compressed="yes" InstallScope="perMachine"/> | ||||
| 
 | ||||
|         <Media Id="1" Cabinet="media1.cab" EmbedCab="yes"/> | ||||
| 
 | ||||
|         <MajorUpgrade | ||||
|             Schedule="afterInstallInitialize" | ||||
|             AllowSameVersionUpgrades="yes" | ||||
|             DowngradeErrorMessage="A later version of [ProductName] is already installed. Setup will now exit."/> | ||||
| 
 | ||||
|         <WixVariable Id="WixUILicenseRtf" Value="$(var.CPACK_WIX_LICENSE_RTF)"/> | ||||
|         <Property Id="WIXUI_INSTALLDIR" Value="INSTALL_ROOT"/> | ||||
| 
 | ||||
|         <?ifdef CPACK_WIX_PRODUCT_ICON?> | ||||
|         <Property Id="ARPPRODUCTICON">ProductIcon.ico</Property> | ||||
|         <Icon Id="ProductIcon.ico" SourceFile="$(var.CPACK_WIX_PRODUCT_ICON)"/> | ||||
|         <?endif?> | ||||
| 
 | ||||
|         <?ifdef CPACK_WIX_UI_BANNER?> | ||||
|         <WixVariable Id="WixUIBannerBmp" Value="$(var.CPACK_WIX_UI_BANNER)"/> | ||||
|         <?endif?> | ||||
| 
 | ||||
|         <?ifdef CPACK_WIX_UI_DIALOG?> | ||||
|         <WixVariable Id="WixUIDialogBmp" Value="$(var.CPACK_WIX_UI_DIALOG)"/> | ||||
|         <?endif?> | ||||
| 
 | ||||
|         <FeatureRef Id="ProductFeature"/> | ||||
| 
 | ||||
|         <UIRef Id="$(var.CPACK_WIX_UI_REF)" /> | ||||
|         <UIRef Id="WixUI_ErrorProgressText" /> | ||||
| 
 | ||||
|         <UI> | ||||
|             <Publish Dialog="ExitDialog" | ||||
|                 Control="Finish"  | ||||
|                 Event="DoAction"  | ||||
|                 Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish> | ||||
|         </UI> | ||||
| 
 | ||||
|         <util:CloseApplication | ||||
|             Id="CloseSupercellWx" | ||||
|             Target="supercell-wx.exe" | ||||
|             RebootPrompt="no" | ||||
|             PromptToContinue="yes" | ||||
|             Description="Supercell Wx should be closed before continuing the install."/> | ||||
| 
 | ||||
|         <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch Supercell Wx" /> | ||||
|         <Property Id="WixShellExecTarget" Value="[#CM_FP_bin.supercell_wx.exe]" /> | ||||
|         <CustomAction Id="LaunchApplication" BinaryKey="WixCA" DllEntry="WixShellExec" Impersonate="yes" /> | ||||
| 
 | ||||
|         <?include "properties.wxi"?> | ||||
|         <?include "product_fragment.wxi"?> | ||||
|     </Product> | ||||
| </Wix> | ||||
|  | @ -7,6 +7,35 @@ namespace scwx | |||
| namespace util | ||||
| { | ||||
| 
 | ||||
| class BytesToStringTest : | ||||
|     public testing::TestWithParam<std::pair<std::ptrdiff_t, std::string>> | ||||
| { | ||||
| }; | ||||
| 
 | ||||
| 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"}; | ||||
|  |  | |||
							
								
								
									
										18
									
								
								wxdata/include/scwx/util/digest.hpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								wxdata/include/scwx/util/digest.hpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| #pragma once | ||||
| 
 | ||||
| #include <istream> | ||||
| #include <vector> | ||||
| 
 | ||||
| #include <openssl/evp.h> | ||||
| 
 | ||||
| namespace scwx | ||||
| { | ||||
| namespace util | ||||
| { | ||||
| 
 | ||||
| bool ComputeDigest(const EVP_MD*              mdtype, | ||||
|                    std::istream&              is, | ||||
|                    std::vector<std::uint8_t>& digest); | ||||
| 
 | ||||
| } // namespace util
 | ||||
| } // namespace scwx
 | ||||
|  | @ -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 | ||||
|  * | ||||
|  |  | |||
							
								
								
									
										82
									
								
								wxdata/source/scwx/util/digest.cpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								wxdata/source/scwx/util/digest.cpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,82 @@ | |||
| #include <scwx/util/digest.hpp> | ||||
| #include <scwx/util/logger.hpp> | ||||
| 
 | ||||
| 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<std::uint8_t>& 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
 | ||||
|  | @ -4,12 +4,76 @@ | |||
| 
 | ||||
| #include <boost/algorithm/string/trim.hpp> | ||||
| #include <boost/lexical_cast.hpp> | ||||
| #include <fmt/format.h> | ||||
| 
 | ||||
| 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<std::string> ParseTokens(const std::string&       s, | ||||
|                                      std::vector<std::string> delimiters, | ||||
|                                      std::size_t              pos) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Dan Paulat
						Dan Paulat