Merge pull request #172 from dpaulat/feature/installer

Add Windows Installer and Updater
This commit is contained in:
Dan Paulat 2024-03-27 01:13:37 -05:00 committed by GitHub
commit ef1101ac4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1304 additions and 23 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -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()

View file

@ -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_);
});
}
}

View 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

View 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

View file

@ -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_ {};

View file

@ -27,6 +27,8 @@ public:
bool CheckForUpdates(const std::string& currentVersion = {});
static void RemoveTemporaryReleases();
static std::shared_ptr<UpdateManager> Instance();
signals:

View 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

View 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

View file

@ -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"))
{

View file

@ -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,10 +37,14 @@ struct Release
std::string body_ {};
bool draft_ {};
bool prerelease_ {};
std::vector<ReleaseAsset> assets_ {};
};
Release tag_invoke(boost::json::value_to_tag<Release>,
const boost::json::value& jv);
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);
} // namespace gh
} // namespace types

View 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

View 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

View 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

View 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

View 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>

View file

@ -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

View file

@ -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 @@ public:
private slots:
void on_downloadButton_clicked();
void on_installUpdateButton_clicked();
private:
friend UpdateDialogImpl;
std::unique_ptr<UpdateDialogImpl> p;
Ui::UpdateDialog* ui;
class Impl;
std::unique_ptr<Impl> p;
Ui::UpdateDialog* ui;
};
} // namespace ui

View file

@ -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
View 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>

View file

@ -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"};

View 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

View file

@ -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
*

View 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

View file

@ -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)

View file

@ -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