updater: Add functionality to check for updates

Implements a manual and automatic update checker with support for both release and testing update channels, allowing users to stay as up to date as possible. It is fully compliant with privacy regulations around the world, as it stays completely silent and inactive until the user gives the Ok to connect to GitHub for the latest releases.
This commit is contained in:
Michael Fabian 'Xaymar' Dirks 2020-09-09 05:06:15 +02:00
parent 6d3b0f1266
commit f907fc80b0
13 changed files with 1348 additions and 45 deletions

View file

@ -120,15 +120,15 @@ jobs:
sudo apt-get -qq update
sudo apt-get purge libjpeg9-dev:amd64 libjpeg8-dev:amd64 libjpeg-turbo8-dev:amd64
sudo apt-get install \
${{ matrix.packages }} \
build-essential \
checkinstall \
pkg-config \
cmake \
ninja-build \
git \
qt5-default libqwt-qt5-dev libqt5svg5-dev \
libavcodec-dev libavutil-dev libswscale-dev
libavcodec-dev libavdevice-dev libavfilter-dev libavformat-dev libavutil-dev libswresample-dev libswscale-dev \
libcurl4-openssl-dev \
${{ matrix.packages }} \
checkinstall pkg-config
${{ matrix.extra_command }}
- name: "Cache: Prerequisites"
uses: actions/cache@v2

View file

@ -22,7 +22,16 @@ jobs:
git submodule update --init --recursive
sudo apt-get -qq update
sudo apt-get purge libjpeg9-dev:amd64 libjpeg8-dev:amd64 libjpeg-turbo8-dev:amd64
sudo apt-get install build-essential clang clang-format checkinstall pkg-config cmake ninja-build git qt5-default libqwt-qt5-dev libqt5svg5-dev libavcodec-dev libavutil-dev libswscale-dev
sudo apt-get install \
build-essential \
cmake \
ninja-build \
git \
qt5-default libqwt-qt5-dev libqt5svg5-dev \
libavcodec-dev libavdevice-dev libavfilter-dev libavformat-dev libavutil-dev libswresample-dev libswscale-dev \
libcurl4-openssl-dev \
checkinstall pkg-config \
clang clang-format
- name: "Cache: Prerequisites"
uses: actions/cache@v2
with:

View file

@ -263,6 +263,7 @@ set(${PropertyPrefix}ENABLE_TRANSITION_SHADER ${ENABLE_TRANSITION_SHADER} CACHE
## Code Related
set(${PropertyPrefix}ENABLE_PROFILING FALSE CACHE BOOL "Enable CPU and GPU performance tracking, which has a non-zero overhead at all times. Do not enable this for release builds.")
set(${PropertyPrefix}ENABLE_CLANG TRUE CACHE BOOL "Enable Clang integration for supported compilers.")
set(${PropertyPrefix}ENABLE_UPDATER TRUE CACHE BOOL "Enable automatic update checks.")
# Code Signing
set(${PropertyPrefix}SIGN_ENABLED FALSE CACHE BOOL "Enable signing builds.")
@ -295,6 +296,12 @@ configure_file(
"${PROJECT_BINARY_DIR}/generated/config.hpp"
)
# Optional Code & Dependencies
if(${PropertyPrefix}ENABLE_UPDATER)
set(REQUIRE_CURL TRUE)
set(REQUIRE_JSON TRUE)
endif()
# libOBS
if(NOT TARGET libobs)
# Packaging
@ -445,25 +452,24 @@ endif()
# CURL
if(REQUIRE_CURL)
find_package(CURL QUIET)
if(NOT CURL_FOUND)
if(WIN32)
# CURL built by OBS Project is not compatible with find modules.
if(${PropertyPrefix}OBS_DOWNLOAD)
set(CURL_LIBRARIES "${obsdeps_SOURCE_DIR}/win${BITS}/bin/libcurl.lib")
set(CURL_INCLUDE_DIRS "${obsdeps_SOURCE_DIR}/win${BITS}/include")
endif()
if(${PropertyPrefix}OBS_NATIVE) # Already defined by OBS
set(CURL_LIBRARIES "${CURL_LIB}")
set(CURL_INCLUDE_DIRS "${CURL_INCLUDE_DIR}")
endif()
if((${PropertyPrefix}OBS_REFERENCE) OR (${PropertyPrefix}OBS_PACKAGE))
elseif(${PropertyPrefix}OBS_DOWNLOAD)
set(CURL_LIBRARIES "${obsdeps_SOURCE_DIR}/win${BITS}/bin/libcurl.lib")
set(CURL_INCLUDE_DIRS "${obsdeps_SOURCE_DIR}/win${BITS}/include")
elseif((${PropertyPrefix}OBS_REFERENCE) OR (${PropertyPrefix}OBS_PACKAGE))
set(CURL_LIBRARIES "${OBS_DEPENDENCIES_DIR}/bin/libcurl.lib")
set(CURL_INCLUDE_DIRS "${OBS_DEPENDENCIES_DIR}/include")
endif()
CacheSet(CURL_LIBRARY_DEBUG ${CURL_LIBRARIES})
CacheSet(CURL_LIBRARY_RELEASE ${CURL_LIBRARIES})
CacheSet(CURL_INCLUDE_DIR ${CURL_INCLUDE_DIRS})
set(CURL_LIBRARY_DEBUG ${CURL_LIBRARIES})
set(CURL_LIBRARY_RELEASE ${CURL_LIBRARIES})
set(CURL_INCLUDE_DIR ${CURL_INCLUDE_DIRS})
set(CURL_FOUND ON)
else()
find_package(CURL REQUIRED)
endif()
endif()
@ -663,6 +669,26 @@ if(HAVE_OBS_FRONTEND)
)
endif()
## Feature - Automatic Updater
if(${PropertyPrefix}ENABLE_UPDATER AND CURL_FOUND)
list(APPEND PROJECT_PRIVATE_SOURCE
"source/updater.hpp"
"source/updater.cpp"
)
list(APPEND PROJECT_DEFINITIONS
ENABLE_UPDATER
)
if(HAVE_OBS_FRONTEND)
list(APPEND PROJECT_PRIVATE_SOURCE
"source/ui/ui-updater.hpp"
"source/ui/ui-updater.cpp"
)
list(APPEND PROJECT_UI
"ui/updater.ui"
)
endif()
endif()
## Feature - FFmpeg Encoder
if(${PropertyPrefix}ENABLE_ENCODER_FFMPEG)
if(NOT ${PropertyPrefix}OBS_NATIVE)

