qtutilities/setup/updater.cpp

370 lines
11 KiB
C++

#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