Compare commits

...

1 Commits

Author SHA1 Message Date
Martchus f4003faa46 WIP: Add an updater 2024-04-07 20:35:41 +02:00
7 changed files with 694 additions and 1 deletions

View File

@ -26,10 +26,11 @@ set(HEADER_FILES
misc/conversion.h
misc/compat.h
models/checklistmodel.h
tests/mockreply.h
resources/qtconfigarguments.h
resources/resources.h
resources/importplugin.h)
set(SRC_FILES misc/dialogutils.cpp misc/desktoputils.cpp models/checklistmodel.cpp resources/qtconfigarguments.cpp
set(SRC_FILES misc/dialogutils.cpp misc/desktoputils.cpp models/checklistmodel.cpp tests/mockreply.cpp resources/qtconfigarguments.cpp
resources/resources.cpp)
set(RES_FILES resources/qtutilsicons.qrc)
@ -42,6 +43,7 @@ set(WIDGETS_HEADER_FILES
settingsdialog/optionpage.h
settingsdialog/settingsdialog.h
settingsdialog/qtsettings.h
setup/updater.h
widgets/buttonoverlay.h
widgets/clearcombobox.h
widgets/clearlineedit.h
@ -61,6 +63,7 @@ set(WIDGETS_SRC_FILES
settingsdialog/optionpage.cpp
settingsdialog/settingsdialog.cpp
settingsdialog/qtsettings.cpp
setup/updater.cpp
widgets/buttonoverlay.cpp
widgets/clearcombobox.cpp
widgets/clearlineedit.cpp
@ -176,6 +179,17 @@ else ()
message(STATUS "D-Bus notifications disabled")
endif ()
# configure whether setup tools are enabled; if not functions/classes under setup become no-ops
option(SETUP_TOOLS "enables setup tools; makes likely sense to disable when distributing via a package" ON)
if (SETUP_TOOLS)
set_property(
SOURCE setup/updater.cpp
APPEND
PROPERTY COMPILE_DEFINITIONS ${META_PROJECT_VARNAME}_SETUP_TOOLS_ENABLED)
list(APPEND QT_TESTS setup)
list(APPEND ADDITIONAL_QT_MODULES Network)
endif ()
# include modules to apply configuration
include(BasicConfig)
include(QtGuiConfig)

369
setup/updater.cpp Normal file
View File

@ -0,0 +1,369 @@
#include "./updater.h"
#include "../misc/compat.h"
#include "../tests/mockreply.h"
#include "resources/config.h"
#ifdef QT_UTILITIES_SETUP_TOOLS_ENABLED
#include <c++utilities/application/argumentparser.h>
#include <QDebug>
#include <QFile>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QRegularExpression>
#include <QStringBuilder>
#include <QVersionNumber>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonParseError>
#endif
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
#define QT_UTILITIES_VERSION_SUFFIX QString()
#else
#define QT_UTILITIES_VERSION_SUFFIX QStringLiteral("-qt5")
#endif
namespace QtUtilities {
/*!
* \brief
*/
void handleUpdate()
{
}
static QFile fileForRequest(const QString &dir, const QNetworkRequest &request)
{
auto url = request.url();
auto path = url.path();
auto query = url.query();
for (const auto c : {QChar('/'), QChar('&')}){
path.replace(c, QChar('-'));
query.replace(c, QChar('-'));
}
return QFile(dir % QChar('/') % url.host() % QChar('-') % path % QChar('-') % query % QStringLiteral(".txt"));
}
struct NetworkReplyData {
explicit NetworkReplyData();
~NetworkReplyData();
static NetworkReplyData current(QObject *sender);
QNetworkReply *reply;
QByteArray response;
};
NetworkReplyData::NetworkReplyData()
: reply(nullptr)
{
}
NetworkReplyData::~NetworkReplyData()
{
if (reply) {
reply->deleteLater();
}
}
NetworkReplyData NetworkReplyData::current(QObject *sender)
{
// assign reply and read the response
auto d = NetworkReplyData();
d.reply = qobject_cast<QNetworkReply *>(sender);
if (!d.reply) {
qDebug() << "Update check: sender is no QNetworkReply";
return d;
}
if (d.reply->error() == QNetworkReply::NoError) {
d.response = d.reply->readAll();
}
// dump the response for debugging/testing if enabled
static const auto dumpDir = qEnvironmentVariable(PROJECT_VARNAME_UPPER "_UPDATER_DUMP_RESPONSES");
if (!dumpDir.isEmpty() && !qobject_cast<MockReply *>(sender)) {
auto file = fileForRequest(dumpDir, d.reply->request());
if (!file.open(QFile::WriteOnly) || !file.write(d.response)) {
qDebug() << "Update check: Unable to dump response to '" << file.fileName() << "': " << file.errorString();
}
file.close();
}
return d;
}
struct UpdateNotifierPrivate {
QNetworkAccessManager *nm = nullptr;
QVersionNumber currentVersion = QVersionNumber();
QRegularExpression gitHubRegex = QRegularExpression(QStringLiteral(".*/github.com/([^/]+)/([^/]+)(/.*)?"));
QRegularExpression assetRegex = QRegularExpression();
QString newVersion;
QString additionalInfo;
QString error;
QUrl downloadUrl;
QUrl releasesUrl;
bool inProgress = false;
bool updateAvailable = false;
bool verbose = false;
};
UpdateNotifier::UpdateNotifier(QObject *parent)
: QObject(parent)
#ifdef QT_UTILITIES_SETUP_TOOLS_ENABLED
, m_p(std::make_unique<UpdateNotifierPrivate>())
#endif
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
return;
#else
m_p->verbose = qEnvironmentVariableIntValue(PROJECT_VARNAME_UPPER "_UPDATER_VERBOSE");
const auto &appInfo = CppUtilities::applicationInfo;
const auto url = QString::fromUtf8(appInfo.url);
const auto gitHubMatch = m_p->gitHubRegex.match(url);
const auto gitHubOrga = gitHubMatch.captured(1);
const auto gitHubRepo = gitHubMatch.captured(2);
if (gitHubOrga.isNull() || gitHubRepo.isNull()) {
return;
}
const auto expectedBinaryName = gitHubRepo % QT_UTILITIES_VERSION_SUFFIX;
m_p->releasesUrl = QStringLiteral("https://api.github.com/repos/") % gitHubOrga % QChar('/') % gitHubRepo % QStringLiteral("/releases?per_page=25");
m_p->currentVersion = QVersionNumber::fromString(QUtf8StringView(appInfo.version));
#if defined(Q_OS_WIN64)
m_p->assetRegex = QRegularExpression(expectedBinaryName + QStringLiteral("-.*-x86_64-w64-mingw32\\.exe\\..+"));
#elif defined(Q_OS_WIN32)
m_p->assetRegex = QRegularExpression(expectedBinaryName + QStringLiteral("-.*-i686-w64-mingw32\\.exe\\..+"));
#elif defined(__GNUC__) && defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && defined(__x86_64__)
m_p->assetRegex = QRegularExpression(expectedBinaryName + QStringLiteral("-.*-x86_64-pc-linux-gnu\\..+"));
#endif
#endif
}
UpdateNotifier::~UpdateNotifier()
{
}
bool UpdateNotifier::isSupported() const
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
return false;
#else
return !m_p->assetRegex.pattern().isEmpty();
#endif
}
bool UpdateNotifier::isInProgress() const
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
return false;
#else
return m_p->inProgress;
#endif
}
bool UpdateNotifier::isUpdateAvailable() const
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
return false;
#else
return m_p->updateAvailable;
#endif
}
const QString &UpdateNotifier::newVersion() const
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
static const auto v = QString();
return v;
#else
return m_p->newVersion;
#endif
}
const QString &UpdateNotifier::additionalInfo() const
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
static const auto v = QString();
return v;
#else
return m_p->additionalInfo;
#endif
}
const QString &UpdateNotifier::error() const
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
static const auto v = QString();
return v;
#else
return m_p->error;
#endif
}
const QUrl &UpdateNotifier::downloadUrl() const
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
static const auto v = QUrl();
return v;
#else
return m_p->downloadUrl;
#endif
}
void UpdateNotifier::setNetworkAccessManager(QNetworkAccessManager *nm)
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
Q_UNUSED(nm)
#else
m_p->nm = nm;
#endif
}
void UpdateNotifier::setError(const QString &context, QNetworkReply *reply)
{
m_p->error = context + reply->errorString();
emit inProgressChanged(m_p->inProgress = false);
}
void UpdateNotifier::setError(const QString &context, const QJsonParseError &jsonError, const QByteArray &response, QNetworkReply *)
{
m_p->error = context % jsonError.errorString() % QChar(' ') % QChar('(') % tr("at offset %1").arg(jsonError.offset) % QChar(')');
if (!response.isEmpty()) {
m_p->error += QStringLiteral("\nResponse was: ");
m_p->error += QString::fromUtf8(response);
}
emit inProgressChanged(m_p->inProgress = false);
}
void UpdateNotifier::checkForUpdate()
{
#ifndef QT_UTILITIES_SETUP_TOOLS_ENABLED
return;
#else
if (!m_p->nm || m_p->inProgress) {
return;
}
emit inProgressChanged(m_p->inProgress = true);
auto *const reply = makeRequest(QNetworkRequest(m_p->releasesUrl));
connect(reply, &QNetworkReply::finished, this, &UpdateNotifier::readReleases);
#endif
}
void UpdateNotifier::readReleases()
{
const auto d = NetworkReplyData::current(sender());
switch (d.reply->error()) {
case QNetworkReply::NoError: {
// parse JSON
auto jsonError = QJsonParseError();
const auto replyDoc = QJsonDocument::fromJson(d.response, &jsonError);
if (jsonError.error != QJsonParseError::NoError) {
setError(tr("Unable to parse releases: "), jsonError, d.response, d.reply);
return;
}
#if !defined(QT_JSON_READONLY)
if (m_p->verbose) {
qDebug().noquote() << "Update check: found releases: " << QString::fromUtf8(replyDoc.toJson(QJsonDocument::Indented));
}
#endif
const auto replyArray = replyDoc.array();
for (const auto &releaseInfoVal : replyArray) {
const auto releaseInfo = releaseInfoVal.toObject();
const auto tag = releaseInfo.value(QLatin1String("tag_name")).toString();
const auto version = QVersionNumber::fromString(tag.startsWith(QChar('v')) ? tag.mid(1) : tag);
if (!version.isNull() && version > m_p->currentVersion) {
const auto assets = releaseInfo.value(QLatin1String("assets"));
m_p->newVersion = version.toString();
if (assets.isArray()) {
processAssets(assets.toArray());
emit inProgressChanged(m_p->inProgress = false);
} else {
queryRelease(releaseInfo.value(QLatin1String("assets_url")).toString());
}
return;
}
if (m_p->verbose) {
qDebug() << "Update check: skipping release: " << tag;
}
}
break;
}
case QNetworkReply::OperationCanceledError:
emit inProgressChanged(m_p->inProgress = false);
return;
default:
setError(tr("Unable to request releases: "), d.reply);
}
}
void UpdateNotifier::queryRelease(const QUrl &releaseUrl)
{
auto *const reply = makeRequest(QNetworkRequest(releaseUrl));
connect(reply, &QNetworkReply::finished, this, &UpdateNotifier::readRelease);
}
void UpdateNotifier::readRelease()
{
const auto d = NetworkReplyData::current(sender());
switch (d.reply->error()) {
case QNetworkReply::NoError: {
// parse JSON
auto jsonError = QJsonParseError();
const auto replyDoc = QJsonDocument::fromJson(d.response, &jsonError);
if (jsonError.error != QJsonParseError::NoError) {
setError(tr("Unable to parse release: "), jsonError, d.response, d.reply);
return;
}
#if !defined(QT_JSON_READONLY)
if (m_p->verbose) {
qDebug().noquote() << "Update check: found release info: " << QString::fromUtf8(replyDoc.toJson(QJsonDocument::Indented));
}
#endif
processAssets(replyDoc.object().value(QLatin1String("assets")).toArray());
emit inProgressChanged(m_p->inProgress = false);
break;
}
case QNetworkReply::OperationCanceledError:
emit inProgressChanged(m_p->inProgress = false);
return;
default:
setError(tr("Unable to request release: "), d.reply);
}
}
void UpdateNotifier::processAssets(const QJsonArray &assets)
{
for (const auto &assetVal : assets) {
const auto asset = assetVal.toObject();
const auto assetName = asset.value(QLatin1String("name")).toString();
if (!assetName.isEmpty() && m_p->assetRegex.match(assetName).hasMatch()) {
m_p->downloadUrl = asset.value(QLatin1String("browser_download_url")).toString();
m_p->updateAvailable = true;
emit updateAvailable(m_p->newVersion, m_p->additionalInfo);
return;
}
if (m_p->verbose) {
qDebug() << "Update check: skipping asset: " << assetName;
}
}
}
QNetworkReply *UpdateNotifier::makeRequest(const QNetworkRequest &request)
{
static const auto mockDir = qEnvironmentVariable(PROJECT_VARNAME_UPPER "_UPDATER_MOCK_RESPONSES");
if (m_p->nm && mockDir.isEmpty()) {
return m_p->nm->get(request);
} else {
// mock the response for debugging/testing if enabled
auto file = fileForRequest(mockDir, request);
qDebug() << "Update check: loading mock response from: " << file.fileName();
return MockReply::forFile(request, std::move(file));
}
}
} // namespace QtUtilities