View file

@ -45,6 +45,21 @@ UI.About.Role.Supporter.Twitch="Twitch Subscriber"
UI.About.Role.Creator="Content Creator"
UI.About.Version="Version:"
# Front-end - Updater
UI.Updater.Dialog.Title="StreamFX Version %s is now available!"
UI.Updater.Dialog.Text="A new version of StreamFX is available to download."
UI.Updater.Dialog.Version.Current="Current Version:"
UI.Updater.Dialog.Version.Latest="Latest Version:"
UI.Updater.Dialog.Button.Ok="Open Download Page"
UI.Updater.Dialog.Button.Cancel="Remind me later"
UI.Updater.GitHubPermission.Title="StreamFX requires your permission to connect to GitHub!"
UI.Updater.GitHubPermission.Text="In order to provide manual or automated update checking, StreamFX relies on the GitHub API to retrieve the latest information.<br>Please read the <a href='https://docs.github.com/en/github/site-policy/github-privacy-statement'><span style='text-decoration: underline;'>Github Privacy Statement</span></a>, and click 'OK' if you agree, or 'Cancel' if you disagree."
UI.Updater.Menu.CheckForUpdates="Check for Updates"
UI.Updater.Menu.CheckForUpdates.Automatically="Automatically check for Updates"
UI.Updater.Menu.Channel="Update Channel"
UI.Updater.Menu.Channel.Release="Release"
UI.Updater.Menu.Channel.Testing="Testing"
# Blur
Blur.Type.Box="Box"
Blur.Type.Box.Description="The 'Box' blur takes the average of all pixels in the given area, which results in its distinct box shape."

View file

