Skip to content

Commit

Permalink
Move check for latest release onto a background thread
Browse files Browse the repository at this point in the history
  • Loading branch information
Matt Young committed Oct 29, 2024
1 parent 70c3f17 commit 2ddeaf1
Show file tree
Hide file tree
Showing 7 changed files with 310 additions and 161 deletions.
2 changes: 2 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ commonSourceFiles = files([
'src/HydrometerTool.cpp',
'src/IbuGuSlider.cpp',
'src/InventoryFormatter.cpp',
'src/LatestReleaseFinder.cpp',
'src/Localization.cpp',
'src/Logging.cpp',
'src/MainWindow.cpp',
Expand Down Expand Up @@ -865,6 +866,7 @@ mocHeaders = files([
'src/HelpDialog.h',
'src/HydrometerTool.h',
'src/IbuGuSlider.h',
'src/LatestReleaseFinder.h',
'src/MainWindow.h',
'src/MashDesigner.h',
'src/MashWizard.h',
Expand Down
247 changes: 89 additions & 158 deletions src/Application.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,21 @@
#include <iostream>
#include <mutex> // For std::call_once etc

//¥¥vv
#include <string>

#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast.hpp>
//¥¥^^

#include <QDebug>
#include <QDesktopServices>
#include <QDirIterator>
#include <QJsonObject>
#include <QJsonParseError>
#include <QMessageBox>
#include <QObject>
#include <QString>
#include <QtNetwork/QNetworkAccessManager>
#include <QtNetwork/QNetworkReply>
#include <QtNetwork/QNetworkRequest>
#include <QThread>
#include <QUrl>
#include <QVersionNumber>

#include "Algorithms.h"
#include "BtSplashScreen.h"
#include "config.h"
#include "database/Database.h"
#include "LatestReleaseFinder.h"
#include "Localization.h"
#include "MainWindow.h"
#include "measurement/ColorMethods.h"
Expand Down Expand Up @@ -91,11 +80,10 @@ namespace {
bool interactive = true;

//! \brief If this option is false, do not bother the user about new versions.
bool checkVersion = true;
bool tellUserAboutNewRelease = true;

void setCheckVersion(bool value) {
checkVersion = value;
}
//! \brief Worker thread for finding the latest released version of the program
QThread latestReleaseFinderThread;

/**
* \brief Create a directory if it doesn't exist, popping a error dialog if creation fails
Expand Down Expand Up @@ -205,164 +193,82 @@ namespace {
}

/**
* \brief Check whether there is a newer version of the software available to download from GitHub. If so, ask the
* user whether they want to upgrade. (We don't actually DO the upgrade. We just take them to the place they
* can download it.)
* \brief This is called to tell us the version number of the latest release of the program in its main GitHub
* repository.
*
* NB: AFAICT, because this is a freestanding function, rather than a member function of a QObject subclass,
* this gets run on the caller's thread. In particular this means it could run at the same time as other
* code running inside the program's main Qt event loop. If we wanted to do anything that might risk
* clashing with other code then we would either want to add locking here or move this function to be,
* say, a slot on one of our objects (eg MainWindow).
*/
void checkForNewVersion() {
//
// Users can turn off the check for new versions
//
if (!checkVersion) {
qDebug() << Q_FUNC_INFO << "Check for new version is disabled";
return;
}

//
// Checking for the latest version involves requesting a JSON object from the GitHub API over HTTPS.
//
// Previously we used the Qt framework (QNetworkAccessManager / QNetworkRequest / QNetworkReply) to do the HTTP
// request/response. The problem with this is that, when something goes wrong it can be rather hard to diagnose.
// Eg we had a bug that triggered a stack overflow in the Qt internals but there was only a limited amount of
// logging we could add to try to determine what was going on.
//
// So now, instead, we use Boost.Beast (which sits on top of Boost.Asio) and OpenSSL. This is very slightly
// lower-level -- in that fewer things are magically defaulted for you -- and requires us to use std::string
// rather than QString. But at least it does not require a callback function. And, should we have future
// problems, it should be easier to delve into.
//
// Although it's a bit long-winded, we're not really doing anything clever here. The example code at
// https://www.boost.org/doc/libs/1_86_0/libs/beast/doc/html/beast/quick_start/http_client.html explains a lot of
// what's going on. We're just doing a bit extra to do HTTPS rather than HTTP.
//
std::string const host{"api.github.com"};
// It would be neat to construct this string at compile-time, but I haven't yet worked out how!
std::string const path = QString{"/repos/%1/%2/releases/latest"}.arg(CONFIG_APPLICATION_NAME_UC, CONFIG_APPLICATION_NAME_LC).toStdString();
std::string const port{"443"};
//
// Here 11 means HTTP/1.1, 20 means HTTP/2.0, 30 means HTTP/3.0. (See
// https://www.boost.org/doc/libs/1_86_0/libs/beast/doc/html/beast/ref/boost__beast__http__message/version/overload1.html.)
// If we were doing something generic then we'd stick with HTTP/1.1 since that has 100% support. But, since we're
// only making one request, and it's to GitHub, and we know they support they newer version of HTTP, we might as
// well use the newer standard.
//
boost::beast::http::request<boost::beast::http::string_body> httpRequest{boost::beast::http::verb::get,
path,
30};
httpRequest.set(boost::beast::http::field::host, host);
//
// GitHub will respond with an error if the user agent field is not present, but it doesn't care what it's set to
// and will even accept empty string.
//
httpRequest.set(boost::beast::http::field::user_agent, "");

boost::asio::io_service ioService;
//
// A lot of old example code for Boost still uses sslv23_client. However, TLS 1.3 has been out since 2018, and we
// know GitHub (along with most other web sites) supports it. So there's no reason not to use that.
//
boost::asio::ssl::context securityContext(boost::asio::ssl::context::tlsv13_client);
boost::asio::ssl::stream<boost::asio::ip::tcp::socket> secureSocket{ioService, securityContext};
// The resolver essentially does the DNS requests to look up the host address etc
boost::asio::ip::tcp::resolver tcpIpResolver{ioService};
auto endpoint = tcpIpResolver.resolve(host, port);
// TODO: Need to add time-outs here.

// Once we have the address, we can connect, do the SSL handshake, and then send the request
boost::asio::connect(secureSocket.lowest_layer(), endpoint);
secureSocket.handshake(boost::asio::ssl::stream_base::handshake_type::client);
boost::beast::http::write(secureSocket, httpRequest);

// We're expecting a response back pretty quickly, so we'll just wait for it here. If we find response times are
// too
boost::beast::http::response<boost::beast::http::string_body> httpResponse;
boost::beast::flat_buffer buffer;
boost::beast::http::read(secureSocket, buffer, httpResponse);

if (httpResponse.result() != boost::beast::http::status::ok) {
//
// It's not the end of the world if we could't check for an update, but we should record the fact. With some
// things in Boost.Beast, the easiest way to convert them to a string is via a standard library output stream,
// so we construct the whole error message like that rather then try to mix-and-match with Qt logging output
// streams.
//
std::ostringstream errorMessage;
errorMessage << "Error checking for update: " << httpResponse.result_int() <<
".\nResponse headers:" << httpResponse.base();
qInfo().noquote() << Q_FUNC_INFO << QString::fromStdString(errorMessage.str());
return;
}

//
// Checking a version number on Sourceforge is easy, eg a GET request to
// https://brewtarget.sourceforge.net/version just returns the last version of Brewtarget that was hosted on
// Sourceforge (quite an old one).
//
// On GitHub, it's a bit harder as there's a REST API that gives back loads of info in JSON format. We don't want
// to do anything clever with the JSON response, just extract one field, so the Qt JSON support suffices here.
// (See comments elsewhere for why we don't use it for BeerJSON.)
//
QByteArray rawContent = QByteArray::fromStdString(httpResponse.body());
QJsonParseError jsonParseError{};

QJsonDocument jsonDocument = QJsonDocument::fromJson(rawContent, &jsonParseError);
if (QJsonParseError::ParseError::NoError != jsonParseError.error) {
qWarning() <<
Q_FUNC_INFO << "Error parsing JSON from version check response:" << jsonParseError.error << "at offset" <<
jsonParseError.offset;
return;

}

QJsonObject jsonObject = jsonDocument.object();

QString remoteVersion = jsonObject.value("tag_name").toString();
// Version names are usually "v3.0.2" etc, so we want to strip the 'v' off the front
if (remoteVersion.startsWith("v", Qt::CaseInsensitive)) {
remoteVersion.remove(0, 1);
}
void checkAgainstLatestRelease(QVersionNumber const latestRelease) {

//
// We used to just compare if the remote version is the same as the current one, but it then gets annoying if you
// are running the nightly build and it keeps asking if you want to, eg, download 4.0.0 because you're "only"
// running 4.0.1. So now we do it properly, letting QVersionNumber do the heavy lifting for us.
//
QVersionNumber const currentlyRunning{QVersionNumber::fromString(CONFIG_VERSION_STRING)};
QVersionNumber const latestRelease {QVersionNumber::fromString(remoteVersion)};

qInfo() <<
Q_FUNC_INFO << "Latest release is" << remoteVersion << "(parsed as" << latestRelease << ") ; "
"currently running" << CONFIG_VERSION_STRING << "(parsed as" << currentlyRunning << ")";
Q_FUNC_INFO << "Latest release is" << latestRelease.toString() << ") ; currently running" <<
CONFIG_VERSION_STRING << "(parsed as" << currentlyRunning.toString() << ")";

// If the remote version is newer...
if (latestRelease > currentlyRunning) {
// ...and the user wants to download the new version...
if(QMessageBox::information(&MainWindow::instance(),
QObject::tr("New Version"),
QObject::tr("Version %1 is now available. Download it?").arg(remoteVersion),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::Yes) == QMessageBox::Yes) {
// ...take them to the website.
//
// Users can turn off the notification about new versions, though note below that this gets reset once they are
// running the latest version again.
//
if (!tellUserAboutNewRelease) {
qInfo() << Q_FUNC_INFO << "Check for new version is disabled";
return;
}

//
// The latest release is newer than what we are currently running.
// See if the user wants to download the newer version.
//
bool const wantsToDownload{
QMessageBox::Yes == QMessageBox::information(
&MainWindow::instance(),
QObject::tr("New Version"),
QObject::tr("Version %1 is now available. Download it?").arg(latestRelease.toString()),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::Yes
)
};
if (wantsToDownload) {
//
// It would be a bit of a tall order for the program to upgrade itself in place. We just take the user to
// the release download page.
//
static QString const releasesPage = QString{"%1/releases"}.arg(CONFIG_GITHUB_URL);
QDesktopServices::openUrl(QUrl(releasesPage));
} else {
// ... and the user does NOT want to download the new version...
// ... and they want us to stop bothering them...
} else {
//
// If the user doesn't want to be taken to the download page, give them the option to stop being reminded
// about the new release.
//
if(QMessageBox::question(&MainWindow::instance(),
QObject::tr("New Version"),
QObject::tr("Stop bothering you about new versions?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::Yes) == QMessageBox::Yes) {
// ... make a note to stop bothering the user about the new version.
setCheckVersion(false);
tellUserAboutNewRelease = false;
}
}
return;
}

// The current version is newest so make a note to bother users about future new versions.
// This means that when a user downloads the new version, this variable will always get reset to true.
setCheckVersion(true);
//
// The user is already running the latest release. Make sure the user gets notified when a newer release is
// available. (They can then turn off that notification if they want.) Essentially, this means that, every time
// the user installs whatever the latest version of the software is, we ensure that we will tell them at least
// once when there is a new release.
//
tellUserAboutNewRelease = true;
return;
}

Expand Down Expand Up @@ -480,6 +386,9 @@ bool Application::initialize() {
// Make sure all the necessary directories and files we need exist before starting.
ensureDirectoriesExist();

// TODO: Seems a bit ugly that we call readSystemOptions here but saveSystemOptions from MainWindow::closeEvent.
// In the long run, it would be a lot more elegant to use RAII to automatically store everything we read from
// PersistentSettings.
Application::readSystemOptions();

Localization::loadTranslations(); // Do internationalization.
Expand Down Expand Up @@ -512,6 +421,13 @@ void Application::cleanup() {
MainWindow::DeleteMainWindow();

Database::instance().unload();

//
// Ensure the thread we spawned to check for new versions is properly terminated
//
latestReleaseFinderThread.quit();
latestReleaseFinderThread.wait();

return;
}

Expand All @@ -536,13 +452,28 @@ int Application::run() {
}
Database::instance().checkForNewDefaultData();

// .:TBD:. Could maybe move the calls to init and setVisible inside createMainWindowInstance() in MainWindow.cpp
//
// Make sure the MainWindow singleton exists, but don't initialise it just yet. We're going to use the end of the
// initialisation to trigger the background thread that checks to see whether a new version of the program is
// available, so we want to set that background thread up first. (But we need the MainWindow object to exist, so we
// can connect signals and slots.)
//
MainWindow & mainWindow = MainWindow::instance();
mainWindow.init();
mainWindow.setVisible(true);
splashScreen.finish(&mainWindow);

checkForNewVersion();
//
// It feels a bit wrong these days to be calling `new` directly, but it is the way to do things in Qt. We tell the
// framework about the object we've created, and it handles clean-up for us.
//
LatestReleaseFinder * latestReleaseFinder = new LatestReleaseFinder{};
latestReleaseFinder->moveToThread(&latestReleaseFinderThread);
mainWindow.connect(&latestReleaseFinderThread, &QThread::finished, latestReleaseFinder, &QObject::deleteLater);

mainWindow.connect(&mainWindow, &MainWindow::initialisedAndVisible, latestReleaseFinder, &LatestReleaseFinder::checkMainRespository);
mainWindow.connect(latestReleaseFinder, &LatestReleaseFinder::foundLatestRelease, &checkAgainstLatestRelease);
latestReleaseFinderThread.start();

mainWindow.initialiseAndMakeVisible();
splashScreen.finish(&mainWindow);
do {
ret = qApp->exec();
} while (ret == 1000);
Expand All @@ -559,8 +490,8 @@ void Application::readSystemOptions() {
updateConfig();

//================Version Checking========================
checkVersion = PersistentSettings::value(PersistentSettings::Names::check_version, QVariant(true)).toBool();
qDebug() << Q_FUNC_INFO << "checkVersion=" << checkVersion;
tellUserAboutNewRelease = PersistentSettings::value(PersistentSettings::Names::check_version, QVariant(true)).toBool();
qDebug() << Q_FUNC_INFO << "tellUserAboutNewRelease=" << tellUserAboutNewRelease;

Measurement::loadDisplayScales();

Expand All @@ -578,7 +509,7 @@ void Application::readSystemOptions() {
}

void Application::saveSystemOptions() {
PersistentSettings::insert(PersistentSettings::Names::check_version, checkVersion);
PersistentSettings::insert(PersistentSettings::Names::check_version, tellUserAboutNewRelease);
//setOption("user_data_dir", userDataDir);

Localization::saveSettings();
Expand Down
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ set(filesToCompile_cpp
${repoDir}/src/HydrometerTool.cpp
${repoDir}/src/IbuGuSlider.cpp
${repoDir}/src/InventoryFormatter.cpp
${repoDir}/src/LatestReleaseFinder
${repoDir}/src/Localization.cpp
${repoDir}/src/Logging.cpp
${repoDir}/src/MainWindow.cpp
Expand Down
Loading

0 comments on commit 2ddeaf1

Please sign in to comment.