70
setup/updater.h Normal file
View File

@ -0,0 +1,70 @@
#ifndef QT_UTILITIES_SETUP_UPDATER_H
#define QT_UTILITIES_SETUP_UPDATER_H
#include "../global.h"
#include "../settingsdialog/optionpage.h" // TODO: UpdateOptionPage
#include <QObject>
#include <QUrl>
#include <memory>
QT_FORWARD_DECLARE_CLASS(QJsonParseError)
QT_FORWARD_DECLARE_CLASS(QJsonArray)
QT_FORWARD_DECLARE_CLASS(QNetworkAccessManager)
QT_FORWARD_DECLARE_CLASS(QNetworkReply)
QT_FORWARD_DECLARE_CLASS(QNetworkRequest)
namespace QtUtilities {
QT_UTILITIES_EXPORT void handleUpdate();
struct UpdateNotifierPrivate;
class QT_UTILITIES_EXPORT UpdateNotifier : public QObject {
Q_OBJECT
Q_PROPERTY(bool supported READ isSupported)
Q_PROPERTY(bool inProgress READ isInProgress NOTIFY inProgressChanged)
Q_PROPERTY(bool updateAvailable READ isUpdateAvailable)
Q_PROPERTY(QString newVersion READ newVersion)
Q_PROPERTY(QString additionalInfo READ additionalInfo)
Q_PROPERTY(QString error READ error)
Q_PROPERTY(QUrl downloadUrl READ downloadUrl)
public:
explicit UpdateNotifier(QObject *parent = nullptr);
~UpdateNotifier() override;
bool isSupported() const;
bool isInProgress() const;
bool isUpdateAvailable() const;
const QString &newVersion() const;
const QString &additionalInfo() const;
const QString &error() const;
const QUrl &downloadUrl() const;
void setNetworkAccessManager(QNetworkAccessManager *nm);
public Q_SLOTS:
void checkForUpdate();
Q_SIGNALS:
void inProgressChanged(bool inProgress);
void updateAvailable(const QString &version, const QString &additionalInfo);
private Q_SLOTS:
void setError(const QString &context, QNetworkReply *reply);
void setError(const QString &context, const QJsonParseError &jsonError, const QByteArray &response, QNetworkReply *reply);
void readReleases();
void queryRelease(const QUrl &releaseUrl);
void readRelease();
void processAssets(const QJsonArray &assets);
private:
QNetworkReply *makeRequest(const QNetworkRequest &request);
std::unique_ptr<UpdateNotifierPrivate> m_p;
};
} // namespace QtUtilities
#endif // QT_UTILITIES_SETUP_UPDATER_H