@ -68,6 +68,11 @@
#include "ui/ui.hpp"
#endif
#ifdef ENABLE_UPDATER
#include "updater.hpp"
//static std::shared_ptr<streamfx::updater> _updater;
#endif
static std::shared_ptr<util::threadpool> _threadpool;
static std::shared_ptr<gs::vertex_buffer> _gs_fstri_vb;
@ -239,6 +244,11 @@ try {
// Finalize Source Tracker
obs::source_tracker::finalize();
// // Auto-Updater
//#ifdef ENABLE_UPDATER
// _updater.reset();
//#endif
// Finalize Thread Pool
_threadpool.reset();

289
source/ui/ui-updater.cpp Normal file
View file

@ -0,0 +1,289 @@
// Copyright (c) 2020 Michael Fabian Dirks <info@xaymar.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#include "ui-updater.hpp"
#include "common.hpp"
#define ST_PREFIX "<ui::updater> "
#define D_LOG_ERROR(...) DLOG_ERROR(ST_PREFIX __VA_ARGS__)
#define D_LOG_WARNING(...) DLOG_WARNING(ST_PREFIX __VA_ARGS__)
#define D_LOG_INFO(...) DLOG_INFO(ST_PREFIX __VA_ARGS__)
#ifdef _DEBUG
#define D_LOG_DEBUG(...) DLOG_DEBUG(ST_PREFIX __VA_ARGS__)
#else
#define D_LOG_DEBUG(...)
#endif
#define D_I18N_MENU_CHECKFORUPDATES "UI.Updater.Menu.CheckForUpdates"
#define D_I18N_MENU_CHECKFORUPDATES_AUTOMATICALLY "UI.Updater.Menu.CheckForUpdates.Automatically"
#define D_I18N_MENU_CHANNEL "UI.Updater.Menu.Channel"
#define D_I18N_MENU_CHANNEL_RELEASE "UI.Updater.Menu.Channel.Release"
#define D_I18N_MENU_CHANNEL_TESTING "UI.Updater.Menu.Channel.Testing"
#define D_I18N_DIALOG_TITLE "UI.Updater.Dialog.Title"
#define D_I18N_GITHUBPERMISSION_TITLE "UI.Updater.GitHubPermission.Title"
#define D_I18N_GITHUBPERMISSION_TEXT "UI.Updater.GitHubPermission.Text"
streamfx::ui::updater_dialog::updater_dialog() : QDialog(reinterpret_cast<QWidget*>(obs_frontend_get_main_window()))
{
setupUi(this);
setWindowFlag(Qt::WindowContextHelpButtonHint, false);
setWindowFlag(Qt::WindowMinimizeButtonHint, false);
setWindowFlag(Qt::WindowMaximizeButtonHint, false);
connect(ok, &QPushButton::clicked, this, &streamfx::ui::updater_dialog::on_ok);
connect(cancel, &QPushButton::clicked, this, &streamfx::ui::updater_dialog::on_cancel);
}
streamfx::ui::updater_dialog::~updater_dialog() {}
void streamfx::ui::updater_dialog::show(streamfx::update_info current, streamfx::update_info update)
{
{
std::vector<char> buf;
if (current.version_type) {
buf.resize(static_cast<size_t>(snprintf(nullptr, 0, "%" PRIu16 ".%" PRIu16 ".%" PRIu16 "%.1s%" PRIu16,
current.version_major, current.version_minor, current.version_patch,
&current.version_type, current.version_index))
+ 1);
snprintf(buf.data(), buf.size(), "%" PRIu16 ".%" PRIu16 ".%" PRIu16 "%.1s%" PRIu16, current.version_major,
current.version_minor, current.version_patch, &current.version_type, current.version_index);
} else {
buf.resize(
static_cast<size_t>(snprintf(nullptr, 0, "%" PRIu16 ".%" PRIu16 ".%" PRIu16, current.version_major,
current.version_minor, current.version_patch))
+ 1);
snprintf(buf.data(), buf.size(), "%" PRIu16 ".%" PRIu16 ".%" PRIu16, current.version_major,
current.version_minor, current.version_patch);
}
currentVersion->setText(QString::fromUtf8(buf.data()));
}
{
std::vector<char> buf;
if (update.version_type) {
buf.resize(static_cast<size_t>(snprintf(nullptr, 0, "%" PRIu16 ".%" PRIu16 ".%" PRIu16 "%.1s%" PRIu16,
update.version_major, update.version_minor, update.version_patch,
&update.version_type, update.version_index))
+ 1);
snprintf(buf.data(), buf.size(), "%" PRIu16 ".%" PRIu16 ".%" PRIu16 "%.1s%" PRIu16, update.version_major,
update.version_minor, update.version_patch, &update.version_type, update.version_index);
} else {
buf.resize(static_cast<size_t>(snprintf(nullptr, 0, "%" PRIu16 ".%" PRIu16 ".%" PRIu16,
update.version_major, update.version_minor, update.version_patch))
+ 1);
snprintf(buf.data(), buf.size(), "%" PRIu16 ".%" PRIu16 ".%" PRIu16, update.version_major,
update.version_minor, update.version_patch);
}
latestVersion->setText(QString::fromUtf8(buf.data()));
{
std::vector<char> buf2;
buf2.resize(static_cast<size_t>(snprintf(nullptr, 0, D_TRANSLATE(D_I18N_DIALOG_TITLE), buf.data())) + 1);
snprintf(buf2.data(), buf2.size(), D_TRANSLATE(D_I18N_DIALOG_TITLE), buf.data());
setWindowTitle(QString::fromUtf8(buf2.data()));
}
}
_update_url = QUrl(QString::fromStdString(update.url));
QDialog::show();
}
void streamfx::ui::updater_dialog::on_ok()
{
QDesktopServices::openUrl(_update_url);
hide();
}
void streamfx::ui::updater_dialog::on_cancel()
{
hide();
}
streamfx::ui::updater::updater(QMenu* menu)
{
// Create dialog.
_dialog = new updater_dialog();
// Create GitHub message box.
_gdpr = new QMessageBox(reinterpret_cast<QWidget*>(obs_frontend_get_main_window()));
_gdpr->setWindowTitle(QString::fromUtf8(D_TRANSLATE(D_I18N_GITHUBPERMISSION_TITLE)));
_gdpr->setTextFormat(Qt::TextFormat::RichText);
_gdpr->setText(QString::fromUtf8(D_TRANSLATE(D_I18N_GITHUBPERMISSION_TEXT)));
_gdpr->setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel);
connect(_gdpr, &QMessageBox::buttonClicked, this, &streamfx::ui::updater::on_gdpr_button);
{ // Create the necessary menu entries.
menu->addSeparator();
// Check for Updates
_cfu = menu->addAction(QString::fromUtf8(D_TRANSLATE(D_I18N_MENU_CHECKFORUPDATES)));
connect(_cfu, &QAction::triggered, this, &streamfx::ui::updater::on_cfu_triggered);
// Automatically check for Updates
_cfu_auto = menu->addAction(QString::fromUtf8(D_TRANSLATE(D_I18N_MENU_CHECKFORUPDATES_AUTOMATICALLY)));
_cfu_auto->setCheckable(true);
connect(_cfu_auto, &QAction::toggled, this, &streamfx::ui::updater::on_cfu_auto_toggled);
// Update Channel
_channel_menu = menu->addMenu(QString::fromUtf8(D_TRANSLATE(D_I18N_MENU_CHANNEL)));
_channel_stable = _channel_menu->addAction(QString::fromUtf8(D_TRANSLATE(D_I18N_MENU_CHANNEL_RELEASE)));
_channel_stable->setCheckable(true);
_channel_preview = _channel_menu->addAction(QString::fromUtf8(D_TRANSLATE(D_I18N_MENU_CHANNEL_TESTING)));
_channel_preview->setCheckable(true);
_channel_group = new QActionGroup(_channel_menu);
_channel_group->addAction(_channel_stable);
_channel_group->addAction(_channel_preview);
connect(_channel_group, &QActionGroup::triggered, this, &streamfx::ui::updater::on_channel_group_triggered);
}
// Connect internal signals.
connect(this, &streamfx::ui::updater::autoupdate_changed, this, &streamfx::ui::updater::on_autoupdate_changed,
Qt::QueuedConnection);
connect(this, &streamfx::ui::updater::channel_changed, this, &streamfx::ui::updater::on_channel_changed,
Qt::QueuedConnection);
connect(this, &streamfx::ui::updater::update_detected, this, &streamfx::ui::updater::on_update_detected,
Qt::QueuedConnection);
connect(this, &streamfx::ui::updater::check_active, this, &streamfx::ui::updater::on_check_active,
Qt::QueuedConnection);
{ // Retrieve the updater object and listen to it.
_updater = streamfx::updater::instance();
_updater->events.automation_changed.add(std::bind(&streamfx::ui::updater::on_updater_automation_changed, this,
std::placeholders::_1, std::placeholders::_2));
_updater->events.channel_changed.add(std::bind(&streamfx::ui::updater::on_updater_channel_changed, this,
std::placeholders::_1, std::placeholders::_2));
_updater->events.refreshed.add(
std::bind(&streamfx::ui::updater::on_updater_refreshed, this, std::placeholders::_1));
if (_updater->automation()) {
if (_updater->gdpr()) {
_updater->refresh();
} else {
_gdpr->exec();
}
}
// Sync with updater information.
emit autoupdate_changed(_updater->automation());
emit channel_changed(_updater->channel());
}
}
streamfx::ui::updater::~updater() {}
void streamfx::ui::updater::on_updater_automation_changed(streamfx::updater&, bool value)
{
emit autoupdate_changed(value);
}
void streamfx::ui::updater::on_updater_channel_changed(streamfx::updater&, streamfx::update_channel channel)
{
emit channel_changed(channel);
}
void streamfx::ui::updater::on_updater_refreshed(streamfx::updater&)
{
emit check_active(false);
if (!_updater->have_update())
return;
emit update_detected();
}
void streamfx::ui::updater::on_channel_changed(streamfx::update_channel channel)
{
bool is_stable = channel == streamfx::update_channel::RELEASE;
_channel_stable->setChecked(is_stable);
_channel_preview->setChecked(!is_stable);
}
void streamfx::ui::updater::on_update_detected()
{
_dialog->show(_updater->get_current_info(), _updater->get_update_info());
}
void streamfx::ui::updater::on_autoupdate_changed(bool enabled)
{
_cfu_auto->setChecked(enabled);
}
void streamfx::ui::updater::on_gdpr_button(QAbstractButton* btn)
{
if (_gdpr->standardButton(btn) == QMessageBox::Ok) {
_updater->set_gdpr(true);
emit check_active(true);
_updater->refresh();
} else {
_updater->set_gdpr(false);
_updater->set_automation(false);
}
}
void streamfx::ui::updater::on_cfu_triggered(bool)
{
if (!_updater->gdpr()) {
_gdpr->exec();
} else {
emit check_active(true);
_updater->refresh();
}
}
void streamfx::ui::updater::on_cfu_auto_toggled(bool flag)
{
_updater->set_automation(flag);
}
void streamfx::ui::updater::on_channel_group_triggered(QAction* action)
{
if (action == _channel_stable) {
_updater->set_channel(update_channel::RELEASE);
} else {
_updater->set_channel(update_channel::TESTING);
}
}
std::shared_ptr<streamfx::ui::updater> streamfx::ui::updater::instance(QMenu* menu)
{
static std::weak_ptr<streamfx::ui::updater> _instance;
static std::mutex _lock;
auto lock = std::lock_guard<std::mutex>(_lock);
if (_instance.expired() && menu) {
auto ptr = std::make_shared<streamfx::ui::updater>(menu);
_instance = ptr;
return ptr;
} else {
return _instance.lock();
}
}
void streamfx::ui::updater::on_check_active(bool active)
{
_cfu->setEnabled(!active);
_channel_group->setEnabled(!active);
_channel_preview->setEnabled(!active);
_channel_stable->setEnabled(!active);
_channel_menu->setEnabled(!active);
}

