diff --git a/scwx-qt/scwx-qt.cmake b/scwx-qt/scwx-qt.cmake index aa491d40..d5143fc6 100644 --- a/scwx-qt/scwx-qt.cmake +++ b/scwx-qt/scwx-qt.cmake @@ -64,12 +64,14 @@ set(HDR_MANAGER source/scwx/qt/manager/radar_product_manager.hpp source/scwx/qt/manager/radar_product_manager_notifier.hpp source/scwx/qt/manager/resource_manager.hpp source/scwx/qt/manager/settings_manager.hpp - source/scwx/qt/manager/text_event_manager.hpp) + source/scwx/qt/manager/text_event_manager.hpp + source/scwx/qt/manager/update_manager.hpp) set(SRC_MANAGER source/scwx/qt/manager/radar_product_manager.cpp source/scwx/qt/manager/radar_product_manager_notifier.cpp source/scwx/qt/manager/resource_manager.cpp source/scwx/qt/manager/settings_manager.cpp - source/scwx/qt/manager/text_event_manager.cpp) + source/scwx/qt/manager/text_event_manager.cpp + source/scwx/qt/manager/update_manager.cpp) set(HDR_MAP source/scwx/qt/map/alert_layer.hpp source/scwx/qt/map/color_table_layer.hpp source/scwx/qt/map/draw_layer.hpp diff --git a/scwx-qt/source/scwx/qt/manager/update_manager.cpp b/scwx-qt/source/scwx/qt/manager/update_manager.cpp new file mode 100644 index 00000000..fe96936f --- /dev/null +++ b/scwx-qt/source/scwx/qt/manager/update_manager.cpp @@ -0,0 +1,256 @@ +#include +#include + +#include +#include + +#include +#include + +namespace scwx +{ +namespace qt +{ +namespace manager +{ + +static const std::string logPrefix_ = "scwx::qt::manager::update_manager"; +static const auto logger_ = scwx::util::Logger::Create(logPrefix_); + +static const std::string kGithubApiBase {"https://api.github.com"}; +static const std::string kScwxReleaseEndpoint { + kGithubApiBase + "/repos/dpaulat/supercell-wx/releases"}; + +class UpdateManager::Impl +{ +public: + explicit Impl(UpdateManager* self) : self_ {self}, releases_ {} {} + + ~Impl() {} + + static std::string GetVersionString(const std::string& releaseName); + static boost::json::value ParseResponseText(const std::string& s); + + size_t PopulateReleases(); + size_t AddReleases(const boost::json::value& json); + std::pair::iterator, std::string> + FindLatestRelease(); + + UpdateManager* self_; + + std::vector releases_; + types::gh::Release latestRelease_; + std::string latestVersion_; +}; + +UpdateManager::UpdateManager() : p(std::make_unique(this)) {} +UpdateManager::~UpdateManager() = default; + +types::gh::Release UpdateManager::latest_release() const +{ + return p->latestRelease_; +} + +std::string UpdateManager::latest_version() const +{ + return p->latestVersion_; +} + +std::string +UpdateManager::Impl::GetVersionString(const std::string& releaseName) +{ + static const std::regex re {"\\d+\\.\\d+\\.\\d+"}; + std::string versionString {}; + std::smatch m; + + std::regex_search(releaseName, m, re); + + if (!m.empty()) + { + versionString = m[0].str(); + } + + return versionString; +} + +boost::json::value UpdateManager::Impl::ParseResponseText(const std::string& s) +{ + boost::json::stream_parser p; + boost::json::error_code ec; + + p.write(s, ec); + if (ec) + { + logger_->warn("{}", ec.message()); + return nullptr; + } + + p.finish(ec); + if (ec) + { + logger_->warn("{}", ec.message()); + return nullptr; + } + + return p.release(); +} + +bool UpdateManager::CheckForUpdates(const std::string& currentVersion) +{ + logger_->info("Checking for updates"); + + // Query GitHub for releases + size_t numReleases = p->PopulateReleases(); + bool newRelease = false; + + // If GitHub returned valid releases + if (numReleases > 0) + { + // Get the latest release + auto [latestRelease, latestVersion] = p->FindLatestRelease(); + + // Validate the latest release, and compare to the current version + if (latestRelease != p->releases_.end() && latestVersion > currentVersion) + { + logger_->info("An update is available: {}", latestVersion); + + p->latestRelease_ = *latestRelease; + p->latestVersion_ = latestVersion; + newRelease = true; + emit UpdateAvailable(latestVersion, *latestRelease); + } + } + + return newRelease; +} + +size_t UpdateManager::Impl::PopulateReleases() +{ + static constexpr size_t perPage = + 100u; // The number of results per page (max 100) + size_t page = 1u; // Page number of the results to fetch + size_t numResults = 0u; + + static const std::string perPageString {fmt::format("{}", perPage)}; + + // Clear any existing releases + releases_.clear(); + + do + { + const std::string pageString {fmt::format("{}", page)}; + + cpr::Response r = cpr::Get( + cpr::Url {kScwxReleaseEndpoint}, + cpr::Parameters {{"per_page", perPageString}, {"page", pageString}}, + cpr::Header {{"accept", "application/vnd.github+json"}, + {"X-GitHub-Api-Version", "2022-11-28"}}); + + // Successful REST API query + if (r.status_code == 200) + { + boost::json::value json = Impl::ParseResponseText(r.text); + if (json == nullptr) + { + logger_->warn("Response not JSON: {}", r.header["content-type"]); + break; + } + + // Add results from response + size_t newResults = AddReleases(json); + numResults += newResults; + + if (newResults < perPage) + { + // We have reached the last page of results + break; + } + } + else + { + logger_->warn( + "Invalid API response: [{}] {}", r.status_code, r.error.message); + break; + } + + // Check page is less than 100, this is to prevent an infinite loop + } while (++page < 100); + + return numResults; +} + +size_t UpdateManager::Impl::AddReleases(const boost::json::value& json) +{ + // Parse releases + std::vector newReleases {}; + try + { + newReleases = + boost::json::value_to>(json); + } + catch (const std::exception& ex) + { + logger_->warn("Error parsing JSON: {}", ex.what()); + } + + size_t newReleaseCount = newReleases.size(); + + // Add releases to the current list + releases_.insert(releases_.end(), newReleases.begin(), newReleases.end()); + + return newReleaseCount; +} + +std::pair::iterator, std::string> +UpdateManager::Impl::FindLatestRelease() +{ + // Initialize the latest release to the end iterator + std::vector::iterator latestRelease = releases_.end(); + std::string latestReleaseVersion {}; + + for (auto it = releases_.begin(); it != releases_.end(); ++it) + { + if (it->draft_ || it->prerelease_) + { + // Skip drafts and prereleases + continue; + } + + // Get the version string of the current release + std::string currentVersion {GetVersionString(it->name_)}; + + // If not set, or current version is lexographically newer + if (latestRelease == releases_.end() || + currentVersion > latestReleaseVersion) + { + // Update the latest release + latestRelease = it; + latestReleaseVersion = currentVersion; + } + } + + return {latestRelease, latestReleaseVersion}; +} + +std::shared_ptr UpdateManager::Instance() +{ + static std::weak_ptr updateManagerReference_ {}; + static std::mutex instanceMutex_ {}; + + std::unique_lock lock(instanceMutex_); + + std::shared_ptr updateManager = + updateManagerReference_.lock(); + + if (updateManager == nullptr) + { + updateManager = std::make_shared(); + updateManagerReference_ = updateManager; + } + + return updateManager; +} + +} // namespace manager +} // namespace qt +} // namespace scwx diff --git a/scwx-qt/source/scwx/qt/manager/update_manager.hpp b/scwx-qt/source/scwx/qt/manager/update_manager.hpp new file mode 100644 index 00000000..10a97350 --- /dev/null +++ b/scwx-qt/source/scwx/qt/manager/update_manager.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include + +#include + +namespace scwx +{ +namespace qt +{ +namespace manager +{ + +class UpdateManager : public QObject +{ + Q_OBJECT + +public: + explicit UpdateManager(); + ~UpdateManager(); + + types::gh::Release latest_release() const; + std::string latest_version() const; + + bool CheckForUpdates(const std::string& currentVersion = {}); + + static std::shared_ptr Instance(); + +signals: + void UpdateAvailable(const std::string& latestVersion, + const types::gh::Release& latestRelease); + +private: + class Impl; + std::unique_ptr p; +}; + +} // namespace manager +} // namespace qt +} // namespace scwx diff --git a/test/source/scwx/qt/manager/update_manager.test.cpp b/test/source/scwx/qt/manager/update_manager.test.cpp new file mode 100644 index 00000000..6558eb3b --- /dev/null +++ b/test/source/scwx/qt/manager/update_manager.test.cpp @@ -0,0 +1,40 @@ +#include + +#include + +namespace scwx +{ +namespace qt +{ +namespace manager +{ + +TEST(UpdateManagerTest, CheckForUpdates) +{ + auto updateManager = UpdateManager::Instance(); + bool updateFound; + types::gh::Release latestRelease; + std::string latestVersion; + + // Check for updates, and expect an update to be found + updateFound = updateManager->CheckForUpdates("0.0.0"); + latestRelease = updateManager->latest_release(); + latestVersion = updateManager->latest_version(); + + EXPECT_EQ(updateFound, true); + EXPECT_GT(latestRelease.name_.size(), 0); + EXPECT_GT(latestRelease.htmlUrl_.size(), 0); + EXPECT_GT(latestRelease.body_.size(), 0); + EXPECT_EQ(latestRelease.draft_, false); + EXPECT_EQ(latestRelease.prerelease_, false); + EXPECT_GT(latestVersion, "0.0.0"); + + // Check for updates, and expect no updates to be found + updateFound = updateManager->CheckForUpdates("9999.99.99"); + + EXPECT_EQ(updateFound, false); +} + +} // namespace manager +} // namespace qt +} // namespace scwx diff --git a/test/test.cmake b/test/test.cmake index a0bc5d20..0e67647e 100644 --- a/test/test.cmake +++ b/test/test.cmake @@ -21,7 +21,8 @@ set(SRC_PROVIDER_TESTS source/scwx/provider/aws_level2_data_provider.test.cpp source/scwx/provider/warnings_provider.test.cpp) set(SRC_QT_CONFIG_TESTS source/scwx/qt/config/county_database.test.cpp source/scwx/qt/config/radar_site.test.cpp) -set(SRC_QT_MANAGER_TESTS source/scwx/qt/manager/settings_manager.test.cpp) +set(SRC_QT_MANAGER_TESTS source/scwx/qt/manager/settings_manager.test.cpp + source/scwx/qt/manager/update_manager.test.cpp) set(SRC_QT_MODEL_TESTS source/scwx/qt/model/imgui_context_model.test.cpp) set(SRC_QT_SETTINGS_TESTS source/scwx/qt/settings/settings_container.test.cpp source/scwx/qt/settings/settings_variable.test.cpp)