File diff suppressed because one or more lines are too long

101
tests/mockreply.cpp Normal file
View File

@ -0,0 +1,101 @@
#include "./mockreply.h"
#include <QFile>
#include <QTimer>
#include <algorithm>
namespace QtUtilities {
struct MockReplyPrivate {
QByteArray bufferArray;
std::string_view bufferView;
const char *pos;
qint64 bytesLeft;
};
MockReply::MockReply(const QByteArray &buffer, int delay, QObject *parent)
: QNetworkReply(parent)
, m_d(std::make_unique<MockReplyPrivate>())
{
m_d->bufferArray = buffer;
m_d->bufferView = std::string_view(m_d->bufferArray.data(), static_cast<std::size_t>(m_d->bufferArray.size()));
m_d->pos = m_d->bufferView.data();
m_d->bytesLeft = static_cast<qint64>(m_d->bufferView.size());
setOpenMode(QIODevice::ReadOnly);
QTimer::singleShot(delay, this, &MockReply::emitFinished);
}
MockReply::~MockReply()
{
}
void MockReply::abort()
{
close();
}
void MockReply::close()
{
}
qint64 MockReply::bytesAvailable() const
{
return m_d->bytesLeft;
}
bool MockReply::isSequential() const
{
return true;
}
qint64 MockReply::size() const
{
return static_cast<qint64>(m_d->bufferView.size());
}
qint64 MockReply::readData(char *data, qint64 maxlen)
{
if (!m_d->bytesLeft) {
return -1;
}
const auto bytesToRead = std::min<qint64>(m_d->bytesLeft, maxlen);
if (!bytesToRead) {
return 0;
}
std::copy(m_d->pos, m_d->pos + bytesToRead, data);
m_d->pos += bytesToRead;
m_d->bytesLeft -= bytesToRead;
return bytesToRead;
}
MockReply *MockReply::forFile(const QNetworkRequest &request, QFile &&file, int delay)
{
auto buffer = QByteArray();
if (file.exists()) {
file.open(QFile::ReadOnly);
buffer = file.readAll();
file.close();
}
auto *const reply = new MockReply(buffer, delay);
reply->setRequest(request);
return reply;
}
void MockReply::emitFinished()
{
if (m_d->bufferView.empty()) {
setError(QNetworkReply::InternalServerError, QStringLiteral("No mock reply available for this request."));
setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 404);
setAttribute(QNetworkRequest::HttpReasonPhraseAttribute, QLatin1String("Not found"));
} else {
setError(QNetworkReply::NoError, QString());
setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 200);
setAttribute(QNetworkRequest::HttpReasonPhraseAttribute, QLatin1String("OK"));
}
setFinished(true);
emit finished();
}
} // namespace QtUtilities