116
source/ui/ui-updater.hpp Normal file
View file

@ -0,0 +1,116 @@
// Copyright (c) 2020 Michael Fabian Dirks <info@xaymar.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#pragma once
#include "ui-common.hpp"
#include "updater.hpp"
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable : 4251 4365 4371 4619 4946)
#endif
#include <QAction>
#include <QBoxLayout>
#include <QComboBox>
#include <QLabel>
#include <QMessageBox>
#include <QWidget>
#include <QWidgetAction>
#include "ui_updater.h"
Q_DECLARE_METATYPE(streamfx::update_channel);
#ifdef _MSC_VER
#pragma warning(pop)
#endif
namespace streamfx::ui {
class updater_dialog : public QDialog, public Ui::Updater {
Q_OBJECT
private:
QUrl _update_url;
public:
updater_dialog();
~updater_dialog();
void show(streamfx::update_info current, streamfx::update_info update);
public slots:
; // Needed by some linters.
void on_ok();
void on_cancel();
};
class updater : public QObject {
Q_OBJECT
private:
std::shared_ptr<streamfx::updater> _updater;
updater_dialog* _dialog;
QMessageBox* _gdpr;
QAction* _cfu;
QAction* _cfu_auto;
QAction* _channel;
QMenu* _channel_menu;
QAction* _channel_stable;
QAction* _channel_preview;
QActionGroup* _channel_group;
public:
updater(QMenu* menu);
~updater();
void on_updater_automation_changed(streamfx::updater&, bool);
void on_updater_channel_changed(streamfx::updater&, streamfx::update_channel);
void on_updater_refreshed(streamfx::updater&);
signals:
; // Needed by some linters.
void autoupdate_changed(bool);
void channel_changed(streamfx::update_channel);
void update_detected();
void check_active(bool);
private slots:
; // Needed by some liners.
// Internal
void on_autoupdate_changed(bool);
void on_channel_changed(streamfx::update_channel);
void on_update_detected();
void on_check_active(bool);
// Qt
void on_gdpr_button(QAbstractButton*);
void on_cfu_triggered(bool);
void on_cfu_auto_toggled(bool);
void on_channel_group_triggered(QAction*);
public:
static std::shared_ptr<streamfx::ui::updater> instance(QMenu* menu = nullptr);
};
} // namespace streamfx::ui

View file

