obs-StreamFX/source/updater.cpp
Michael Fabian 'Xaymar' Dirks f907fc80b0 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.
2023-03-28 12:52:27 +02:00

492 lines
15 KiB
C++

// 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();
}
}