49
tests/mockreply.h Normal file
View File

@ -0,0 +1,49 @@
#ifndef QT_UTILITIES_TESTS_MOCK_REPLY_H
#define QT_UTILITIES_TESTS_MOCK_REPLY_H
#include "../global.h"
#include <QNetworkReply>
#include <memory>
QT_FORWARD_DECLARE_CLASS(QFile)
namespace QtUtilities {
struct MockReplyPrivate;
/*!
* \brief The MockedReply class provides a fake QNetworkReply which will just return data from a specified buffer.
*/
class QT_UTILITIES_EXPORT MockReply : public QNetworkReply {
Q_OBJECT
public:
~MockReply() override;
public Q_SLOTS:
void abort() override;
public:
void close() override;
qint64 bytesAvailable() const override;
bool isSequential() const override;
qint64 size() const override;
qint64 readData(char *data, qint64 maxlen) override;
static MockReply *forFile(const QNetworkRequest &request, QFile &&file, int delay = 0);
protected:
QT_UTILITIES_EXPORT MockReply(const QByteArray &buffer, int delay, QObject *parent = nullptr);
private Q_SLOTS:
void emitFinished();
private:
std::unique_ptr<MockReplyPrivate> m_d;
};
} // namespace QtUtilities
#endif // QT_UTILITIES_TESTS_HELPER_H