@ -87,7 +87,7 @@ streamfx::ui::handler::handler()
// Handle all frontend events.
obs_frontend_add_event_callback(frontend_event_handler, this);
{ // Build StreamFX menu.
// Build StreamFX menu.
_menu = new QMenu(reinterpret_cast<QWidget*>(obs_frontend_get_main_window()));
{ // Github Issues
@ -108,13 +108,16 @@ streamfx::ui::handler::handler()
connect(_link_github, &QAction::triggered, this, &streamfx::ui::handler::on_action_github);
}
#ifdef ENABLE_UPDATER
_updater = streamfx::ui::updater::instance(_menu);
#endif
{ // About StreamFX
_about_dialog = new streamfx::ui::about();
_menu->addSeparator();
_about_action = _menu->addAction(QString::fromUtf8(D_TRANSLATE(_i18n_menu_about.data())));
connect(_about_action, &QAction::triggered, this, &streamfx::ui::handler::on_action_about);
}
}
}
streamfx::ui::handler::~handler()

View file

@ -21,6 +21,10 @@
#include "ui-about.hpp"
#include "ui-common.hpp"
#ifdef ENABLE_UPDATER
#include "ui-updater.hpp"
#endif
namespace streamfx::ui {
class handler : public QObject {
Q_OBJECT
@ -42,6 +46,10 @@ namespace streamfx::ui {
QAction* _about_action;
ui::about* _about_dialog;
#ifdef ENABLE_UPDATER
std::shared_ptr<streamfx::ui::updater> _updater;
#endif
public:
handler();
~handler();
@ -55,11 +63,17 @@ namespace streamfx::ui {
public slots:
; // Not having this breaks some linters.
// Issues & Help
void on_action_report_issue(bool);
void on_action_request_help(bool);
// Official Links
void on_action_website(bool);
void on_action_discord(bool);
void on_action_github(bool);
// About
void on_action_about(bool);
public /* Singleton */:

492
source/updater.cpp Normal file
View file

@ -0,0 +1,492 @@
// Copyright (c) 2020 Michael Fabian Dirks <info@xaymar.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#include "updater.hpp"
#include "version.hpp"
#include <mutex>
#include <string_view>
#include "configuration.hpp"
#include "plugin.hpp"
// TODO:
// - Cache result in the configuration directory (not as a configuration value).
// - Move 'autoupdater.last_checked_at' to out of the configuration.
// - Figure out if nightly updates are viable at all.
#define ST_PREFIX "<updater> "
#define D_LOG_ERROR(...) DLOG_ERROR(ST_PREFIX __VA_ARGS__)
#define D_LOG_WARNING(...) DLOG_WARNING(ST_PREFIX __VA_ARGS__)
#define D_LOG_INFO(...) DLOG_INFO(ST_PREFIX __VA_ARGS__)
#ifdef _DEBUG
#define D_LOG_DEBUG(...) DLOG_DEBUG(ST_PREFIX __VA_ARGS__)
#else
#define D_LOG_DEBUG(...)
#endif
#define ST_CFG_GDPR "updater.gdpr"
#define ST_CFG_AUTOMATION "updater.automation"
#define ST_CFG_CHANNEL "updater.channel"
#define ST_CFG_LASTCHECKEDAT "updater.lastcheckedat"
void streamfx::to_json(nlohmann::json& json, const update_info& info)
{
auto version = nlohmann::json::object();
version["major"] = info.version_major;
version["minor"] = info.version_minor;
version["patch"] = info.version_patch;
if (info.version_type)
version["alpha"] = info.version_type == 'a' ? true : false;
version["index"] = info.version_index;
json["version"] = version;
json["preview"] = info.channel == update_channel::TESTING;
json["url"] = info.url;
json["name"] = info.name;
}
void streamfx::from_json(const nlohmann::json& json, update_info& info)
{
if (json.find("html_url") != json.end()) {
// This is a response from GitHub.
// Retrieve entries from the release object.
auto entry_tag_name = json.find("tag_name");
auto entry_name = json.find("name");
auto entry_url = json.find("html_url");
auto entry_preview = json.find("prerelease");
if ((entry_tag_name == json.end()) || (entry_name == json.end()) || (entry_url == json.end())
|| (entry_preview == json.end())) {
throw std::runtime_error("JSON is missing one or more required keys.");
}
// Initialize update information.
info.channel = entry_preview->get<bool>() ? update_channel::TESTING : update_channel::RELEASE;
info.url = entry_url->get<std::string>();
info.name = entry_name->get<std::string>();
// Parse the tag name as SemVer (A.B.C)
{
std::string tag_name = entry_tag_name->get<std::string>();
size_t dot_1 = tag_name.find_first_of(".", 0) + 1;
info.version_major = static_cast<uint16_t>(strtoul(&tag_name.at(0), nullptr, 10));
if (dot_1 < tag_name.size()) {
info.version_minor = static_cast<uint16_t>(strtoul(&tag_name.at(dot_1), nullptr, 10));
}
char* endptr = nullptr;
size_t dot_2 = tag_name.find_first_of(".", dot_1) + 1;
if (dot_2 < tag_name.size()) {
info.version_patch = static_cast<uint16_t>(strtoul(&tag_name.at(dot_2), &endptr, 10));
}
// Check if there's data following the SemVer structure. (A.B.CdE)
if ((endptr != nullptr) && (endptr < (tag_name.data() + tag_name.size()))) {
size_t last_num = static_cast<size_t>(endptr - tag_name.data()) + 1;
info.version_type = *endptr;
if (last_num < tag_name.size())
info.version_index = static_cast<uint16_t>(strtoul(&tag_name.at(last_num), nullptr, 10));
} else {
info.version_type = 0;
info.version_index = 0;
}
}
} else {
auto version = json.at("version");
info.version_major = version.at("major").get<uint16_t>();
info.version_minor = version.at("minor").get<uint16_t>();
info.version_patch = version.at("patch").get<uint16_t>();
if (version.find("type") != version.end())
info.version_type = version.at("alpha").get<bool>() ? 'a' : 'b';
info.version_index = version.at("index").get<uint16_t>();
info.channel = json.at("preview").get<bool>() ? update_channel::TESTING : update_channel::RELEASE;
info.url = json.at("url").get<std::string>();
info.name = json.at("name").get<std::string>();
}
}
bool streamfx::update_info::is_newer(update_info& other)
{
// 1. Compare Major version:
// A. Ours is greater: Remote is older.
// B. Theirs is greater: Remote is newer.
// C. Continue the check.
if (version_major > other.version_major)
return false;
if (version_major < other.version_major)
return true;
// 2. Compare Minor version:
// A. Ours is greater: Remote is older.
// B. Theirs is greater: Remote is newer.
// C. Continue the check.
if (version_minor > other.version_minor)
return false;
if (version_minor < other.version_minor)
return true;
// 3. Compare Patch version:
// A. Ours is greater: Remote is older.
// B. Theirs is greater: Remote is newer.
// C. Continue the check.
if (version_patch > other.version_patch)
return false;
if (version_patch < other.version_patch)
return true;
// 4. Compare Type:
// A. Ours empty: Remote is older.
// B. Theirs empty: Remote is newer.
// C. Continue the check.
// A automatically implies that ours is not empty for B. A&B combined imply that both are not empty for step 5.
if (version_type == 0)
return false;
if (other.version_type == 0)
return true;
// 5. Compare Type value:
// A. Ours is greater: Remote is older.
// B. Theirs is greater: Remote is newer.
// C. Continue the check.
if (version_type > other.version_type)
return false;
if (version_type < other.version_type)
return true;
// 6. Compare Index:
// A. Ours is greater or equal: Remote is older or identical.
// B. Remote is newer
if (version_index >= other.version_index)
return false;
return true;
}
void streamfx::updater::task(util::threadpool_data_t)
try {
{
std::vector<char> buffer;
task_query(buffer);
task_parse(buffer);
}
#ifdef _DEBUG
{
std::lock_guard<std::mutex> lock(_lock);
nlohmann::json current = _current_info;
nlohmann::json stable = _release_info;
nlohmann::json preview = _testing_info;
D_LOG_DEBUG("Current Version: %s", current.dump().c_str());
D_LOG_DEBUG("Stable Version: %s", stable.dump().c_str());
D_LOG_DEBUG("Preview Version: %s", preview.dump().c_str());
}
#endif
// Log that we have a potential update.
if (have_update()) {
auto info = get_update_info();
if (info.version_type) {
D_LOG_INFO("Update to version %d.%d.%d%.1s%d is available at \"%s\".", info.version_major,
info.version_minor, info.version_patch, &info.version_type, info.version_index,
info.url.c_str());
} else {
D_LOG_INFO("Update to version %d.%d.%d is available at \"%s\".", info.version_major, info.version_minor,
info.version_patch, info.url.c_str());
}
} else {
D_LOG_DEBUG("No update available.");
}
// Notify listeners of the update.
events.refreshed.call(*this);
} catch (const std::exception& ex) {
// Notify about the error.
std::string message = ex.what();
events.error.call(*this, message);
}
void streamfx::updater::task_query(std::vector<char>& buffer)
{
static constexpr std::string_view ST_API_URL = "https://api.github.com/repos/Xaymar/obs-StreamFX/releases";
util::curl curl;
size_t buffer_offset = 0;
// Set headers (User-Agent is needed so Github can contact us!).
curl.set_header("User-Agent", "StreamFX Updater v" STREAMFX_VERSION_STRING);
curl.set_header("Accept", "application/vnd.github.v3+json");
// Set up request.
curl.set_option(CURLOPT_HTTPGET, true); // GET
curl.set_option(CURLOPT_POST, false); // Not POST
curl.set_option(CURLOPT_URL, ST_API_URL);
curl.set_option(CURLOPT_TIMEOUT, 10); // 10s until we fail.
// Callbacks
curl.set_write_callback([this, &buffer, &buffer_offset](void* data, size_t s1, size_t s2) {
size_t size = s1 * s2;
if (buffer.size() < (size + buffer_offset))
buffer.resize(buffer_offset + size);
memcpy(buffer.data() + buffer_offset, data, size);
buffer_offset += size;
return s1 * s2;
});
//std::bind(&streamfx::updater::task_write_cb, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)
// Clear any unknown data and reserve 64KiB of memory.
buffer.clear();
buffer.reserve(0xFFFF);
// Finally, execute the request.
D_LOG_DEBUG("Querying for latest releases...");
if (CURLcode res = curl.perform(); res != CURLE_OK) {
D_LOG_ERROR("Performing query failed with error: %s", curl_easy_strerror(res));
throw std::runtime_error(curl_easy_strerror(res));
}
int32_t status_code = 0;
if (CURLcode res = curl.get_info(CURLINFO_HTTP_CODE, status_code); res != CURLE_OK) {
D_LOG_ERROR("Retrieving status code failed with error: %s", curl_easy_strerror(res));
throw std::runtime_error(curl_easy_strerror(res));
}
D_LOG_DEBUG("API returned status code %d.", status_code);
if (status_code != 200) {
D_LOG_ERROR("API returned unexpected status code %d.", status_code);
throw std::runtime_error("Request failed due to one or more reasons.");
}
}
void streamfx::updater::task_parse(std::vector<char>& buffer)
{
// Parse the JSON response from the API.
nlohmann::json json = nlohmann::json::parse(buffer.begin(), buffer.end());
// Check if it was parsed as an object.
if (json.type() != nlohmann::json::value_t::array) {
throw std::runtime_error("Response is not a JSON array.");
}
// Iterate over all entries.
std::lock_guard<std::mutex> lock(_lock);
for (auto obj : json) {
try {
auto info = obj.get<streamfx::update_info>();
if (info.channel == update_channel::RELEASE) {
if (_release_info.is_newer(info))
_release_info = info;
if (_testing_info.is_newer(info))
_testing_info = info;
} else {
if (_testing_info.is_newer(info))
_testing_info = info;
}
} catch (const std::exception& ex) {
D_LOG_DEBUG("Failed to parse entry, error: %s", ex.what());
}
}
}
bool streamfx::updater::can_check()
{
#ifdef _DEBUG
return true;
#else
auto now = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch());
auto threshold = (_lastcheckedat + std::chrono::minutes(10));
return (now > threshold);
#endif
}
void streamfx::updater::load()
{
std::lock_guard<std::mutex> lock(_lock);
if (auto config = streamfx::configuration::instance(); config) {
auto dataptr = config->get();
if (obs_data_has_user_value(dataptr.get(), ST_CFG_GDPR))
_gdpr = obs_data_get_bool(dataptr.get(), ST_CFG_GDPR);
if (obs_data_has_user_value(dataptr.get(), ST_CFG_AUTOMATION))
_automation = obs_data_get_bool(dataptr.get(), ST_CFG_AUTOMATION);
if (obs_data_has_user_value(dataptr.get(), ST_CFG_CHANNEL))
_channel = static_cast<update_channel>(obs_data_get_int(dataptr.get(), ST_CFG_CHANNEL));
if (obs_data_has_user_value(dataptr.get(), ST_CFG_LASTCHECKEDAT))
_lastcheckedat = std::chrono::seconds(obs_data_get_int(dataptr.get(), ST_CFG_LASTCHECKEDAT));
}
}
void streamfx::updater::save()
{
if (auto config = streamfx::configuration::instance(); config) {
auto dataptr = config->get();
obs_data_set_bool(dataptr.get(), ST_CFG_GDPR, _gdpr);
obs_data_set_bool(dataptr.get(), ST_CFG_AUTOMATION, _automation);
obs_data_set_int(dataptr.get(), ST_CFG_CHANNEL, static_cast<long long>(_channel));
obs_data_set_int(dataptr.get(), ST_CFG_LASTCHECKEDAT, static_cast<long long>(_lastcheckedat.count()));
}
}
streamfx::updater::updater()
: _lock(), _task(),
_gdpr(false), _automation(true), _channel(update_channel::RELEASE), _lastcheckedat(),
_current_info(), _release_info(), _testing_info(), _dirty(false)
{
// Load information from configuration.
load();
// Build current version information.
try {
_current_info.version_major = STREAMFX_VERSION_MAJOR;
_current_info.version_minor = STREAMFX_VERSION_MINOR;
_current_info.version_patch = STREAMFX_VERSION_PATCH;
_current_info.channel = _channel;
std::string suffix = STREAMFX_VERSION_SUFFIX;
if (suffix.length()) {
_current_info.version_type = suffix.at(0);
_current_info.version_index = static_cast<uint16_t>(strtoul(&suffix.at(1), nullptr, 10));
}
} catch (...) {
D_LOG_ERROR("Failed to parse current version information, results may be inaccurate.");
}
}
streamfx::updater::~updater()
{
save();
}
bool streamfx::updater::gdpr()
{
return _gdpr;
}
void streamfx::updater::set_gdpr(bool value)
{
_dirty = true;
_gdpr = value;
events.gdpr_changed(*this, _gdpr);
{
std::lock_guard<std::mutex> lock(_lock);
save();
}
D_LOG_INFO("User %s the processing of data.", _gdpr ? "allowed" : "disallowed");
}
bool streamfx::updater::automation()
{
return _automation;
}
void streamfx::updater::set_automation(bool value)
{
_automation = value;
events.automation_changed(*this, _automation);
{
std::lock_guard<std::mutex> lock(_lock);
save();
}
D_LOG_INFO("Automatic checks at launch are now %s.", value ? "enabled" : "disabled");
}
streamfx::update_channel streamfx::updater::channel()
{
return _channel;
}
void streamfx::updater::set_channel(update_channel value)
{
std::lock_guard<std::mutex> lock(_lock);
_dirty = true;
_channel = value;
events.channel_changed(*this, _channel);
save();
D_LOG_INFO("Update channel changed to %s.", value == update_channel::RELEASE ? "Release" : "Testing");
}
void streamfx::updater::refresh()
{
if (!_task.expired() || !gdpr()) {
return;
}
if (can_check()) {
std::lock_guard<std::mutex> lock(_lock);
// Update last checked time.
_lastcheckedat =
std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch());
save();
// Spawn a new task.
_task = streamfx::threadpool()->push(std::bind(&streamfx::updater::task, this, std::placeholders::_1), nullptr);
} else {
events.refreshed(*this);
}
}
bool streamfx::updater::have_update()
{
auto info = get_update_info();
return _current_info.is_newer(info);
}
streamfx::update_info streamfx::updater::get_current_info()
{
return _current_info;
}
streamfx::update_info streamfx::updater::get_update_info()
{
std::lock_guard<std::mutex> lock(_lock);
update_info info = _release_info;
if (info.is_newer(_testing_info) && (_channel == update_channel::TESTING))
info = _testing_info;
return info;
}
std::shared_ptr<streamfx::updater> streamfx::updater::instance()
{
static std::weak_ptr<streamfx::updater> _instance;
static std::mutex _lock;
std::lock_guard<std::mutex> lock(_lock);
if (_instance.expired()) {
auto ptr = std::make_shared<streamfx::updater>();
_instance = ptr;
return ptr;
} else {
return _instance.lock();
}
}

115
source/updater.hpp Normal file
View file

@ -0,0 +1,115 @@
// Copyright (c) 2020 Michael Fabian Dirks <info@xaymar.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#pragma once
#include <atomic>
#include <chrono>
#include <nlohmann/json.hpp>
#include "util/util-curl.hpp"
#include "util/util-event.hpp"
#include "util/util-threadpool.hpp"
namespace streamfx {
enum class update_channel {
RELEASE,
TESTING,
};
struct update_info {
uint16_t version_major = 0;
uint16_t version_minor = 0;
uint16_t version_patch = 0;
char version_type = 0;
uint16_t version_index = 0;
update_channel channel = update_channel::RELEASE;
std::string url = "";
std::string name = "";
bool is_newer(update_info& other);
};
void to_json(nlohmann::json&, const update_info&);
void from_json(const nlohmann::json&, update_info&);
class updater {
// Internal
std::mutex _lock;
std::weak_ptr<::util::threadpool::task> _task;
// Options
std::atomic_bool _gdpr;
std::atomic_bool _automation;
update_channel _channel;
std::chrono::seconds _lastcheckedat;
// Update Information
update_info _current_info;
update_info _release_info;
update_info _testing_info;
bool _dirty;
private:
void task(util::threadpool_data_t);
void task_query(std::vector<char>& buffer);
void task_parse(std::vector<char>& buffer);
bool can_check();
void load();
void save();
public:
updater();
~updater();
// GDPR compliance (must require user interaction!)
bool gdpr();
void set_gdpr(bool);
// Automatic Update checks
bool automation();
void set_automation(bool);
// Update Channel
update_channel channel();
void set_channel(update_channel channel);
// Refresh information.
void refresh();
// Check current data.
bool have_update();
update_info get_current_info();
update_info get_update_info();
public:
struct _ {
util::event<updater&, bool> gdpr_changed;
util::event<updater&, bool> automation_changed;
util::event<updater&, update_channel> channel_changed;
util::event<updater&, std::string&> error;
util::event<updater&> refreshed;
} events;
public:
static std::shared_ptr<streamfx::updater> instance();
};
} // namespace streamfx

View file

@ -20,6 +20,7 @@
#pragma once
#include <cinttypes>
#include <cstring>
#include <functional>
#include <map>
#include <string>
@ -59,20 +60,17 @@ namespace util {
return curl_easy_setopt(_curl, opt, value);
};
template<>
CURLcode set_option(CURLoption opt, const bool value)
{
// CURL does not seem to accept boolean, so we err on the side of safety here.
return curl_easy_setopt(_curl, opt, value ? 1 : 0);
};
template<>
CURLcode set_option(CURLoption opt, const std::string value)
{
return curl_easy_setopt(_curl, opt, value.c_str());
};
template<>
CURLcode set_option(CURLoption opt, const std::string_view value)
{
return curl_easy_setopt(_curl, opt, value.data());
@ -84,7 +82,6 @@ namespace util {
return curl_easy_getinfo(_curl, info, &value);
};
template<>
CURLcode get_info(CURLINFO info, std::vector<char>& value)
{
char* buffer;
@ -99,7 +96,6 @@ namespace util {
return CURLE_OK;
};
template<>
CURLcode get_info(CURLINFO info, std::string& value)
{
std::vector<char> buffer;

218
ui/updater.ui Normal file
View file

@ -0,0 +1,218 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Updater</class>
<widget class="QDialog" name="Updater">
<property name="windowModality">
<enum>Qt::ApplicationModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>532</width>
<height>245</height>
</rect>
</property>
<property name="windowTitle">
<string comment="StreamFX::UI.Updater.Dialog.Title">StreamFX version %s is now available!</string>
</property>
<property name="styleSheet">
<string notr="true">QWidget {
background: transparent;
color: hsl(0, 0%, 100%);
}
QWidget[objectName=&quot;Updater&quot;] {
background: hsl(0, 0%, 10%);
border: 0;
padding: 5px;
}
QWidget[objectName=&quot;cancel&quot;],
QWidget[objectName=&quot;ok&quot;] {
background: hsl(0, 0%, 20%);
border: hsl(0, 0%, 15%);
padding: 5px;
}
QWidget[objectName=&quot;cancel&quot;]:hover,
QWidget[objectName=&quot;ok&quot;]:hover {
background: hsl(0, 0%, 30%);
}</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>10</number>
</property>
<property name="topMargin">
<number>10</number>
</property>
<property name="rightMargin">
<number>10</number>
</property>
<property name="bottomMargin">
<number>10</number>
</property>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap resource="streamfx.qrc">:/logos/streamfx_logo</pixmap>
</property>
<property name="scaledContents">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="font">
<font>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string comment="StreamFX::UI.Updater.Dialog.Text">A new StreamFX version is available to be installed. </string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLabel" name="currentVersionLabel">
<property name="font">
<font>
<pointsize>12</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string comment="StreamFX::UI.Updater.Dialog.Version.Current">Current Version:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="currentVersion">
<property name="font">
<font>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>0.0.0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLabel" name="latestVersionLabel">
<property name="font">
<font>
<pointsize>12</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string comment="StreamFX::UI.Updater.Dialog.Version.Latest">Latest Version:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="latestVersion">
<property name="font">
<font>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>1.0.0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QPushButton" name="cancel">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string comment="StreamFX::UI.Updater.Dialog.Button.Cancel">Remind me Later</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="ok">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string comment="StreamFX::UI.Updater.Dialog.Button.Ok">Download Now</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources>
<include location="streamfx.qrc"/>
</resources>
<connections/>
</ui>