89
tests/setup.cpp Normal file
View File

@ -0,0 +1,89 @@
#include "../setup/updater.h"
#include "resources/config.h"
#include <c++utilities/application/argumentparser.h>
#include <c++utilities/conversion/conversionexception.h>
#include <c++utilities/conversion/stringconversion.h>
#include <QEventLoop>
#include <QTimer>
#include <QNetworkAccessManager>
#include <QtTest/QtTest>
using namespace QtUtilities;
class SetupTests : public QObject {
Q_OBJECT
private Q_SLOTS:
void initTestCase();
void testUpdateNotifier();
private:
double m_timeoutFactor = 1.0;
};
void SetupTests::initTestCase()
{
CppUtilities::applicationInfo.url = "https://github.com/Martchus/syncthingtray";
CppUtilities::applicationInfo.version = "1.4.0";
if (!qEnvironmentVariableIsSet(PROJECT_VARNAME_UPPER "_UPDATER_VERBOSE")) {
qputenv(PROJECT_VARNAME_UPPER "_UPDATER_VERBOSE", "1");
}
if (const auto timeoutFactorEnv = qgetenv(PROJECT_VARNAME_UPPER "_TEST_TIMEOUT_FACTOR"); !timeoutFactorEnv.isEmpty()) {
try {
m_timeoutFactor = CppUtilities::stringToNumber<double>(timeoutFactorEnv.data());
qDebug() << "using timeout factor: " << m_timeoutFactor;
} catch (const CppUtilities::ConversionException &) {
qDebug() << "ignoring invalid " PROJECT_VARNAME_UPPER "_TEST_TIMEOUT_FACTOR";
}
}
}
void SetupTests::testUpdateNotifier()
{
auto updateNotifier = UpdateNotifier();
if (!updateNotifier.isSupported()) {
qDebug() << "skipping: UpdateNotifier() is not supported";
return;
}
auto eventLoop = QEventLoop();
auto timeout = QTimer();
timeout.setInterval(static_cast<int>(5000 * m_timeoutFactor));
timeout.setSingleShot(true);
auto networkAccessManager = QNetworkAccessManager();
auto newVersionFromSignal = QString();
updateNotifier.setNetworkAccessManager(&networkAccessManager);
updateNotifier.checkForUpdate();
QObject::connect(&updateNotifier, &UpdateNotifier::inProgressChanged, &eventLoop, &QEventLoop::quit);
QObject::connect(&updateNotifier, &UpdateNotifier::updateAvailable, &updateNotifier, [&newVersionFromSignal] (const QString &version, const QString &additionalInfo) {
Q_UNUSED(additionalInfo)
newVersionFromSignal = version;
});
QObject::connect(&timeout, &QTimer::timeout, &eventLoop, &QEventLoop::quit);
timeout.start();
eventLoop.exec();
timeout.stop();
QCOMPARE(updateNotifier.error(), QString());
qDebug() << "download URL: " << updateNotifier.downloadUrl();
QCOMPARE(updateNotifier.downloadUrl().host(), QStringLiteral("github.com"));
QVERIFY2(updateNotifier.downloadUrl().path().startsWith(QLatin1String("/Martchus/syncthingtray/releases/download/")), "download URL contains expected path");
QVERIFY2(updateNotifier.isUpdateAvailable(), "update considered available");
QVERIFY2(!updateNotifier.isInProgress(), "not timed out");
QVERIFY2(!updateNotifier.newVersion().isEmpty(), "new version assigned");
QVERIFY2(!newVersionFromSignal.isEmpty(), "updateAvailable() was emitted with non-empty version");
QCOMPARE(updateNotifier.newVersion(), newVersionFromSignal);
QVERIFY2(!QVersionNumber::fromString(newVersionFromSignal).isNull(), "version is parsable");
}
QTEST_MAIN(SetupTests)
#include "setup.moc"