aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-24 22:59:10 +0200
committersaturneric <[email protected]>2026-09-24 22:59:10 +0200
commit4249f0192baf88e33bfb4e47828279ae2b569a8d (patch)
tree5eea66cc8f58c6770c9777215a58c7be8f375e1b
parentdocs(README): document instant messaging module and UI actions (diff)
downloadmodules-4249f0192baf88e33bfb4e47828279ae2b569a8d.tar.gz
modules-4249f0192baf88e33bfb4e47828279ae2b569a8d.zip
feat(update): add robust GitHub update checking
- replace legacy provider tasks with an injectable update checker - track release, tag, and commit lookup results independently - preserve last known good results across failed refreshes - persist schema-versioned update state and reject stale formats - add update presentation logic and refresh the update dialog UI - remove provider selection in favor of GitHub Releases - add comprehensive release-checking and presentation tests
-rw-r--r--src/m_ver_check/BKTUSVersionCheckTask.cpp309
-rw-r--r--src/m_ver_check/BKTUSVersionCheckTask.h101
-rw-r--r--src/m_ver_check/CMakeLists.txt21
-rw-r--r--src/m_ver_check/GitHubVersionCheckTask.cpp200
-rw-r--r--src/m_ver_check/GitHubVersionCheckTask.h101
-rw-r--r--src/m_ver_check/ReleaseCheck.cpp181
-rw-r--r--src/m_ver_check/ReleaseCheck.h123
-rw-r--r--src/m_ver_check/SoftwareVersion.cpp114
-rw-r--r--src/m_ver_check/SoftwareVersion.h102
-rw-r--r--src/m_ver_check/UpdateChecker.cpp162
-rw-r--r--src/m_ver_check/UpdateChecker.h124
-rw-r--r--src/m_ver_check/UpdatePresentation.cpp154
-rw-r--r--src/m_ver_check/UpdatePresentation.h71
-rw-r--r--src/m_ver_check/UpdateTab.cpp312
-rw-r--r--src/m_ver_check/UpdateTab.h62
-rw-r--r--src/m_ver_check/Utils.cpp54
-rw-r--r--src/m_ver_check/Utils.h15
-rw-r--r--src/m_ver_check/VersionCheckingModule.cpp241
-rw-r--r--src/m_ver_check/VersionCheckingModule.h9
-rw-r--r--src/m_ver_check/module.json2
-rw-r--r--src/m_ver_check/test/CMakeLists.txt60
-rw-r--r--src/m_ver_check/test/ReleaseCheckTest.cpp336
-rw-r--r--src/m_ver_check/test/UpdateCheckerTest.cpp340
-rw-r--r--src/m_ver_check/test/UpdatePresentationTest.cpp161
24 files changed, 2186 insertions, 1169 deletions
diff --git a/src/m_ver_check/BKTUSVersionCheckTask.cpp b/src/m_ver_check/BKTUSVersionCheckTask.cpp
deleted file mode 100644
index 9cf26b9..0000000
--- a/src/m_ver_check/BKTUSVersionCheckTask.cpp
+++ /dev/null
@@ -1,309 +0,0 @@
-/**
- * Copyright (C) 2021-2024 Saturneric <[email protected]>
- *
- * This file is part of GpgFrontend.
- *
- * GpgFrontend is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * GpgFrontend is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
- *
- * The initial version of the source code is inherited from
- * the gpg4usb project, which is under GPL-3.0-or-later.
- *
- * All the source code of GpgFrontend was modified and released by
- * Saturneric <[email protected]> starting on May 12, 2021.
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- */
-
-#include "BKTUSVersionCheckTask.h"
-
-#include <GFSDKLog.h>
-#include <qobject.h>
-
-#include <QDomDocument>
-#include <QMetaType>
-#include <QtNetwork>
-
-#include "GFModule.h"
-#include "SoftwareVersion.h"
-#include "Utils.h"
-
-BKTUSVersionCheckTask::BKTUSVersionCheckTask()
- : network_manager_(new QNetworkAccessManager(this)),
- current_version_(GFAppVersion(GFModuleSdkContext())) {
- qRegisterMetaType<SoftwareVersion>("SoftwareVersion");
- meta_.api = "BKTUS.com";
- meta_.current_version = current_version_;
- meta_.local_commit_hash = GFAppGitCommitHash(GFModuleSdkContext());
-
- connect(this, &BKTUSVersionCheckTask::SignalUpgradeVersion, this,
- [](const SoftwareVersion& sv) {
- gf::sdk::SetCacheText(
- GFModuleSdkContext(), GF_STORE_DURABLE, "update_checking_cache",
- (QJsonDocument(sv.ToJson()).toJson()).constData());
- });
-}
-
-auto BKTUSVersionCheckTask::Run() -> int {
- QString base_url = "";
-
- QList<QUrl> urls = {
- {"https://ftp.bktus.com/GpgFrontend/appcast.xml"},
- {"https://git.bktus.com/gpgfrontend/gpgfrontend/atom/?h=" +
- current_version_},
- {"https://git.bktus.com/gpgfrontend/gpgfrontend/atom/?h=" +
- meta_.local_commit_hash},
- };
-
- connect(network_manager_, &QNetworkAccessManager::finished, this,
- &BKTUSVersionCheckTask::slot_parse_reply);
-
- int index = 0;
- for (const QUrl& url : urls) {
- QNetworkRequest request(url);
- // A check that never answers holds its task, and the tab waiting on it,
- // for the rest of the run.
- request.setTransferTimeout(30000);
- request.setHeader(QNetworkRequest::UserAgentHeader,
- GFAppUserAgent(GFModuleSdkContext()));
- auto* reply = network_manager_->get(request);
- reply->setProperty("GFCheckIndex", index++);
- replies_.append(reply);
- }
-
- return 0;
-}
-
-void BKTUSVersionCheckTask::slot_parse_reply(QNetworkReply* reply) {
- if (reply->error() == QNetworkReply::NoError) {
- auto index = reply->property("GFCheckIndex").toInt();
- FLOG_DEBUG("get reply from url: %1, index: %2", reply->url().toString(),
- index);
- switch (index) {
- case 0:
- slot_parse_latest_version_info(reply);
- break;
- case 1:
- slot_parse_current_tag_info(reply);
- break;
- case 2:
- slot_parse_current_commit_hash_info(reply);
- break;
- default:
- break;
- }
- } else {
- FLOG_DEBUG("get reply from url: %1, error: %2 %3", reply->url().toString(),
- reply->errorString(), reply->readAll());
- }
-
- replies_.removeAll(reply);
- reply->deleteLater();
-
- if (replies_.isEmpty()) {
- meta_.timestamp = QDateTime::currentDateTime();
- FillGrtWithVersionInfo(meta_);
- emit SignalUpgradeVersion(meta_);
- }
-}
-
-void BKTUSVersionCheckTask::slot_parse_latest_version_info(
- QNetworkReply* reply) {
- if (reply == nullptr || reply->error() != QNetworkReply::NoError) {
- return;
- }
-
- auto reply_bytes = reply->readAll();
-
- QDomDocument doc;
- auto xml_text = QString::fromUtf8(reply_bytes);
- auto result = doc.setContent(xml_text);
- if (!result) {
-#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
- FLOG_WARN("xml parse failed: %1, line: %2, column: %3", result.errorMessage,
- result.errorLine, result.errorColumn);
-#else
- LOG_WARN("xml parse failed");
-#endif
- return;
- }
-
- auto root = doc.documentElement();
- auto channel = root.firstChildElement("channel");
- auto items = channel.elementsByTagName("item");
-
- FLOG_DEBUG("appcast.xml items: %1", items.size());
- if (items.size() == 0) {
- LOG_WARN("no xml entry of latest version");
- return;
- }
-
- // pick the newest item that belongs to the same series as the running build,
- // so a stable user is never offered a mainline release and vice versa.
- for (int i = 0; i < items.size(); ++i) {
- auto item = items.at(i).toElement();
- if (item.isNull()) continue;
-
- auto title = item.firstChildElement("title").text();
- auto version = ExtractVersionFromRawTag(title);
- if (version.isEmpty()) {
- FLOG_WARN("fail to match regex rules for release name from bktus: %1",
- title);
- continue;
- }
-
- if (!SoftwareVersion::SameSeries(version, current_version_)) continue;
- if (!meta_.latest_version.isEmpty() &&
- GFCompareSoftwareVersion(version.toUtf8().constData(),
- meta_.latest_version.toUtf8().constData()) <=
- 0) {
- continue;
- }
-
- meta_.latest_version = version;
- meta_.publish_date = item.firstChildElement("pubDate").text();
- meta_.release_note = item.firstChildElement("description").text();
- }
-
- if (meta_.latest_version.isEmpty()) {
- FLOG_WARN("no bktus release found in series of current version: %1",
- current_version_);
- }
-}
-
-void BKTUSVersionCheckTask::slot_parse_current_tag_info(QNetworkReply* reply) {
- if (reply == nullptr || reply->error() != QNetworkReply::NoError) return;
-
- QVariant status_code =
- reply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
- int status = status_code.toInt();
-
- if (status != 200) {
- FLOG_WARN("http status code from git.bktus.com is not 200: %1", status);
- return;
- }
-
- auto reply_bytes = reply->readAll();
-
- QDomDocument doc;
- QString xml_text = QString::fromUtf8(reply_bytes);
- auto result = doc.setContent(xml_text);
- if (!result) {
-#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
- FLOG_WARN("xml parse failed: %1, line: %2, column: %3", result.errorMessage,
- result.errorLine, result.errorColumn);
-#else
- LOG_WARN("xml parse failed");
-#endif
- return;
- }
-
- auto entries = doc.elementsByTagName("entry");
-
- if (entries.size() == 0) {
- FLOG_WARN("no xml entry of current version: %1", current_version_);
- return;
- }
-
- QDomElement entry_elem = entries.at(0).toElement();
- if (entry_elem.isNull()) {
- FLOG_WARN("first xml entry of current version: %1 is null",
- current_version_);
- return;
- }
-
- auto title_elem = entry_elem.firstChildElement("title");
- auto id_elem = entry_elem.firstChildElement("id");
- auto published_elem = entry_elem.firstChildElement("published");
-
- if (title_elem.isNull() || id_elem.isNull() || published_elem.isNull()) {
- FLOG_WARN("illegal xml entry of structure of version: %1",
- current_version_);
- return;
- }
-
- auto title_text = title_elem.text();
- auto id_text = id_elem.text();
- auto published_text = published_elem.text();
-
- FLOG_DEBUG("got tag info from bktus: %1, %2, %3", title_text, id_text,
- published_text);
- meta_.current_version_publish_in_remote = true;
-}
-
-void BKTUSVersionCheckTask::slot_parse_current_commit_hash_info(
- QNetworkReply* reply) {
- if (reply == nullptr || reply->error() != QNetworkReply::NoError) {
- return;
- }
-
- QVariant status_code =
- reply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
- int status = status_code.toInt();
-
- if (status != 200) {
- FLOG_WARN("http status code from git.bktus.com is not 200: %1", status);
- return;
- }
-
- auto reply_bytes = reply->readAll();
-
- QDomDocument doc;
- QString xml_text = QString::fromUtf8(reply_bytes);
- auto result = doc.setContent(xml_text);
- if (!result) {
-#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
- FLOG_WARN("xml parse failed: %1, line: %2, column: %3", result.errorMessage,
- result.errorLine, result.errorColumn);
-#else
- LOG_WARN("xml parse failed");
-#endif
- return;
- }
-
- auto entries = doc.elementsByTagName("entry");
-
- if (entries.size() == 0) {
- FLOG_WARN("no xml entry of current version: %1", current_version_);
- return;
- }
-
- QDomElement entry_elem = entries.at(0).toElement();
- if (entry_elem.isNull()) {
- FLOG_WARN("first xml entry of current version: %1 is null",
- current_version_);
- return;
- }
-
- auto title_elem = entry_elem.firstChildElement("title");
- auto id_elem = entry_elem.firstChildElement("id");
- auto published_elem = entry_elem.firstChildElement("published");
-
- if (title_elem.isNull() || id_elem.isNull() || published_elem.isNull()) {
- FLOG_WARN("illegal xml entry of structure of version: %1",
- current_version_);
- return;
- }
-
- auto title_text = title_elem.text();
- auto id_text = id_elem.text();
- auto published_text = published_elem.text();
-
- FLOG_DEBUG("got commit info from bktus: %1, %2, %3", title_text, id_text,
- published_text);
-
- // the new form of id_text is like: urn:sha1:abcdef1234567890...
- meta_.current_commit_hash_publish_in_remote =
- id_text.trimmed().contains(meta_.local_commit_hash.trimmed());
-}
diff --git a/src/m_ver_check/BKTUSVersionCheckTask.h b/src/m_ver_check/BKTUSVersionCheckTask.h
deleted file mode 100644
index 6c45cc1..0000000
--- a/src/m_ver_check/BKTUSVersionCheckTask.h
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * Copyright (C) 2021-2024 Saturneric <[email protected]>
- *
- * This file is part of GpgFrontend.
- *
- * GpgFrontend is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * GpgFrontend is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
- *
- * The initial version of the source code is inherited from
- * the gpg4usb project, which is under GPL-3.0-or-later.
- *
- * All the source code of GpgFrontend was modified and released by
- * Saturneric <[email protected]> starting on May 12, 2021.
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- */
-
-#pragma once
-
-#include <QObject>
-
-#include "SoftwareVersion.h"
-
-class QNetworkReply;
-class QNetworkAccessManager;
-
-/**
- * @brief
- *
- */
-class BKTUSVersionCheckTask : public QObject {
- Q_OBJECT
- public:
- /**
- * @brief Construct a new Version Check Thread object
- *
- */
- BKTUSVersionCheckTask();
-
- /**
- * @brief
- *
- * @return int
- */
- auto Run() -> int;
-
- signals:
-
- /**
- * @brief
- *
- * @param version
- */
- void SignalUpgradeVersion(SoftwareVersion version);
-
- private slots:
-
- /**
- * @brief
- *
- */
- void slot_parse_reply(QNetworkReply* reply);
-
- /**
- * @brief
- *
- * @param reply
- */
- void slot_parse_latest_version_info(QNetworkReply* reply);
-
- /**
- * @brief
- *
- * @param reply
- */
- void slot_parse_current_tag_info(QNetworkReply* reply);
-
- /**
- * @brief
- *
- * @param reply
- */
- void slot_parse_current_commit_hash_info(QNetworkReply* reply);
-
- private:
- QList<QNetworkReply*> replies_; ///<
- QNetworkAccessManager* network_manager_; ///<
- QString current_version_; ///<
- SoftwareVersion meta_;
-};
diff --git a/src/m_ver_check/CMakeLists.txt b/src/m_ver_check/CMakeLists.txt
index fa3369d..c3ab468 100644
--- a/src/m_ver_check/CMakeLists.txt
+++ b/src/m_ver_check/CMakeLists.txt
@@ -25,8 +25,23 @@
# Everything about this module -- id, version, name, description, author,
# capabilities and translation context -- lives in module.json beside this file.
+#
+# SOURCES is spelled out rather than left to aux_source_directory: the test
+# directory below compiles the Host-free half (ReleaseCheck, UpdateChecker,
+# UpdatePresentation, SoftwareVersion) on its own.
gf_add_module(
- NAME ver_check
- QT Core Network Widgets Xml
- UI_DIR ui
+ NAME ver_check
+ QT Core Network Widgets
+ SOURCES VersionCheckingModule.cpp
+ ReleaseCheck.cpp
+ SoftwareVersion.cpp
+ UpdateChecker.cpp
+ UpdatePresentation.cpp
+ UpdateTab.cpp
+ Utils.cpp
+ UI_DIR ui
LUA_SCRIPTS ui/main.lua)
+
+if(GPGFRONTEND_MODULES_BUILD_TESTS)
+ add_subdirectory(test)
+endif()
diff --git a/src/m_ver_check/GitHubVersionCheckTask.cpp b/src/m_ver_check/GitHubVersionCheckTask.cpp
deleted file mode 100644
index d2f8358..0000000
--- a/src/m_ver_check/GitHubVersionCheckTask.cpp
+++ /dev/null
@@ -1,200 +0,0 @@
-/**
- * Copyright (C) 2021-2024 Saturneric <[email protected]>
- *
- * This file is part of GpgFrontend.
- *
- * GpgFrontend is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * GpgFrontend is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
- *
- * The initial version of the source code is inherited from
- * the gpg4usb project, which is under GPL-3.0-or-later.
- *
- * All the source code of GpgFrontend was modified and released by
- * Saturneric <[email protected]> starting on May 12, 2021.
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- */
-
-#include "GitHubVersionCheckTask.h"
-
-#include <GFSDKLog.h>
-#include <qobject.h>
-
-#include <QMetaType>
-#include <QtNetwork>
-
-#include "GFModule.h"
-#include "SoftwareVersion.h"
-#include "Utils.h"
-
-GitHubVersionCheckTask::GitHubVersionCheckTask()
- : network_manager_(new QNetworkAccessManager(this)),
- current_version_(GFAppVersion(GFModuleSdkContext())) {
- qRegisterMetaType<SoftwareVersion>("SoftwareVersion");
- meta_.api = "GitHub";
- meta_.current_version = current_version_;
- meta_.local_commit_hash = GFAppGitCommitHash(GFModuleSdkContext());
-
- connect(this, &GitHubVersionCheckTask::SignalUpgradeVersion, this,
- [](const SoftwareVersion& sv) {
- gf::sdk::SetCacheText(
- GFModuleSdkContext(), GF_STORE_DURABLE, "update_checking_cache",
- (QJsonDocument(sv.ToJson()).toJson()).constData());
- });
-}
-
-auto GitHubVersionCheckTask::Run() -> int {
- QString base_url = "https://api.github.com/repos/saturneric/gpgfrontend";
- QList<QUrl> urls = {
- // fetch the release list (not /releases/latest): with parallel stable and
- // mainline tracks we must pick the newest release within the running
- // build's own series, which a single global "latest" cannot express.
- {base_url + "/releases?per_page=100"},
- {base_url + "/releases/tags/" + current_version_},
- {base_url + "/commits/" + meta_.local_commit_hash},
- };
-
- connect(network_manager_, &QNetworkAccessManager::finished, this,
- &GitHubVersionCheckTask::slot_parse_reply);
-
- int index = 0;
- for (const QUrl& url : urls) {
- QNetworkRequest request(url);
- // A check that never answers holds its task, and the tab waiting on it,
- // for the rest of the run.
- request.setTransferTimeout(30000);
- request.setHeader(QNetworkRequest::UserAgentHeader,
- GFAppUserAgent(GFModuleSdkContext()));
- QNetworkReply* reply = network_manager_->get(request);
- reply->setProperty("GFCheckIndex", index++);
- replies_.append(reply);
- }
-
- return 0;
-}
-
-void GitHubVersionCheckTask::slot_parse_reply(QNetworkReply* reply) {
- if (reply->error() == QNetworkReply::NoError) {
- auto index = reply->property("GFCheckIndex").toInt();
- FLOG_DEBUG("get reply from url: %1, index: %2", reply->url().toString(),
- index);
- switch (index) {
- case 0:
- slot_parse_latest_version_info(reply);
- break;
- case 1:
- slot_parse_current_version_info(reply);
- break;
- case 2:
- slot_parse_current_commit_info(reply);
- break;
- default:
- break;
- }
- } else {
- FLOG_DEBUG("get reply from url: %1, error: %2 %3", reply->url().toString(),
- reply->errorString(), reply->readAll());
- }
-
- replies_.removeAll(reply);
- reply->deleteLater();
-
- if (replies_.isEmpty()) {
- meta_.timestamp = QDateTime::currentDateTime();
- FillGrtWithVersionInfo(meta_);
- emit SignalUpgradeVersion(meta_);
- }
-}
-
-void GitHubVersionCheckTask::slot_parse_latest_version_info(
- QNetworkReply* reply) {
- if (reply == nullptr || reply->error() != QNetworkReply::NoError) {
- return;
- }
-
- auto reply_bytes = reply->readAll();
- auto latest_reply_json = QJsonDocument::fromJson(reply_bytes);
-
- if (!latest_reply_json.isArray()) {
- FLOG_WARN("cannot parse data from github: %1", reply_bytes);
- return;
- }
-
- // pick the newest release that belongs to the same series as the running
- // build, so a stable user is never offered a mainline release and vice versa.
- for (const auto& value : latest_reply_json.array()) {
- if (!value.isObject()) continue;
- auto release = value.toObject();
-
- if (release["draft"].toBool()) continue;
-
- auto raw_tag = release["tag_name"].toString();
- auto version = ExtractVersionFromRawTag(raw_tag);
- if (version.isEmpty()) {
- FLOG_WARN("raw tag name from github: %1 cannot match regex rules",
- raw_tag);
- continue;
- }
-
- if (!SoftwareVersion::SameSeries(version, current_version_)) continue;
- if (!meta_.latest_version.isEmpty() &&
- GFCompareSoftwareVersion(version.toUtf8().constData(),
- meta_.latest_version.toUtf8().constData()) <=
- 0) {
- continue;
- }
-
- meta_.latest_version = version;
- meta_.publish_date = release["published_at"].toString();
- meta_.release_note = release["body"].toString();
- }
-
- if (meta_.latest_version.isEmpty()) {
- FLOG_WARN("no github release found in series of current version: %1",
- current_version_);
- }
-}
-
-void GitHubVersionCheckTask::slot_parse_current_version_info(
- QNetworkReply* reply) {
- if (reply == nullptr || reply->error() != QNetworkReply::NoError) return;
-
- auto reply_bytes = reply->readAll();
- auto current_reply_json = QJsonDocument::fromJson(reply_bytes);
-
- if (!current_reply_json.isObject()) {
- FLOG_WARN("cannot parse data from github: %1", reply_bytes);
- return;
- }
-
- meta_.current_version_publish_in_remote = true;
-}
-
-void GitHubVersionCheckTask::slot_parse_current_commit_info(
- QNetworkReply* reply) {
- if (reply == nullptr || reply->error() != QNetworkReply::NoError) {
- return;
- }
-
- auto reply_bytes = reply->readAll();
- auto current_reply_json = QJsonDocument::fromJson(reply_bytes);
-
- if (!current_reply_json.isObject()) {
- FLOG_WARN("cannot parse data from github: %1", reply_bytes);
- return;
- }
-
- auto sha = current_reply_json["sha"].toString();
- meta_.current_commit_hash_publish_in_remote = sha == meta_.local_commit_hash;
-}
diff --git a/src/m_ver_check/GitHubVersionCheckTask.h b/src/m_ver_check/GitHubVersionCheckTask.h
deleted file mode 100644
index 615a5e8..0000000
--- a/src/m_ver_check/GitHubVersionCheckTask.h
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * Copyright (C) 2021-2024 Saturneric <[email protected]>
- *
- * This file is part of GpgFrontend.
- *
- * GpgFrontend is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * GpgFrontend is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
- *
- * The initial version of the source code is inherited from
- * the gpg4usb project, which is under GPL-3.0-or-later.
- *
- * All the source code of GpgFrontend was modified and released by
- * Saturneric <[email protected]> starting on May 12, 2021.
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- */
-
-#pragma once
-
-#include <QObject>
-
-#include "SoftwareVersion.h"
-
-class QNetworkReply;
-class QNetworkAccessManager;
-
-/**
- * @brief
- *
- */
-class GitHubVersionCheckTask : public QObject {
- Q_OBJECT
- public:
- /**
- * @brief Construct a new Version Check Thread object
- *
- */
- GitHubVersionCheckTask();
-
- /**
- * @brief
- *
- * @return int
- */
- auto Run() -> int;
-
- signals:
-
- /**
- * @brief
- *
- * @param version
- */
- void SignalUpgradeVersion(SoftwareVersion version);
-
- private slots:
-
- /**
- * @brief
- *
- */
- void slot_parse_reply(QNetworkReply* reply);
-
- /**
- * @brief
- *
- * @param reply
- */
- void slot_parse_latest_version_info(QNetworkReply* reply);
-
- /**
- * @brief
- *
- * @param reply
- */
- void slot_parse_current_version_info(QNetworkReply* reply);
-
- /**
- * @brief
- *
- * @param reply
- */
- void slot_parse_current_commit_info(QNetworkReply* reply);
-
- private:
- QList<QNetworkReply*> replies_; ///<
- QNetworkAccessManager* network_manager_; ///<
- QString current_version_; ///<
- SoftwareVersion meta_;
-};
diff --git a/src/m_ver_check/ReleaseCheck.cpp b/src/m_ver_check/ReleaseCheck.cpp
new file mode 100644
index 0000000..ed9e799
--- /dev/null
+++ b/src/m_ver_check/ReleaseCheck.cpp
@@ -0,0 +1,181 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include "ReleaseCheck.h"
+
+#include <GFSDKApp.h>
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QRegularExpression>
+
+namespace {
+
+auto Compare(const QString& a, const QString& b) -> int {
+ return GFCompareSoftwareVersion(a.toUtf8().constData(),
+ b.toUtf8().constData());
+}
+
+auto ParseObject(const QByteArray& body) -> std::optional<QJsonObject> {
+ const auto doc = QJsonDocument::fromJson(body);
+ if (!doc.isObject()) return std::nullopt;
+ return doc.object();
+}
+
+} // namespace
+
+auto ExtractVersionFromRawTag(const QString& raw_tag) -> QString {
+ static const QRegularExpression kVersionRe(
+ R"(^[vV](\d+\.)?(\d+\.)?(\*|\d+))");
+ auto match = kVersionRe.match(raw_tag);
+ return match.hasMatch() ? match.captured(0) : QString{};
+}
+
+auto IsPrereleaseVersion(const QString& version) -> bool {
+ static const QRegularExpression kSuffixRe(R"(^[vV]?\d+(\.\d+)*[-+~_a-zA-Z])");
+ return kSuffixRe.match(version).hasMatch();
+}
+
+auto ParseReleaseList(int http_status, const QByteArray& body,
+ const QString& current) -> ListResult {
+ ListResult result;
+ if (http_status != 200) return result;
+
+ const auto doc = QJsonDocument::fromJson(body);
+ if (!doc.isArray()) return result;
+
+ const bool accept_prerelease = IsPrereleaseVersion(current);
+ result.outcome = ListOutcome::kNoMatch;
+
+ for (const auto& value : doc.array()) {
+ if (!value.isObject()) continue;
+ const auto release = value.toObject();
+
+ if (release["draft"].toBool()) continue;
+ if (release["prerelease"].toBool() && !accept_prerelease) continue;
+
+ const auto version =
+ ExtractVersionFromRawTag(release["tag_name"].toString());
+ if (version.isEmpty()) continue;
+ if (!SoftwareVersion::SameSeries(version, current)) continue;
+
+ if (result.outcome == ListOutcome::kFound &&
+ Compare(version, result.latest.version) <= 0) {
+ continue;
+ }
+
+ result.outcome = ListOutcome::kFound;
+ result.latest.version = version;
+ result.latest.published_at = release["published_at"].toString();
+ result.latest.notes = release["body"].toString();
+ result.latest.html_url = release["html_url"].toString();
+ }
+
+ return result;
+}
+
+auto ClassifyLookup(int http_status, bool body_valid) -> RemoteFact {
+ if (http_status == 200) {
+ return body_valid ? RemoteFact::kConfirmed : RemoteFact::kUnknown;
+ }
+ if (http_status == 404) return RemoteFact::kNotFound;
+ return RemoteFact::kUnknown;
+}
+
+auto ParseTagLookup(int http_status, const QByteArray& body) -> RemoteFact {
+ const auto obj = http_status == 200 ? ParseObject(body) : std::nullopt;
+ const bool valid = obj && !obj->value("tag_name").toString().isEmpty();
+ return ClassifyLookup(http_status, valid);
+}
+
+auto ParseCommitLookup(int http_status, const QByteArray& body,
+ const QString& local_commit) -> RemoteFact {
+ const auto commit = local_commit.trimmed();
+ if (commit.isEmpty()) return RemoteFact::kUnknown;
+
+ if (http_status == 422) {
+ const auto obj = ParseObject(body);
+ const bool no_commit =
+ obj && obj->value("message").toString().startsWith("No commit found");
+ return no_commit ? RemoteFact::kNotFound : RemoteFact::kUnknown;
+ }
+
+ // GitHub resolves a short SHA to the full one, so the answer must start
+ // with ours; any other SHA answers a different question.
+ const auto obj = http_status == 200 ? ParseObject(body) : std::nullopt;
+ const bool valid = obj && obj->value("sha").toString().startsWith(
+ commit, Qt::CaseInsensitive);
+ return ClassifyLookup(http_status, valid);
+}
+
+auto IsUsable(const SoftwareVersion& r) -> bool {
+ return r.list.outcome != ListOutcome::kFailed;
+}
+
+auto IsComplete(const SoftwareVersion& fresh) -> bool {
+ return IsUsable(fresh) && fresh.tag_fact != RemoteFact::kUnknown &&
+ fresh.commit_fact != RemoteFact::kUnknown;
+}
+
+auto IsAuthoritative(const SoftwareVersion& r) -> bool {
+ return IsUsable(r) && r.complete;
+}
+
+auto Merge(const std::optional<SoftwareVersion>& prev,
+ const SoftwareVersion& fresh) -> std::optional<SoftwareVersion> {
+ if (!IsUsable(fresh)) return prev;
+
+ auto merged = fresh;
+ if (prev && prev->SameBuild(fresh.current_version, fresh.local_commit_hash)) {
+ if (merged.tag_fact == RemoteFact::kUnknown) {
+ merged.tag_fact = prev->tag_fact;
+ }
+ if (merged.commit_fact == RemoteFact::kUnknown) {
+ merged.commit_fact = prev->commit_fact;
+ }
+ }
+ return merged;
+}
+
+auto Decide(const SoftwareVersion& r) -> Verdict {
+ if (!IsUsable(r)) return Verdict::kUnknown;
+ if (r.list.outcome == ListOutcome::kFound &&
+ Compare(r.current_version, r.list.latest.version) < 0) {
+ return Verdict::kUpdateAvailable;
+ }
+ if (r.tag_fact == RemoteFact::kNotFound) {
+ return Verdict::kWithdrawnOrUnreleased;
+ }
+ if (r.commit_fact == RemoteFact::kNotFound) return Verdict::kUnofficialBuild;
+ return Verdict::kUpToDate;
+}
+
+auto ShouldPromptAtStartup(Verdict v) -> bool {
+ return v == Verdict::kUpdateAvailable;
+}
diff --git a/src/m_ver_check/ReleaseCheck.h b/src/m_ver_check/ReleaseCheck.h
new file mode 100644
index 0000000..696e7fe
--- /dev/null
+++ b/src/m_ver_check/ReleaseCheck.h
@@ -0,0 +1,123 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+#include <optional>
+
+#include "SoftwareVersion.h"
+
+/**
+ * @file ReleaseCheck.h
+ * @brief Everything an update check decides, with no network and no Host.
+ *
+ * Parsing the release server's answers and turning a result into a verdict
+ * live here so they can be tested on their own; UpdateChecker only moves
+ * bytes and keeps state.
+ */
+
+/// What a result means for the user, in precedence order (see Decide()).
+enum class Verdict {
+ kUnknown, ///< the release list could not be read
+ kUpdateAvailable, ///< a newer release exists in this build's series
+ kWithdrawnOrUnreleased, ///< this version has no release (confirmed 404)
+ kUnofficialBuild, ///< this commit is not in the repository (confirmed)
+ kUpToDate, ///< nothing newer, and nothing known to be wrong
+};
+
+/**
+ * @brief Extract a normalized "vX.Y.Z" version string from a raw release tag or
+ * title. Returns an empty string when the raw text does not match.
+ */
+auto ExtractVersionFromRawTag(const QString& raw_tag) -> QString;
+
+/// A version with a suffix after its numbers ("v2.2.0-rc1") is a prerelease.
+auto IsPrereleaseVersion(const QString& version) -> bool;
+
+/**
+ * @brief The newest release in @p current's series from a GitHub release list.
+ *
+ * Drafts are skipped, and so are prereleases unless @p current is one itself.
+ * @param http_status 0 when the request never got an HTTP answer
+ */
+auto ParseReleaseList(int http_status, const QByteArray& body,
+ const QString& current) -> ListResult;
+
+/// 200 with a body that says what was asked -> confirmed; 404 -> not found;
+/// anything else says nothing.
+auto ClassifyLookup(int http_status, bool body_valid) -> RemoteFact;
+
+/// `GET /releases/tags/<tag>`: is this version released?
+auto ParseTagLookup(int http_status, const QByteArray& body) -> RemoteFact;
+
+/// `GET /commits/<sha>`: is this commit in the repository? GitHub answers a
+/// well-formed but unknown SHA with 422 "No commit found", which is as
+/// definite as a 404.
+auto ParseCommitLookup(int http_status, const QByteArray& body,
+ const QString& local_commit) -> RemoteFact;
+
+/// The release list came back: the result can be shown and kept.
+auto IsUsable(const SoftwareVersion& r) -> bool;
+
+/// Usable, and its own check answered every question: good enough to skip
+/// the next startup check.
+auto IsAuthoritative(const SoftwareVersion& r) -> bool;
+
+/// Whether a fresh check answered every question itself.
+auto IsComplete(const SoftwareVersion& fresh) -> bool;
+
+/**
+ * @brief The new last-known-good result.
+ *
+ * An unusable @p fresh leaves @p prev as it was. A usable one replaces it,
+ * except that a fact @p fresh could not establish keeps @p prev's answer when
+ * both describe the same build. `complete` stays @p fresh's own: an inherited
+ * fact fills the picture, it does not make this check authoritative.
+ */
+auto Merge(const std::optional<SoftwareVersion>& prev,
+ const SoftwareVersion& fresh) -> std::optional<SoftwareVersion>;
+
+/**
+ * @brief The verdict, first match wins:
+ *
+ * 1. release list unusable -> kUnknown
+ * 2. a newer release in this series -> kUpdateAvailable
+ * 3. tag lookup kNotFound -> kWithdrawnOrUnreleased
+ * 4. commit lookup kNotFound -> kUnofficialBuild
+ * 5. otherwise -> kUpToDate
+ *
+ * An update comes first because installing it also answers 3 and 4. Only a
+ * confirmed kNotFound reaches 3 or 4: a lookup that failed asserts nothing.
+ */
+auto Decide(const SoftwareVersion& r) -> Verdict;
+
+/// Only a confirmed update interrupts startup; everything else waits for the
+/// user to open the dialog.
+auto ShouldPromptAtStartup(Verdict v) -> bool;
diff --git a/src/m_ver_check/SoftwareVersion.cpp b/src/m_ver_check/SoftwareVersion.cpp
index ed74cb3..2184361 100644
--- a/src/m_ver_check/SoftwareVersion.cpp
+++ b/src/m_ver_check/SoftwareVersion.cpp
@@ -28,42 +28,102 @@
#include "SoftwareVersion.h"
-#include <GFModule.h>
-#include <GFSDKLog.h>
-
-#include <QJsonDocument>
#include <QJsonObject>
#include <QString>
+#include <array>
+#include <utility>
+
+namespace {
+
+// Names, not enum values, on disk: a reordered enum must not flip a cached
+// "not found" into "confirmed".
+constexpr std::array<std::pair<RemoteFact, const char*>, 3> kFactNames = {{
+ {RemoteFact::kUnknown, "unknown"},
+ {RemoteFact::kConfirmed, "confirmed"},
+ {RemoteFact::kNotFound, "not_found"},
+}};
+
+constexpr std::array<std::pair<ListOutcome, const char*>, 3> kListNames = {{
+ {ListOutcome::kFailed, "failed"},
+ {ListOutcome::kNoMatch, "no_match"},
+ {ListOutcome::kFound, "found"},
+}};
-auto SoftwareVersion::NeedUpgrade() const -> bool {
- return !latest_version.isEmpty() &&
- GFCompareSoftwareVersion(current_version.toUtf8().constData(),
- latest_version.toUtf8().constData()) < 0;
+template <typename Enum, std::size_t N>
+auto NameOf(const std::array<std::pair<Enum, const char*>, N>& names,
+ Enum value) -> QString {
+ for (const auto& [e, name] : names) {
+ if (e == value) return QString::fromLatin1(name);
+ }
+ return QString::fromLatin1(names.front().second);
}
-auto SoftwareVersion::VersionWithdrawn() const -> bool {
- return !latest_version.isEmpty() && !current_version_publish_in_remote;
+template <typename Enum, std::size_t N>
+auto ValueOf(const std::array<std::pair<Enum, const char*>, N>& names,
+ const QString& name) -> Enum {
+ for (const auto& [e, n] : names) {
+ if (name == QLatin1String(n)) return e;
+ }
+ return names.front().first;
}
-auto SoftwareVersion::CurrentVersionReleased() const -> bool {
- return !latest_version.isEmpty() && current_version_publish_in_remote;
+} // namespace
+
+auto SoftwareVersion::SameBuild(const QString& version,
+ const QString& commit) const -> bool {
+ return current_version == version && local_commit_hash == commit;
}
auto SoftwareVersion::ToJson() const -> QJsonObject {
+ QJsonObject latest;
+ latest["version"] = list.latest.version;
+ latest["published_at"] = list.latest.published_at;
+ latest["notes"] = list.latest.notes;
+ latest["html_url"] = list.latest.html_url;
+
QJsonObject obj;
- obj["api"] = api;
- obj["latest_version"] = latest_version;
+ obj["schema"] = kSchema;
obj["current_version"] = current_version;
- obj["current_version_publish_in_remote"] = current_version_publish_in_remote;
- obj["current_commit_hash_publish_in_remote"] =
- current_commit_hash_publish_in_remote;
- obj["publish_date"] = publish_date;
- obj["release_note"] = release_note;
obj["local_commit_hash"] = local_commit_hash;
- obj["timestamp"] = timestamp.toSecsSinceEpoch();
+ obj["list"] = NameOf(kListNames, list.outcome);
+ obj["latest"] = latest;
+ obj["tag_fact"] = NameOf(kFactNames, tag_fact);
+ obj["commit_fact"] = NameOf(kFactNames, commit_fact);
+ obj["complete"] = complete;
+ obj["checked_at"] = checked_at.toSecsSinceEpoch();
return obj;
}
+auto SoftwareVersion::FromJson(const QJsonObject& obj)
+ -> std::optional<SoftwareVersion> {
+ if (obj.value("schema").toInt() != kSchema) return std::nullopt;
+ if (!obj.value("checked_at").isDouble()) return std::nullopt;
+
+ SoftwareVersion sv;
+ sv.current_version = obj.value("current_version").toString();
+ sv.local_commit_hash = obj.value("local_commit_hash").toString();
+ sv.list.outcome = ValueOf(kListNames, obj.value("list").toString());
+
+ const auto latest = obj.value("latest").toObject();
+ sv.list.latest.version = latest.value("version").toString();
+ sv.list.latest.published_at = latest.value("published_at").toString();
+ sv.list.latest.notes = latest.value("notes").toString();
+ sv.list.latest.html_url = latest.value("html_url").toString();
+
+ sv.tag_fact = ValueOf(kFactNames, obj.value("tag_fact").toString());
+ sv.commit_fact = ValueOf(kFactNames, obj.value("commit_fact").toString());
+ sv.complete = obj.value("complete").toBool();
+ sv.checked_at =
+ QDateTime::fromSecsSinceEpoch(obj.value("checked_at").toInteger());
+
+ // A "found" list without a version is not something ToJson() writes.
+ if (sv.list.outcome == ListOutcome::kFound &&
+ sv.list.latest.version.isEmpty()) {
+ return std::nullopt;
+ }
+ return sv;
+}
+
auto SoftwareVersion::VersionSeries(const QString& version) -> QString {
if (version.isEmpty()) return {};
@@ -87,17 +147,3 @@ auto SoftwareVersion::SameSeries(const QString& a, const QString& b) -> bool {
auto series_a = VersionSeries(a);
return !series_a.isEmpty() && series_a == VersionSeries(b);
}
-
-void SoftwareVersion::FromJson(const QJsonObject& obj) {
- api = obj.value("api").toString();
- latest_version = obj.value("latest_version").toString();
- current_version = obj.value("current_version").toString();
- current_version_publish_in_remote =
- obj.value("current_version_publish_in_remote").toBool();
- current_commit_hash_publish_in_remote =
- obj.value("current_commit_hash_publish_in_remote").toBool();
- publish_date = obj.value("publish_date").toString();
- release_note = obj.value("release_note").toString();
- local_commit_hash = obj.value("local_commit_hash").toString();
- timestamp = QDateTime::fromSecsSinceEpoch(obj.value("timestamp").toInt());
-}
diff --git a/src/m_ver_check/SoftwareVersion.h b/src/m_ver_check/SoftwareVersion.h
index af5ec37..5a0245d 100644
--- a/src/m_ver_check/SoftwareVersion.h
+++ b/src/m_ver_check/SoftwareVersion.h
@@ -29,75 +29,66 @@
#pragma once
#include <QDateTime>
-#include <QJsonDocument>
+#include <QJsonObject>
#include <QString>
+#include <optional>
/**
- * @brief
+ * @brief What one lookup established about this build on the release server.
*
+ * Three states, not a bool: a lookup that could not be made (offline, rate
+ * limited, a server error) says nothing, and must never read as "not found".
*/
-struct SoftwareVersion {
- QString api;
- QString latest_version; ///<
- QString current_version; ///<
+enum class RemoteFact { kUnknown, kConfirmed, kNotFound };
- bool current_version_publish_in_remote = false; ///<
- bool current_commit_hash_publish_in_remote = false; ///<
+/**
+ * @brief How the release list turned out. A valid list with no release in
+ * this build's series is an answer (kNoMatch), not a failure.
+ */
+enum class ListOutcome { kFailed, kNoMatch, kFound };
- QString publish_date; ///<
- QString release_note; ///<
- QString local_commit_hash;
+struct LatestRelease {
+ QString version; ///< normalized, e.g. "v2.1.9"
+ QString published_at; ///< as the server wrote it
+ QString notes; ///< markdown
+ QString html_url; ///< the release page
+};
- QDateTime timestamp;
+struct ListResult {
+ ListOutcome outcome = ListOutcome::kFailed;
+ LatestRelease latest; ///< meaningful only for kFound
+};
- /**
- * @brief
- *
- * @return true
- * @return false
- */
- [[nodiscard]] auto IsInfoValid() const -> bool {
- return !latest_version.isEmpty();
- }
+/**
+ * @brief One update check, for one build.
+ *
+ * `complete` is set when the check that produced this result answered every
+ * question itself. A result can be usable (the release list came back) while
+ * incomplete (a lookup failed); only a complete one is trusted enough to skip
+ * the next startup check.
+ */
+struct SoftwareVersion {
+ static constexpr int kSchema = 2;
- /**
- * @brief
- *
- * @return true
- * @return false
- */
- [[nodiscard]] auto NeedUpgrade() const -> bool;
+ QString current_version;
+ QString local_commit_hash;
- /**
- * @brief
- *
- * @return true
- * @return false
- */
- [[nodiscard]] auto VersionWithdrawn() const -> bool;
+ ListResult list;
+ RemoteFact tag_fact = RemoteFact::kUnknown;
+ RemoteFact commit_fact = RemoteFact::kUnknown;
+ bool complete = false;
- /**
- * @brief
- *
- * @return true
- * @return false
- */
- [[nodiscard]] auto CurrentVersionReleased() const -> bool;
+ QDateTime checked_at;
+
+ /// Whether this result describes the given build.
+ [[nodiscard]] auto SameBuild(const QString& version,
+ const QString& commit) const -> bool;
- /**
- * @brief
- *
- * @return QJsonDocument
- */
[[nodiscard]] auto ToJson() const -> QJsonObject;
- /**
- * @brief
- *
- * @param obj
- * @return auto
- */
- void FromJson(const QJsonObject& obj);
+ /// @return nothing for a malformed object or one from an older schema
+ [[nodiscard]] static auto FromJson(const QJsonObject& obj)
+ -> std::optional<SoftwareVersion>;
/**
* @brief Return the release series ("channel") a version belongs to, i.e. its
@@ -116,7 +107,4 @@ struct SoftwareVersion {
*/
[[nodiscard]] static auto SameSeries(const QString& a, const QString& b)
-> bool;
-
- private:
- static auto version_compare(const QString& a, const QString& b) -> int;
};
diff --git a/src/m_ver_check/UpdateChecker.cpp b/src/m_ver_check/UpdateChecker.cpp
new file mode 100644
index 0000000..82f62b3
--- /dev/null
+++ b/src/m_ver_check/UpdateChecker.cpp
@@ -0,0 +1,162 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include "UpdateChecker.h"
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QPointer>
+
+#include "ReleaseCheck.h"
+
+namespace {
+
+constexpr int kStorageSchema = 2;
+
+} // namespace
+
+UpdateChecker::UpdateChecker(Build build, Fetcher fetch, Store store,
+ Clock clock, QObject* parent)
+ : QObject(parent),
+ build_(std::move(build)),
+ fetch_(std::move(fetch)),
+ store_(std::move(store)),
+ clock_(clock ? std::move(clock)
+ : [] { return QDateTime::currentDateTime(); }) {
+ if (store_.load) state_ = FromStorage(store_.load(), build_);
+}
+
+auto UpdateChecker::State() const -> const UpdateSnapshot& { return state_; }
+
+auto UpdateChecker::IsFresh() const -> bool {
+ const auto& good = state_.last_good;
+ return good && IsAuthoritative(*good) &&
+ good->SameBuild(build_.version, build_.commit) &&
+ good->checked_at.secsTo(clock_()) < kFreshSeconds;
+}
+
+void UpdateChecker::Start(CheckMode mode) {
+ if (state_.checking) return;
+ if (mode == CheckMode::kIfStale && IsFresh()) return;
+
+ fresh_ = SoftwareVersion{};
+ fresh_.current_version = build_.version;
+ fresh_.local_commit_hash = build_.commit;
+
+ const bool ask_commit = !build_.commit.trimmed().isEmpty();
+ // Counted before anything is sent: a fetcher may answer synchronously.
+ pending_ = ask_commit ? 3 : 2;
+ state_.checking = true;
+ emit Changed();
+
+ const QString base = QString::fromLatin1(kRepoApi);
+ // Replies can outlive the checker: the module deletes it on deactivation.
+ QPointer<UpdateChecker> self(this);
+
+ // The list, not /releases/latest: with parallel stable and mainline tracks
+ // the newest release in the running build's own series is wanted, which a
+ // single global "latest" cannot express.
+ fetch_(QUrl(base + "/releases?per_page=100"), [self](int status,
+ const QByteArray& body) {
+ if (self.isNull()) return;
+ self->fresh_.list = ParseReleaseList(status, body, self->build_.version);
+ self->finish();
+ });
+
+ fetch_(QUrl(base + "/releases/tags/" + build_.version),
+ [self](int status, const QByteArray& body) {
+ if (self.isNull()) return;
+ self->fresh_.tag_fact = ParseTagLookup(status, body);
+ self->finish();
+ });
+
+ if (ask_commit) {
+ fetch_(QUrl(base + "/commits/" + build_.commit.trimmed()),
+ [self](int status, const QByteArray& body) {
+ if (self.isNull()) return;
+ self->fresh_.commit_fact =
+ ParseCommitLookup(status, body, self->build_.commit);
+ self->finish();
+ });
+ }
+}
+
+void UpdateChecker::finish() {
+ if (--pending_ > 0) return;
+
+ const auto now = clock_();
+ fresh_.checked_at = now;
+ fresh_.complete = IsComplete(fresh_);
+
+ state_.checking = false;
+ state_.last_attempt = now;
+ state_.last_attempt_failed = !IsUsable(fresh_);
+ state_.last_good = Merge(state_.last_good, fresh_);
+
+ if (store_.save) store_.save(ToStorage(state_));
+ emit Changed();
+}
+
+auto UpdateChecker::ToStorage(const UpdateSnapshot& s) -> QByteArray {
+ QJsonObject obj;
+ obj["schema"] = kStorageSchema;
+ if (s.last_good) obj["last_good"] = s.last_good->ToJson();
+ if (s.last_attempt.isValid()) {
+ obj["last_attempt"] = s.last_attempt.toSecsSinceEpoch();
+ }
+ obj["last_attempt_failed"] = s.last_attempt_failed;
+ return QJsonDocument(obj).toJson(QJsonDocument::Compact);
+}
+
+auto UpdateChecker::FromStorage(const QByteArray& data, const Build& build)
+ -> UpdateSnapshot {
+ UpdateSnapshot s;
+ const auto doc = QJsonDocument::fromJson(data);
+ if (!doc.isObject()) return s;
+
+ const auto obj = doc.object();
+ if (obj.value("schema").toInt() != kStorageSchema) return s;
+
+ auto good = SoftwareVersion::FromJson(obj.value("last_good").toObject());
+ // What was learned about another build says nothing about this one, and
+ // neither does how asking about it went.
+ if (!good || !good->SameBuild(build.version, build.commit)) return s;
+
+ s.last_good = std::move(good);
+ if (obj.value("last_attempt").isDouble()) {
+ s.last_attempt =
+ QDateTime::fromSecsSinceEpoch(obj.value("last_attempt").toInteger());
+ }
+ s.last_attempt_failed = obj.value("last_attempt_failed").toBool();
+ return s;
+}
+
+auto ShouldPromptOnStartup(const UpdateSnapshot& s) -> bool {
+ return !s.checking && s.last_good &&
+ ShouldPromptAtStartup(Decide(*s.last_good));
+}
diff --git a/src/m_ver_check/UpdateChecker.h b/src/m_ver_check/UpdateChecker.h
new file mode 100644
index 0000000..51a3ede
--- /dev/null
+++ b/src/m_ver_check/UpdateChecker.h
@@ -0,0 +1,124 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#pragma once
+
+#include <QByteArray>
+#include <QDateTime>
+#include <QObject>
+#include <QUrl>
+#include <functional>
+#include <optional>
+
+#include "SoftwareVersion.h"
+
+/**
+ * @brief Everything the update UI needs to know, readable at any moment.
+ *
+ * A failed attempt never replaces what was known: `last_good` is the last
+ * result whose release list came back, and the attempt fields say how the
+ * most recent try went.
+ */
+struct UpdateSnapshot {
+ bool checking = false;
+ std::optional<SoftwareVersion> last_good;
+ QDateTime last_attempt;
+ bool last_attempt_failed = false;
+};
+
+enum class CheckMode {
+ kIfStale, ///< skip when a recent authoritative result for this build exists
+ kForce, ///< always ask the server (the user pressed the button)
+};
+
+/**
+ * @brief The one update check of the process.
+ *
+ * Every caller shares it: a Start() while a check is running joins that check
+ * instead of starting another. The transport, the store and the clock are
+ * handed in, so the whole state machine runs in a test without a network, a
+ * Host or an event loop.
+ */
+class UpdateChecker : public QObject {
+ Q_OBJECT
+
+ public:
+ /// Answers a GET: the HTTP status (0 when there was none) and the body.
+ using Reply = std::function<void(int http_status, const QByteArray& body)>;
+ using Fetcher = std::function<void(const QUrl& url, Reply reply)>;
+ using Clock = std::function<QDateTime()>;
+
+ struct Store {
+ std::function<QByteArray()> load;
+ std::function<void(const QByteArray&)> save;
+ };
+
+ struct Build {
+ QString version;
+ QString commit;
+ };
+
+ static constexpr qint64 kFreshSeconds = 24 * 60 * 60;
+ static constexpr auto kRepoApi =
+ "https://api.github.com/repos/saturneric/gpgfrontend";
+
+ UpdateChecker(Build build, Fetcher fetch, Store store, Clock clock = {},
+ QObject* parent = nullptr);
+
+ [[nodiscard]] auto State() const -> const UpdateSnapshot&;
+
+ /// Starts a check, joins the running one, or (kIfStale) does nothing.
+ void Start(CheckMode mode);
+
+ /// A recent authoritative result for this build: no startup check needed.
+ [[nodiscard]] auto IsFresh() const -> bool;
+
+ /// The state as stored; FromStorage() reads it back for @p build only.
+ [[nodiscard]] static auto ToStorage(const UpdateSnapshot& s) -> QByteArray;
+ [[nodiscard]] static auto FromStorage(const QByteArray& data,
+ const Build& build) -> UpdateSnapshot;
+
+ signals:
+ /// Any change of State(), including the start of a check.
+ void Changed();
+
+ private:
+ void finish();
+
+ Build build_;
+ Fetcher fetch_;
+ Store store_;
+ Clock clock_;
+
+ UpdateSnapshot state_;
+ SoftwareVersion fresh_;
+ int pending_ = 0;
+};
+
+/// Whether the startup check should open the update dialog now.
+auto ShouldPromptOnStartup(const UpdateSnapshot& s) -> bool;
diff --git a/src/m_ver_check/UpdatePresentation.cpp b/src/m_ver_check/UpdatePresentation.cpp
new file mode 100644
index 0000000..d60e507
--- /dev/null
+++ b/src/m_ver_check/UpdatePresentation.cpp
@@ -0,0 +1,154 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include "UpdatePresentation.h"
+
+#include <QCoreApplication>
+#include <QLocale>
+
+#include "ReleaseCheck.h"
+
+// Every string here uses the literal context "UpdateTab", shared with the
+// dialog's own tr(): lupdate files them together, and a named constant would
+// hide them from it.
+
+auto RelativeTime(const QDateTime& then, const QDateTime& now) -> QString {
+ const auto minutes = then.secsTo(now) / 60;
+ if (minutes < 1) return QCoreApplication::translate("UpdateTab", "just now");
+
+ const int m = static_cast<int>(minutes);
+ if (minutes < 60) {
+ return QCoreApplication::translate("UpdateTab", "%n minute(s) ago", "", m);
+ }
+
+ const int h = static_cast<int>(minutes / 60);
+ if (h < 24) {
+ return QCoreApplication::translate("UpdateTab", "%n hour(s) ago", "", h);
+ }
+
+ return QLocale().toString(then, QLocale::ShortFormat);
+}
+
+auto PresentUpdate(const UpdateSnapshot& s, const QDateTime& now)
+ -> UpdateView {
+ UpdateView v;
+ v.busy = s.checking;
+ v.check_enabled = !s.checking;
+
+ if (!s.last_good) {
+ if (s.checking) {
+ v.headline =
+ QCoreApplication::translate("UpdateTab", "Checking for updatesโ€ฆ");
+ v.check_label = QCoreApplication::translate("UpdateTab", "Check now");
+ } else if (s.last_attempt_failed) {
+ v.headline = QCoreApplication::translate("UpdateTab",
+ "Couldn't check for updates");
+ v.detail =
+ QCoreApplication::translate("UpdateTab", "Please try again later.");
+ v.check_label = QCoreApplication::translate("UpdateTab", "Try again");
+ } else {
+ v.headline =
+ QCoreApplication::translate("UpdateTab", "No update information yet");
+ v.detail = QCoreApplication::translate(
+ "UpdateTab",
+ "Check now to see whether a newer GpgFrontend is available.");
+ v.check_label = QCoreApplication::translate("UpdateTab", "Check now");
+ }
+ return v;
+ }
+
+ const auto& r = *s.last_good;
+ const auto& latest = r.list.latest.version;
+
+ switch (Decide(r)) {
+ case Verdict::kUpdateAvailable:
+ v.tone = UpdateTone::kAttention;
+ v.headline = QCoreApplication::translate("UpdateTab",
+ "GpgFrontend %1 is available")
+ .arg(latest);
+ v.detail = QCoreApplication::translate("UpdateTab", "You are using %1.")
+ .arg(r.current_version);
+ v.show_download = true;
+ v.download_version = latest;
+ v.download_label =
+ QCoreApplication::translate("UpdateTab", "Download %1").arg(latest);
+ v.notes = r.list.latest.notes.trimmed();
+ v.show_notes = !v.notes.isEmpty();
+ break;
+ case Verdict::kWithdrawnOrUnreleased:
+ v.tone = UpdateTone::kAttention;
+ v.headline = QCoreApplication::translate(
+ "UpdateTab", "This version isn't listed on GitHub Releases");
+ v.detail = QCoreApplication::translate(
+ "UpdateTab",
+ "It may be a pre-release build or a withdrawn version. "
+ "The latest official release is recommended.");
+ break;
+ case Verdict::kUnofficialBuild:
+ v.tone = UpdateTone::kAttention;
+ v.headline = QCoreApplication::translate(
+ "UpdateTab", "This build isn't from the official repository");
+ v.detail = QCoreApplication::translate(
+ "UpdateTab",
+ "Its source commit wasn't found upstream. That is expected "
+ "for self-built or modified versions.");
+ break;
+ case Verdict::kUpToDate:
+ v.tone = UpdateTone::kGood;
+ v.headline =
+ QCoreApplication::translate("UpdateTab", "You're up to date");
+ v.detail = r.list.outcome == ListOutcome::kFound
+ ? QCoreApplication::translate("UpdateTab",
+ "%1 is the latest release.")
+ .arg(r.current_version)
+ : QCoreApplication::translate(
+ "UpdateTab", "No newer release was found for %1.")
+ .arg(r.current_version);
+ break;
+ case Verdict::kUnknown:
+ // last_good is usable by construction; kept for completeness.
+ v.headline = QCoreApplication::translate("UpdateTab",
+ "Couldn't check for updates");
+ break;
+ }
+
+ v.freshness = QCoreApplication::translate("UpdateTab",
+ "Last checked %1 ยท GitHub Releases")
+ .arg(RelativeTime(r.checked_at, now));
+
+ if (s.last_attempt_failed && !s.checking) {
+ v.notice = QCoreApplication::translate(
+ "UpdateTab",
+ "Couldn't check for updates just now. Showing the last "
+ "known result.");
+ v.check_label = QCoreApplication::translate("UpdateTab", "Try again");
+ } else {
+ v.check_label = QCoreApplication::translate("UpdateTab", "Check again");
+ }
+ return v;
+}
diff --git a/src/m_ver_check/UpdatePresentation.h b/src/m_ver_check/UpdatePresentation.h
new file mode 100644
index 0000000..831db40
--- /dev/null
+++ b/src/m_ver_check/UpdatePresentation.h
@@ -0,0 +1,71 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#pragma once
+
+#include <QDateTime>
+#include <QString>
+
+#include "UpdateChecker.h"
+
+/**
+ * @file UpdatePresentation.h
+ * @brief What the update dialog shows, decided without a widget.
+ *
+ * The checker keeps a precise model; the user gets a headline, a line of
+ * detail and at most two buttons. Failure wording stays generic: the cause of
+ * a failed request (offline, rate limit, server) is not known for certain,
+ * so it is not guessed.
+ */
+
+enum class UpdateTone { kNeutral, kGood, kAttention };
+
+struct UpdateView {
+ UpdateTone tone = UpdateTone::kNeutral;
+ QString headline;
+ QString detail;
+ QString notice; ///< a refresh failed while older results are shown
+ QString freshness; ///< "Last checked ... ยท GitHub Releases"
+
+ bool busy = false;
+
+ bool show_download = false;
+ QString download_label;
+ QString download_version;
+
+ QString check_label; ///< "Check again", "Try again" or "Check now"
+ bool check_enabled = true;
+
+ bool show_notes = false;
+ QString notes; ///< markdown
+};
+
+auto PresentUpdate(const UpdateSnapshot& s, const QDateTime& now) -> UpdateView;
+
+/// "just now", "5 minutes ago", "3 hours ago", then the date.
+auto RelativeTime(const QDateTime& then, const QDateTime& now) -> QString;
diff --git a/src/m_ver_check/UpdateTab.cpp b/src/m_ver_check/UpdateTab.cpp
index 48d8148..bc998d4 100644
--- a/src/m_ver_check/UpdateTab.cpp
+++ b/src/m_ver_check/UpdateTab.cpp
@@ -29,226 +29,176 @@
#include "UpdateTab.h"
#include "GFModule.h"
+#include "UpdateChecker.h"
+#include "UpdatePresentation.h"
#include "VersionCheckingModule.h"
-//
-#include "BKTUSVersionCheckTask.h"
-#include "GitHubVersionCheckTask.h"
+namespace {
-UpdateTab::UpdateTab(QWidget* parent)
- : QWidget(parent), current_version_(GFAppVersion(GFModuleSdkContext())) {
- auto* layout = new QVBoxLayout();
-
- current_version_box_ = new QGroupBox(tr("Current Version Information"));
- auto* current_version_layout = new QVBoxLayout();
- current_version_label_ = new QLabel();
- current_version_label_->setText("<center>" + tr("Current Version") +
- tr(": ") + "<b>" + current_version_ +
- "</b></center>");
- current_version_label_->setWordWrap(true);
- latest_version_label_ = new QLabel();
- current_version_layout->addWidget(current_version_label_);
- current_version_layout->addWidget(latest_version_label_);
- current_version_box_->setLayout(current_version_layout);
-
- upgrade_info_box_ = new QGroupBox(tr("Upgrade Information"));
- auto* upgrade_info_layout = new QVBoxLayout();
- upgrade_label_ = new QLabel();
- upgrade_label_->setWordWrap(true);
- upgrade_label_->setOpenExternalLinks(true);
- upgrade_label_->setHidden(true);
-
- pb_ = new QProgressBar();
- pb_->setRange(0, 0);
- pb_->setTextVisible(false);
-
- upgrade_info_layout->addWidget(upgrade_label_);
- upgrade_info_box_->setLayout(upgrade_info_layout);
-
- check_update_btn_ = new QPushButton(tr("Check for Updates"));
- check_update_btn_->setIcon(QIcon::fromTheme("view-refresh"));
- check_update_btn_->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+constexpr auto kDownloadPage =
+ "https://www.gpgfrontend.bktus.com/overview/downloads/";
- release_note_box_ = new QGroupBox(tr("Release Notes"));
- auto* release_note_layout = new QVBoxLayout();
- release_note_viewer_ = new QTextEdit();
- release_note_viewer_->setReadOnly(true);
- release_note_viewer_->setAcceptRichText(true);
- release_note_viewer_->hide();
- release_note_layout->addWidget(release_note_viewer_);
- release_note_box_->setLayout(release_note_layout);
+auto ToneIcon(const QStyle* style, UpdateTone tone) -> QIcon {
+ switch (tone) {
+ case UpdateTone::kGood:
+ return style->standardIcon(QStyle::SP_DialogApplyButton);
+ case UpdateTone::kAttention:
+ return style->standardIcon(QStyle::SP_MessageBoxWarning);
+ case UpdateTone::kNeutral:
+ break;
+ }
+ return style->standardIcon(QStyle::SP_MessageBoxInformation);
+}
- current_version_box_->hide();
- release_note_box_->hide();
- upgrade_info_box_->hide();
+} // namespace
- layout->addWidget(current_version_box_);
- layout->addWidget(upgrade_info_box_);
- layout->addWidget(release_note_box_);
+UpdateTab::UpdateTab(QWidget* parent)
+ : QWidget(parent), checker_(VersionChecker()) {
+ icon_ = new QLabel(this);
+ icon_->setAlignment(Qt::AlignTop);
- auto* hbox = new QHBoxLayout();
- hbox->addWidget(pb_, 1);
- hbox->addWidget(check_update_btn_, 0, Qt::AlignRight);
- layout->addLayout(hbox);
+ headline_ = new QLabel(this);
+ headline_->setWordWrap(true);
+ auto headline_font = headline_->font();
+ headline_font.setBold(true);
+ headline_font.setPointSizeF(headline_font.pointSizeF() * 1.25);
+ headline_->setFont(headline_font);
- connect(check_update_btn_, &QPushButton::clicked, this,
- &UpdateTab::slot_check_version_update);
+ detail_ = new QLabel(this);
+ detail_->setWordWrap(true);
- setLayout(layout);
+ notice_ = new QLabel(this);
+ notice_->setWordWrap(true);
- slot_show_version_status();
-}
+ freshness_ = new QLabel(this);
+ freshness_->setWordWrap(true);
+ freshness_->setForegroundRole(QPalette::PlaceholderText);
+ auto small_font = freshness_->font();
+ small_font.setPointSizeF(small_font.pointSizeF() * 0.9);
+ freshness_->setFont(small_font);
-void UpdateTab::slot_show_version_status() {
- check_update_btn_->setEnabled(true);
- this->pb_->setHidden(true);
+ auto* text_column = new QVBoxLayout();
+ text_column->setSpacing(4);
+ text_column->addWidget(headline_);
+ text_column->addWidget(detail_);
+ text_column->addWidget(notice_);
+ text_column->addWidget(freshness_);
- auto is_loading_done = gf::sdk::StateBool(
- GFModuleSdkContext(), GFGetModuleID(), "version.loading_done", 0);
+ auto* header = new QHBoxLayout();
+ header->setSpacing(12);
+ header->addWidget(icon_, 0, Qt::AlignTop);
+ header->addLayout(text_column, 1);
- if (is_loading_done == 0) {
- MLogDebug("version info loading haven't been done yet.");
+ busy_ = new QProgressBar(this);
+ busy_->setRange(0, 0);
+ busy_->setTextVisible(false);
+ busy_->setMaximumHeight(6);
- upgrade_label_->setText(
- "<center>" +
- tr("Unable to retrieve the latest version information. This may be "
- "due to a network issue or the server being unavailable.") +
- "</center><center>" +
- tr("Please check your internet connection or try again later.") +
- "</center><center>" + tr("Alternatively, you can visit the") +
- " <a "
- "href=\"https://www.gpgfrontend.bktus.com/overview/downloads/\">" +
- tr("official download page") + "</a> " +
- tr("to check for the latest version.") + "</center>");
- upgrade_label_->show();
- upgrade_info_box_->show();
- return;
- }
+ check_btn_ = new QPushButton(this);
+ check_btn_->setIcon(QIcon::fromTheme("view-refresh"));
- auto is_need_upgrade = gf::sdk::StateBool(
- GFModuleSdkContext(), GFGetModuleID(), "version.need_upgrade", 0);
+ download_btn_ = new QPushButton(this);
+ download_btn_->setIcon(QIcon::fromTheme("download"));
+ download_btn_->setDefault(true);
- auto is_current_version_publish_in_remote =
- gf::sdk::StateBool(GFModuleSdkContext(), GFGetModuleID(),
- "version.current_version_publish_in_remote", 0);
+ auto* actions = new QHBoxLayout();
+ actions->addWidget(busy_, 1);
+ actions->addStretch();
+ actions->addWidget(check_btn_);
+ actions->addWidget(download_btn_);
- auto is_current_commit_hash_publish_in_remote =
- gf::sdk::StateBool(GFModuleSdkContext(), GFGetModuleID(),
- "version.current_commit_hash_publish_in_remote", 0);
+ notes_toggle_ = new QToolButton(this);
+ notes_toggle_->setText(tr("Release notes"));
+ notes_toggle_->setCheckable(true);
+ notes_toggle_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ notes_toggle_->setArrowType(Qt::RightArrow);
+ notes_toggle_->setAutoRaise(true);
- QString const latest_version = gf::sdk::StateText(
- GFModuleSdkContext(), GFGetModuleID(), "version.latest_version", "");
+ notes_ = new QTextBrowser(this);
+ notes_->setOpenExternalLinks(true);
+ notes_->hide();
- QString const release_note = gf::sdk::StateText(
- GFModuleSdkContext(), GFGetModuleID(), "version.release_note", "");
+ auto* layout = new QVBoxLayout(this);
+ layout->addLayout(header);
+ layout->addSpacing(8);
+ layout->addLayout(actions);
+ layout->addSpacing(8);
+ layout->addWidget(notes_toggle_, 0, Qt::AlignLeft);
+ layout->addWidget(notes_, 1);
+ layout->addStretch();
- QString const api = gf::sdk::StateText(GFModuleSdkContext(), GFGetModuleID(),
- "version.api", "Unknown");
+ connect(notes_toggle_, &QToolButton::toggled, this, [this](bool open) {
+ notes_toggle_->setArrowType(open ? Qt::DownArrow : Qt::RightArrow);
+ notes_->setVisible(open);
+ });
- FLOG_INFO("latest version from remote: %1", latest_version);
+ connect(check_btn_, &QPushButton::clicked, this, [this] {
+ if (!checker_.isNull()) checker_->Start(CheckMode::kForce);
+ });
- latest_version_label_->setText("<center><b>" +
- tr("Latest Version From %1").arg(api) + ": " +
- latest_version + "</b></center>");
- current_version_box_->show();
+ connect(download_btn_, &QPushButton::clicked, this, [] {
+ QDesktopServices::openUrl(QUrl(QString::fromLatin1(kDownloadPage)));
+ });
- if (is_need_upgrade != 0) {
- upgrade_label_->setText(
- "<center>" + tr("Your current version is outdated.") +
- "</center><center>" + tr("Click") +
- " <a "
- "href=\"https://www.gpgfrontend.bktus.com/overview/downloads/\">" +
- tr("here") + "</a> " + tr("to download the latest version.") +
- "</center>");
- upgrade_label_->show();
- upgrade_info_box_->show();
- } else if ((!latest_version.trimmed().isEmpty() &&
- is_current_version_publish_in_remote == 0)) {
- upgrade_label_->setText(
- "<center>" +
- tr("This version is either withdrawn due to critical issues or is an "
- "unreleased build. "
- "Please stop using it and download the latest version.") +
- "</center><center>" + tr("Click") +
- " <a href=\"https://www.gpgfrontend.bktus.com/overview/downloads/\">" +
- tr("here") + "</a> " + tr("to download the latest version.") +
- "</center>");
- upgrade_label_->show();
- upgrade_info_box_->show();
- } else if (is_current_commit_hash_publish_in_remote == 0) {
- upgrade_label_->setText(
- "<center>" +
- tr("The commit hash for this build was not found in the official "
- "repository. This may indicate a modified or unofficial version.") +
- "</center><center>" + tr("Click") +
- " <a "
- "href=\"https://www.gpgfrontend.bktus.com/overview/downloads/\">" +
- tr("here") + "</a> " +
- tr("to verify your installation or download the official build.") +
- "</center>");
- upgrade_label_->show();
- upgrade_info_box_->show();
- } else {
- upgrade_label_->setText("<center>" +
- tr("You are using the latest version. No "
- "action is required.") +
- "</center>");
- upgrade_label_->show();
- upgrade_info_box_->show();
+ if (!checker_.isNull()) {
+ connect(checker_, &UpdateChecker::Changed, this, &UpdateTab::render);
}
- if (!release_note.trimmed().isEmpty()) {
- release_note_viewer_->clear();
- release_note_viewer_->setMarkdown(release_note);
- release_note_viewer_->show();
- release_note_box_->show();
+ // Readable at once: a check that finished before this dialog existed has
+ // left its answer in the checker, not in a signal already gone.
+ render();
+}
+
+void UpdateTab::render() {
+ if (checker_.isNull()) {
+ UpdateView view;
+ view.headline = tr("Update checking is unavailable");
+ view.check_enabled = false;
+ view.check_label = tr("Check now");
+ apply(view);
+ return;
}
+ apply(PresentUpdate(checker_->State(), QDateTime::currentDateTime()));
}
-void UpdateTab::slot_check_version_update() {
- check_update_btn_->setEnabled(false);
- pb_->show();
+void UpdateTab::apply(const UpdateView& view) {
+ const auto icon_size = style()->pixelMetric(QStyle::PM_MessageBoxIconSize);
+ icon_->setPixmap(ToneIcon(style(), view.tone).pixmap(icon_size, icon_size));
+
+ headline_->setText(view.headline);
+ detail_->setText(view.detail);
+ detail_->setVisible(!view.detail.isEmpty());
+ notice_->setText(view.notice);
+ notice_->setVisible(!view.notice.isEmpty());
+ freshness_->setText(view.freshness);
+ freshness_->setVisible(!view.freshness.isEmpty());
- // The one place the choice is stored; the settings page writes it.
- auto api = gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_MODULE,
- "update_checking_api", "github")
- .toString();
+ busy_->setVisible(view.busy);
+ check_btn_->setText(view.check_label);
+ check_btn_->setEnabled(view.check_enabled);
- if (api == "bktus") {
- auto* task = new BKTUSVersionCheckTask();
- connect(
- task, &BKTUSVersionCheckTask::SignalUpgradeVersion, this,
- [this](const SoftwareVersion&) -> void { slot_show_version_status(); });
- connect(task, &BKTUSVersionCheckTask::SignalUpgradeVersion, task,
- &QObject::deleteLater);
- task->Run();
+ download_btn_->setText(view.download_label);
+ download_btn_->setVisible(view.show_download);
+
+ notes_toggle_->setVisible(view.show_notes);
+ if (view.show_notes) {
+ notes_->setMarkdown(view.notes);
+ notes_->setVisible(notes_toggle_->isChecked());
} else {
- auto* task = new GitHubVersionCheckTask();
- connect(
- task, &GitHubVersionCheckTask::SignalUpgradeVersion, this,
- [this](const SoftwareVersion&) -> void { slot_show_version_status(); });
- connect(task, &GitHubVersionCheckTask::SignalUpgradeVersion, task,
- &QObject::deleteLater);
- task->Run();
+ notes_->clear();
+ notes_->hide();
}
}
void UpdateTab::showEvent(QShowEvent* event) {
QWidget::showEvent(event);
-
- auto is_loading_done = gf::sdk::StateBool(
- GFModuleSdkContext(), GFGetModuleID(), "version.loading_done", 0);
+ render();
// The Host's own switch, the one the setup wizard and this module's
- // settings page share. This used to read a register-table key nothing ever
- // wrote, so the tab checked for updates whatever the user had chosen.
+ // settings page share. Off, the dialog still offers the button: pressing
+ // it is the user asking.
const auto prohibit = gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_HOST,
"network/prohibit_update_check", false)
.toBool();
-
- if (!prohibit && is_loading_done == 0) {
- slot_check_version_update();
- } else {
- slot_show_version_status();
- }
+ if (!prohibit && !checker_.isNull()) checker_->Start(CheckMode::kIfStale);
}
diff --git a/src/m_ver_check/UpdateTab.h b/src/m_ver_check/UpdateTab.h
index 55ba33d..edcf9b1 100644
--- a/src/m_ver_check/UpdateTab.h
+++ b/src/m_ver_check/UpdateTab.h
@@ -28,61 +28,41 @@
#pragma once
+#include <QPointer>
#include <QtWidgets/QtWidgets>
+class UpdateChecker;
+struct UpdateView;
+
/**
- * @brief Class containing the main tab of about dialog
+ * @brief The update dialog's content: renders UpdateView, offers the actions.
*
+ * It decides nothing itself. The checker holds the state and PresentUpdate()
+ * turns it into text; this only draws it and forwards the two buttons.
*/
class UpdateTab : public QWidget {
Q_OBJECT
- QLabel* current_version_label_; ///<
- QLabel* latest_version_label_; ///<
- QLabel* upgrade_label_; ///<
- QProgressBar* pb_; ///<
- QTextEdit* release_note_viewer_; ///<
- QString current_version_; ///<
- QGroupBox* release_note_box_;
- QGroupBox* upgrade_info_box_;
- QGroupBox* current_version_box_;
- QPushButton* check_update_btn_;
-
public:
- /**
- * @brief Construct a new Update Tab object
- *
- * @param parent
- */
explicit UpdateTab(QWidget* parent = nullptr);
protected:
- /**
- * @brief
- *
- * @param event
- */
void showEvent(QShowEvent* event) override;
- private slots:
- /**
- * @brief
- *
- * @param version
- */
- void slot_show_version_status();
+ private:
+ void render();
+ void apply(const UpdateView& view);
- /**
- * @brief
- *
- */
- void slot_check_version_update();
+ QPointer<UpdateChecker> checker_;
- signals:
- /**
- * @brief
- *
- * @param data
- */
- void SignalReplyFromUpdateServer(QByteArray data);
+ QLabel* icon_;
+ QLabel* headline_;
+ QLabel* detail_;
+ QLabel* notice_;
+ QLabel* freshness_;
+ QProgressBar* busy_;
+ QPushButton* check_btn_;
+ QPushButton* download_btn_;
+ QToolButton* notes_toggle_;
+ QTextBrowser* notes_;
};
diff --git a/src/m_ver_check/Utils.cpp b/src/m_ver_check/Utils.cpp
index 160c455..05352c7 100644
--- a/src/m_ver_check/Utils.cpp
+++ b/src/m_ver_check/Utils.cpp
@@ -30,20 +30,31 @@
#include <GFSDKLog.h>
-#include <QRegularExpression>
-
+#include "ReleaseCheck.h"
#include "VersionCheckingModule.h"
-auto ExtractVersionFromRawTag(const QString& raw_tag) -> QString {
- static const QRegularExpression kVersionRe(
- R"(^[vV](\d+\.)?(\d+\.)?(\*|\d+))");
- auto match = kVersionRe.match(raw_tag);
- return match.hasMatch() ? match.captured(0) : QString{};
+namespace {
+
+auto VerdictName(Verdict v) -> QString {
+ switch (v) {
+ case Verdict::kUpdateAvailable:
+ return QStringLiteral("update_available");
+ case Verdict::kWithdrawnOrUnreleased:
+ return QStringLiteral("withdrawn_or_unreleased");
+ case Verdict::kUnofficialBuild:
+ return QStringLiteral("unofficial_build");
+ case Verdict::kUpToDate:
+ return QStringLiteral("up_to_date");
+ case Verdict::kUnknown:
+ break;
+ }
+ return QStringLiteral("unknown");
}
-void FillGrtWithVersionInfo(const SoftwareVersion& version) {
- // Plain QStrings: the SDK borrows its arguments. Each of these used to be a
- // GFMemStrDup() that nothing freed, a dozen leaks per update check.
+} // namespace
+
+void FillGrtWithVersionInfo(const UpdateSnapshot& state) {
+ // Plain QStrings: the SDK borrows its arguments.
auto* ctx = GFModuleSdkContext();
const auto ns = GFModuleId();
const auto text = [&](const char* key, const QString& value) {
@@ -53,16 +64,13 @@ void FillGrtWithVersionInfo(const SoftwareVersion& version) {
gf::sdk::SetStateBool(ctx, ns, QString::fromLatin1(key), value);
};
- text("version.current_version", version.current_version);
- text("version.latest_version", version.latest_version);
- text("version.local_commit_hash", version.local_commit_hash);
- flag("version.current_version_publish_in_remote",
- version.current_version_publish_in_remote);
- flag("version.current_commit_hash_publish_in_remote",
- version.current_commit_hash_publish_in_remote);
- flag("version.need_upgrade", version.NeedUpgrade());
- flag("version.current_version_released", version.CurrentVersionReleased());
- text("version.release_note", version.release_note);
- text("version.api", version.api);
- flag("version.loading_done", version.IsInfoValid());
-} \ No newline at end of file
+ const auto& good = state.last_good;
+ text("version.verdict",
+ good ? VerdictName(Decide(*good)) : VerdictName(Verdict::kUnknown));
+ text("version.current_version", good ? good->current_version : QString{});
+ text("version.latest_version", good ? good->list.latest.version : QString{});
+ text("version.checked_at",
+ good ? good->checked_at.toString(Qt::ISODate) : QString{});
+ flag("version.last_attempt_failed", state.last_attempt_failed);
+ flag("version.loading_done", good.has_value());
+}
diff --git a/src/m_ver_check/Utils.h b/src/m_ver_check/Utils.h
index a811aa6..3d1d035 100644
--- a/src/m_ver_check/Utils.h
+++ b/src/m_ver_check/Utils.h
@@ -28,15 +28,8 @@
#pragma once
-#include "SoftwareVersion.h"
+#include "UpdateChecker.h"
-void FillGrtWithVersionInfo(const SoftwareVersion& version);
-
-/**
- * @brief Extract a normalized "vX.Y.Z" version string from a raw release tag or
- * title. Returns an empty string when the raw text does not match.
- *
- * @param raw_tag the raw tag/title from a release feed.
- * @return QString the matched version (e.g. "v2.1.3"), or empty on no match.
- */
-auto ExtractVersionFromRawTag(const QString& raw_tag) -> QString; \ No newline at end of file
+/// Mirror the checker's state into the module's Host state namespace, where
+/// diagnostics can read it.
+void FillGrtWithVersionInfo(const UpdateSnapshot& state);
diff --git a/src/m_ver_check/VersionCheckingModule.cpp b/src/m_ver_check/VersionCheckingModule.cpp
index bb259a2..73cf9fa 100644
--- a/src/m_ver_check/VersionCheckingModule.cpp
+++ b/src/m_ver_check/VersionCheckingModule.cpp
@@ -29,25 +29,20 @@
#include "VersionCheckingModule.h"
#include <GFSDKBuildInfo.h>
-#include <GFSDKHostCommands.hpp>
#include <GFSDKLog.h>
#include <GFSDKUI.h>
-#include <QButtonGroup>
+#include <GFSDKHostCommands.hpp>
#include <QCheckBox>
#include <QGroupBox>
-#include <QHBoxLayout>
-#include <QLabel>
-#include <QMetaType>
-#include <QRadioButton>
+#include <QPointer>
#include <QVBoxLayout>
#include <QtNetwork>
+#include <atomic>
-#include "BKTUSVersionCheckTask.h"
#include "GFModule.h"
#include "GFModuleIdentity.h"
-#include "GitHubVersionCheckTask.h"
-#include "SoftwareVersion.h"
+#include "UpdateChecker.h"
#include "UpdateTab.h"
#include "Utils.h"
@@ -58,41 +53,61 @@ namespace {
/// than a second one that could disagree.
constexpr auto kProhibitKey = "network/prohibit_update_check";
-/// Which service to ask. The module's own setting.
-constexpr auto kApiKey = "update_checking_api";
+/// The checker's state, last known good result included.
+constexpr auto kStateKey = "update_checking_state";
+
+std::atomic<bool> g_stopped{false};
+QPointer<UpdateChecker> g_checker;
+
+/// A GitHub API GET through Qt. Every request answers, success or not: a
+/// transport failure is status 0, and the transfer timeout bounds a server
+/// that never replies.
+auto MakeFetcher(QNetworkAccessManager* nam) -> UpdateChecker::Fetcher {
+ return [nam = QPointer<QNetworkAccessManager>(nam)](
+ const QUrl& url, const UpdateChecker::Reply& reply) {
+ if (nam.isNull()) {
+ reply(0, {});
+ return;
+ }
-auto Api() -> QString {
- return gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_MODULE, kApiKey,
- "github")
- .toString();
+ QNetworkRequest request(url);
+ request.setTransferTimeout(30000);
+ request.setHeader(QNetworkRequest::UserAgentHeader,
+ GFAppUserAgent(GFModuleSdkContext()));
+ request.setRawHeader("Accept", "application/vnd.github+json");
+ request.setRawHeader("X-GitHub-Api-Version", "2022-11-28");
+
+ auto* r = nam->get(request);
+ QObject::connect(r, &QNetworkReply::finished, r, [r, reply] {
+ const int status =
+ r->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ FLOG_DEBUG("update check reply: %1, http status: %2, error: %3",
+ r->url().toString(), status, r->errorString());
+ const auto body = r->readAll();
+ r->deleteLater();
+ reply(status, body);
+ });
+ };
}
-void StoreResult(const SoftwareVersion& sv) {
- gf::sdk::SetCacheText(GFModuleSdkContext(), GF_STORE_DURABLE,
- "update_checking_cache",
- (QJsonDocument(sv.ToJson()).toJson()).constData());
+auto MakeStore() -> UpdateChecker::Store {
+ return {
+ [] {
+ return gf::sdk::CacheText(GFModuleSdkContext(), GF_STORE_DURABLE,
+ kStateKey)
+ .toUtf8();
+ },
+ [](const QByteArray& data) {
+ gf::sdk::SetCacheText(GFModuleSdkContext(), GF_STORE_DURABLE, kStateKey,
+ QString::fromUtf8(data));
+ },
+ };
}
-/// Ask the service; cache what it says for the next start. The task always
-/// reports, success or not, so it is always released.
-void CheckUpdate() {
- if (Api() == "bktus") {
- MLogInfo("checking updating using api of bktus.com");
- auto* task = new BKTUSVersionCheckTask();
- QObject::connect(task, &BKTUSVersionCheckTask::SignalUpgradeVersion,
- QCoreApplication::instance(), &StoreResult);
- QObject::connect(task, &BKTUSVersionCheckTask::SignalUpgradeVersion, task,
- &QObject::deleteLater);
- task->Run();
- } else {
- MLogInfo("checking updating using api of github.com");
- auto* task = new GitHubVersionCheckTask();
- QObject::connect(task, &GitHubVersionCheckTask::SignalUpgradeVersion,
- QCoreApplication::instance(), &StoreResult);
- QObject::connect(task, &GitHubVersionCheckTask::SignalUpgradeVersion, task,
- &QObject::deleteLater);
- task->Run();
- }
+/// On the GUI thread, where the checker and its requests live.
+template <typename F>
+void OnGuiThread(F&& f) {
+ QMetaObject::invokeMethod(QCoreApplication::instance(), std::forward<F>(f));
}
void OpenUpdateDialog() {
@@ -100,6 +115,26 @@ void OpenUpdateDialog() {
{gf::cmd::ViewRef{QStringLiteral(GF_MODULE_ID ".update")}});
}
+/// Once per run: the startup check's answer, or a fresh stored one.
+void CheckAtStartup() {
+ auto* checker = VersionChecker();
+ if (checker == nullptr) return;
+
+ auto watch = std::make_shared<QMetaObject::Connection>();
+ const auto decide = [checker, watch] {
+ if (checker->State().checking) return;
+ QObject::disconnect(*watch);
+ if (ShouldPromptOnStartup(checker->State())) {
+ LOG_INFO("a newer release is available, notifying user");
+ OpenUpdateDialog();
+ }
+ };
+
+ *watch = QObject::connect(checker, &UpdateChecker::Changed, checker, decide);
+ checker->Start(CheckMode::kIfStale);
+ decide();
+}
+
/// The update dialog, in a frame the Host owns.
class UpdateWidget : public QWidget, public gf::ui::DialogWidget {
public:
@@ -109,7 +144,8 @@ class UpdateWidget : public QWidget, public gf::ui::DialogWidget {
}
};
-/// Settings > Updates: whether to check at startup, and where.
+/// Settings > Updates: whether to check at startup. Nothing else is worth a
+/// setting: there is one release server, and a day is the right interval.
class UpdateSettingsWidget : public QWidget, public gf::ui::SettingsWidget {
public:
UpdateSettingsWidget() {
@@ -117,28 +153,14 @@ class UpdateSettingsWidget : public QWidget, public gf::ui::SettingsWidget {
QCoreApplication::translate("GTrC", "Update Checking"), this);
auto* column = new QVBoxLayout(box);
- check_ = new QCheckBox(
- QCoreApplication::translate(
- "GTrC", "Checking for version updates when the application "
- "starts."),
- box);
+ check_ =
+ new QCheckBox(QCoreApplication::translate(
+ "GTrC",
+ "Checking for version updates when the application "
+ "starts."),
+ box);
column->addWidget(check_);
- auto* row = new QHBoxLayout();
- row->addWidget(new QLabel(
- QCoreApplication::translate("GTrC", "Update Checking API:"), box));
- github_ = new QRadioButton(QCoreApplication::translate("GTrC", "GitHub"),
- box);
- bktus_ = new QRadioButton(QCoreApplication::translate("GTrC", "BKTUS.com"),
- box);
- auto* group = new QButtonGroup(this);
- group->addButton(github_);
- group->addButton(bktus_);
- row->addWidget(github_);
- row->addWidget(bktus_);
- row->addStretch();
- column->addLayout(row);
-
auto* layout = new QVBoxLayout(this);
layout->addWidget(box);
layout->addStretch();
@@ -148,33 +170,25 @@ class UpdateSettingsWidget : public QWidget, public gf::ui::SettingsWidget {
check_->setChecked(!gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_HOST,
kProhibitKey, true)
.toBool());
- const bool bktus = Api() == "bktus";
- bktus_->setChecked(bktus);
- github_->setChecked(!bktus);
}
auto ApplySettings() -> bool override {
- const bool host_ok =
- gf::sdk::SetSetting(GFModuleSdkContext(), GF_SETTING_HOST,
- kProhibitKey, !check_->isChecked());
- const bool module_ok = gf::sdk::SetSetting(
- GFModuleSdkContext(), GF_SETTING_MODULE, kApiKey,
- bktus_->isChecked() ? QStringLiteral("bktus")
- : QStringLiteral("github"));
- return host_ok && module_ok;
+ return gf::sdk::SetSetting(GFModuleSdkContext(), GF_SETTING_HOST,
+ kProhibitKey, !check_->isChecked());
}
private:
QCheckBox* check_;
- QRadioButton* github_;
- QRadioButton* bktus_;
};
/// Help > Check for Updates.
struct CheckForUpdates {
static constexpr gf::cmd::Meta kMeta{
- GF_MODULE_ID ".check_for_updates", GC_TR("Check for Updates"),
- GC_TR("See whether a newer GpgFrontend is available"), "", 0,
+ GF_MODULE_ID ".check_for_updates",
+ GC_TR("Check for Updates"),
+ GC_TR("See whether a newer GpgFrontend is available"),
+ "",
+ 0,
gf::cmd::kNeedsGuiThread};
using Args = gf::cmd::Unit;
using Result = gf::cmd::Unit;
@@ -189,28 +203,67 @@ auto DoCheckForUpdates(const gf::cmd::CommandContext& /*ctx*/,
} // namespace
+auto VersionChecker() -> UpdateChecker* {
+ if (g_stopped.load()) return nullptr;
+ if (g_checker.isNull()) {
+ auto* ctx = GFModuleSdkContext();
+ auto* nam = new QNetworkAccessManager();
+ g_checker = new UpdateChecker({QString::fromUtf8(GFAppVersion(ctx)),
+ QString::fromUtf8(GFAppGitCommitHash(ctx))},
+ MakeFetcher(nam), MakeStore());
+ nam->setParent(g_checker);
+
+ QObject::connect(g_checker, &UpdateChecker::Changed, g_checker,
+ [checker = g_checker.data()] {
+ FillGrtWithVersionInfo(checker->State());
+ });
+ FillGrtWithVersionInfo(g_checker->State());
+ }
+ return g_checker;
+}
+
auto OnActivate() -> GFResult {
LOG_INFO("version checking module activating");
+ g_stopped.store(false);
+
+ // Left behind by 1.5 and earlier: a cache in the old format, and the choice
+ // of a check service that no longer exists.
+ gf::sdk::RemoveCache(GFModuleSdkContext(), GF_STORE_DURABLE,
+ "update_checking_cache");
+ gf::sdk::RemoveSetting(GFModuleSdkContext(), GF_SETTING_MODULE,
+ "update_checking_api");
+
const bool dialog = gf::ui::RegisterNativeWidget<UpdateWidget>(
"update", {GC_TR("Check for Updates"), "", "", "", "", 500, 600},
[](const QCborMap& /*args*/) { return new UpdateWidget(); });
const bool settings = gf::ui::RegisterNativeWidget<UpdateSettingsWidget>(
"settings",
- {GC_TR("Updates"), GC_TR("update,version,check,github,bktus"), "", "",
- "", 0, 0},
+ {GC_TR("Updates"), GC_TR("update,version,check,github"), "", "", "", 0,
+ 0},
[](const QCborMap& /*args*/) { return new UpdateSettingsWidget(); });
return dialog && settings
? GFResult::Ok()
: GFResult::Fail("the update widgets were not registered");
}
+auto OnDeactivate() -> GFResult {
+ // No check starts from here on; the checker and its requests go with the
+ // event loop's next turn, on the thread that owns them.
+ g_stopped.store(true);
+ if (!g_checker.isNull()) {
+ QMetaObject::invokeMethod(g_checker.data(), &QObject::deleteLater,
+ Qt::QueuedConnection);
+ }
+ return GFResult::Ok();
+}
+
auto OnApplicationLoaded(const GFEvent& /*event*/) -> GFEventResult {
LOG_DEBUG("application starting completed event: processing");
// Absent until the setup wizard has run: no check before the user has been
// asked whether to check at all.
- const auto prohibited = gf::sdk::Setting(GFModuleSdkContext(),
- GF_SETTING_HOST, kProhibitKey);
+ const auto prohibited =
+ gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_HOST, kProhibitKey);
if (!prohibited.isValid()) {
LOG_DEBUG("application loaded: the setup wizard has not asked yet");
return GFEventResult::Ok();
@@ -220,33 +273,7 @@ auto OnApplicationLoaded(const GFEvent& /*event*/) -> GFEventResult {
return GFEventResult::Ok();
}
- auto cache = gf::sdk::CacheText(GFModuleSdkContext(), GF_STORE_DURABLE,
- "update_checking_cache");
- auto json = QJsonDocument::fromJson(cache.toUtf8());
-
- if (json.isEmpty() || !json.isObject()) {
- LOG_DEBUG("application loaded: nothing cached, checking now");
- CheckUpdate();
- return GFEventResult::Ok();
- }
-
- SoftwareVersion sv;
- sv.FromJson(json.object());
-
- FLOG_DEBUG("got software version meta data: %1", json.toJson());
- if (sv.timestamp.addDays(1) < QDateTime::currentDateTime()) {
- CheckUpdate();
- return GFEventResult::Ok();
- }
-
- FillGrtWithVersionInfo(sv);
-
- if (sv.NeedUpgrade() || !sv.CurrentVersionReleased() ||
- !sv.current_commit_hash_publish_in_remote) {
- LOG_INFO(
- "software version is outdated or not fully released, notifying user");
- OpenUpdateDialog();
- }
+ OnGuiThread(&CheckAtStartup);
// An observation, answered at once: the check runs on its own.
return GFEventResult::Ok();
@@ -269,7 +296,7 @@ const GFModuleHooks kHooks = {
GF_MODULE_VERSION,
GF_MODULE_TRANSLATION_CONTEXT,
&OnActivate,
- nullptr, // the Host withdraws the command, widgets and script itself
+ &OnDeactivate, // stops a running check; the Host withdraws the rest
&OnUnload,
kEvents.data(),
kEvents.size(),
diff --git a/src/m_ver_check/VersionCheckingModule.h b/src/m_ver_check/VersionCheckingModule.h
index a59d85c..6bbb84e 100644
--- a/src/m_ver_check/VersionCheckingModule.h
+++ b/src/m_ver_check/VersionCheckingModule.h
@@ -29,3 +29,12 @@
#pragma once
#include "GFModule.h"
+
+class UpdateChecker;
+
+/**
+ * @brief The module's one update checker, created on first use.
+ *
+ * GUI thread only. Null once the module is deactivating.
+ */
+auto VersionChecker() -> UpdateChecker*;
diff --git a/src/m_ver_check/module.json b/src/m_ver_check/module.json
index 4c44dad..3a4c6ed 100644
--- a/src/m_ver_check/module.json
+++ b/src/m_ver_check/module.json
@@ -3,7 +3,7 @@
"id": "com.bktus.gpgfrontend.module.version_checking",
"version": "2.0.0",
"name": "VersionChecking",
- "description": "Try checking GpgFrontend version.",
+ "description": "Checks GitHub Releases for newer GpgFrontend versions.",
"author": "Saturneric",
"capabilities": [
"network",
diff --git a/src/m_ver_check/test/CMakeLists.txt b/src/m_ver_check/test/CMakeLists.txt
new file mode 100644
index 0000000..1f7f63d
--- /dev/null
+++ b/src/m_ver_check/test/CMakeLists.txt
@@ -0,0 +1,60 @@
+# Copyright (C) 2021-2024 Saturneric <[email protected]>
+#
+# This file is part of GpgFrontend.
+#
+# GpgFrontend is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# GpgFrontend is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+#
+# The initial version of the source code is inherited from
+# the gpg4usb project, which is under GPL-3.0-or-later.
+#
+# All the source code of GpgFrontend was modified and released by
+# Saturneric <[email protected]> starting on May 12, 2021.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Unit tests for the Host-free half of the update check.
+#
+# ReleaseCheck, UpdateChecker, UpdatePresentation and SoftwareVersion take no
+# Host call, no widget and no network: the checker is handed its transport,
+# store and clock. The one SDK symbol they use, GFCompareSoftwareVersion,
+# needs no host either, so the real module-side SDK is linked for it.
+
+if(NOT TARGET GTest::gtest)
+ find_package(GTest QUIET)
+endif()
+
+if(NOT TARGET GTest::gtest)
+ message(STATUS "GTest not found; skipping version checking module tests")
+ return()
+endif()
+
+add_executable(gf_mod_ver_check_test
+ ReleaseCheckTest.cpp
+ UpdateCheckerTest.cpp
+ UpdatePresentationTest.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../ReleaseCheck.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../SoftwareVersion.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../UpdateChecker.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../UpdatePresentation.cpp)
+
+target_include_directories(gf_mod_ver_check_test PRIVATE
+ "${CMAKE_CURRENT_SOURCE_DIR}/.."
+ "${CMAKE_BINARY_DIR}/src/sdk")
+
+target_link_libraries(gf_mod_ver_check_test PRIVATE
+ GTest::gtest GTest::gtest_main Qt::Core gf_sdk)
+
+set_target_properties(gf_mod_ver_check_test PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${GPGFRONTEND_MODULE_TEST_OUTPUT_DIR}")
+gf_register_module_test(gf_mod_ver_check_test)
diff --git a/src/m_ver_check/test/ReleaseCheckTest.cpp b/src/m_ver_check/test/ReleaseCheckTest.cpp
new file mode 100644
index 0000000..f546c80
--- /dev/null
+++ b/src/m_ver_check/test/ReleaseCheckTest.cpp
@@ -0,0 +1,336 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include <gtest/gtest.h>
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <array>
+
+#include "ReleaseCheck.h"
+
+namespace {
+
+auto Release(const QString& tag, bool draft = false, bool prerelease = false)
+ -> QJsonObject {
+ QJsonObject r;
+ r["tag_name"] = tag;
+ r["draft"] = draft;
+ r["prerelease"] = prerelease;
+ r["published_at"] = "2026-01-01T00:00:00Z";
+ r["body"] = "notes for " + tag;
+ r["html_url"] = "https://github.com/saturneric/GpgFrontend/releases/" + tag;
+ return r;
+}
+
+auto List(const QJsonArray& releases) -> QByteArray {
+ return QJsonDocument(releases).toJson();
+}
+
+auto Result(ListOutcome list, RemoteFact tag, RemoteFact commit,
+ const QString& latest = "v2.1.9") -> SoftwareVersion {
+ SoftwareVersion r;
+ r.current_version = "v2.1.8";
+ r.local_commit_hash = "abc123";
+ r.list.outcome = list;
+ if (list == ListOutcome::kFound) r.list.latest.version = latest;
+ r.tag_fact = tag;
+ r.commit_fact = commit;
+ return r;
+}
+
+constexpr std::array<RemoteFact, 3> kFacts = {
+ RemoteFact::kUnknown, RemoteFact::kConfirmed, RemoteFact::kNotFound};
+
+} // namespace
+
+TEST(ReleaseCheckTest, ExtractVersionNormalizesTags) {
+ EXPECT_EQ(ExtractVersionFromRawTag("v2.1.9"), "v2.1.9");
+ EXPECT_EQ(ExtractVersionFromRawTag("v2.1.9-rc1"), "v2.1.9");
+ EXPECT_EQ(ExtractVersionFromRawTag("2.1.9"), "");
+ EXPECT_EQ(ExtractVersionFromRawTag("release"), "");
+}
+
+TEST(ReleaseCheckTest, PrereleaseVersionDetection) {
+ EXPECT_FALSE(IsPrereleaseVersion("v2.1.9"));
+ EXPECT_FALSE(IsPrereleaseVersion("2.1"));
+ EXPECT_TRUE(IsPrereleaseVersion("v2.2.0-rc1"));
+ EXPECT_TRUE(IsPrereleaseVersion("v2.2.0beta"));
+}
+
+TEST(ReleaseCheckTest, PicksNewestReleaseInOwnSeries) {
+ const auto body = List({Release("v2.2.3"), Release("v2.1.10"),
+ Release("v2.1.9"), Release("v2.0.7")});
+ const auto stable = ParseReleaseList(200, body, "v2.1.8");
+ ASSERT_EQ(stable.outcome, ListOutcome::kFound);
+ EXPECT_EQ(stable.latest.version, "v2.1.10");
+ EXPECT_EQ(stable.latest.notes, "notes for v2.1.10");
+ EXPECT_TRUE(stable.latest.html_url.endsWith("v2.1.10"));
+
+ const auto mainline = ParseReleaseList(200, body, "v2.2.0");
+ ASSERT_EQ(mainline.outcome, ListOutcome::kFound);
+ EXPECT_EQ(mainline.latest.version, "v2.2.3");
+}
+
+TEST(ReleaseCheckTest, SkipsDraftsAndForeignTags) {
+ const auto body =
+ List({Release("v2.1.12", true), Release("nightly"), Release("v2.1.9")});
+ const auto r = ParseReleaseList(200, body, "v2.1.8");
+ ASSERT_EQ(r.outcome, ListOutcome::kFound);
+ EXPECT_EQ(r.latest.version, "v2.1.9");
+}
+
+TEST(ReleaseCheckTest, PrereleasesOnlyForPrereleaseBuilds) {
+ const auto body = List({Release("v2.1.11", false, true), Release("v2.1.9")});
+ EXPECT_EQ(ParseReleaseList(200, body, "v2.1.8").latest.version, "v2.1.9");
+ EXPECT_EQ(ParseReleaseList(200, body, "v2.1.8-rc1").latest.version,
+ "v2.1.11");
+}
+
+TEST(ReleaseCheckTest, ValidListWithoutMatchIsNotAFailure) {
+ EXPECT_EQ(ParseReleaseList(200, List({}), "v2.1.8").outcome,
+ ListOutcome::kNoMatch);
+ EXPECT_EQ(ParseReleaseList(200, List({Release("v2.2.0")}), "v2.1.8").outcome,
+ ListOutcome::kNoMatch);
+}
+
+TEST(ReleaseCheckTest, BrokenListIsAFailure) {
+ const auto good = List({Release("v2.1.9")});
+ EXPECT_EQ(ParseReleaseList(403, good, "v2.1.8").outcome,
+ ListOutcome::kFailed);
+ EXPECT_EQ(ParseReleaseList(0, {}, "v2.1.8").outcome, ListOutcome::kFailed);
+ EXPECT_EQ(ParseReleaseList(200, "{\"message\":\"x\"}", "v2.1.8").outcome,
+ ListOutcome::kFailed);
+ EXPECT_EQ(ParseReleaseList(200, "<html>", "v2.1.8").outcome,
+ ListOutcome::kFailed);
+}
+
+TEST(ReleaseCheckTest, ClassifyLookup) {
+ EXPECT_EQ(ClassifyLookup(200, true), RemoteFact::kConfirmed);
+ EXPECT_EQ(ClassifyLookup(200, false), RemoteFact::kUnknown);
+ EXPECT_EQ(ClassifyLookup(404, false), RemoteFact::kNotFound);
+ for (const int status : {0, 301, 401, 403, 422, 429, 500, 502, 503}) {
+ EXPECT_EQ(ClassifyLookup(status, false), RemoteFact::kUnknown) << status;
+ }
+}
+
+TEST(ReleaseCheckTest, TagLookup) {
+ EXPECT_EQ(ParseTagLookup(200, R"({"tag_name":"v2.1.8"})"),
+ RemoteFact::kConfirmed);
+ EXPECT_EQ(ParseTagLookup(200, "garbage"), RemoteFact::kUnknown);
+ EXPECT_EQ(ParseTagLookup(404, R"({"message":"Not Found"})"),
+ RemoteFact::kNotFound);
+ EXPECT_EQ(ParseTagLookup(403, R"({"message":"API rate limit exceeded"})"),
+ RemoteFact::kUnknown);
+}
+
+TEST(ReleaseCheckTest, CommitLookup) {
+ EXPECT_EQ(ParseCommitLookup(200, R"({"sha":"abc123def"})", "abc123"),
+ RemoteFact::kConfirmed);
+ EXPECT_EQ(ParseCommitLookup(200, R"({"sha":"fff000"})", "abc123"),
+ RemoteFact::kUnknown);
+ EXPECT_EQ(ParseCommitLookup(404, {}, "abc123"), RemoteFact::kNotFound);
+ EXPECT_EQ(
+ ParseCommitLookup(422, R"({"message":"No commit found for SHA: abc123"})",
+ "abc123"),
+ RemoteFact::kNotFound);
+ EXPECT_EQ(
+ ParseCommitLookup(422, R"({"message":"Validation Failed"})", "abc123"),
+ RemoteFact::kUnknown);
+ EXPECT_EQ(ParseCommitLookup(200, R"({"sha":"abc123"})", ""),
+ RemoteFact::kUnknown);
+}
+
+TEST(ReleaseCheckTest, VerdictPrecedence) {
+ using F = RemoteFact;
+ using L = ListOutcome;
+ // An update wins over everything the lookups found.
+ EXPECT_EQ(Decide(Result(L::kFound, F::kNotFound, F::kNotFound)),
+ Verdict::kUpdateAvailable);
+ // Then a missing release, then a missing commit.
+ EXPECT_EQ(Decide(Result(L::kFound, F::kNotFound, F::kNotFound, "v2.1.8")),
+ Verdict::kWithdrawnOrUnreleased);
+ EXPECT_EQ(Decide(Result(L::kNoMatch, F::kConfirmed, F::kNotFound)),
+ Verdict::kUnofficialBuild);
+ EXPECT_EQ(Decide(Result(L::kNoMatch, F::kConfirmed, F::kConfirmed)),
+ Verdict::kUpToDate);
+ EXPECT_EQ(Decide(Result(L::kFound, F::kConfirmed, F::kConfirmed, "v2.1.8")),
+ Verdict::kUpToDate);
+ // A dev build ahead of the latest release is not offered a downgrade.
+ EXPECT_EQ(Decide(Result(L::kFound, F::kConfirmed, F::kConfirmed, "v2.1.7")),
+ Verdict::kUpToDate);
+}
+
+TEST(ReleaseCheckTest, VerdictTableForEveryCombination) {
+ for (const auto list :
+ {ListOutcome::kFailed, ListOutcome::kNoMatch, ListOutcome::kFound}) {
+ for (const auto tag : kFacts) {
+ for (const auto commit : kFacts) {
+ for (const QString latest : {"v2.1.9", "v2.1.8"}) {
+ const auto r = Result(list, tag, commit, latest);
+ const auto v = Decide(r);
+ Verdict expected = Verdict::kUpToDate;
+ if (list == ListOutcome::kFailed) {
+ expected = Verdict::kUnknown;
+ } else if (list == ListOutcome::kFound && latest == "v2.1.9") {
+ expected = Verdict::kUpdateAvailable;
+ } else if (tag == RemoteFact::kNotFound) {
+ expected = Verdict::kWithdrawnOrUnreleased;
+ } else if (commit == RemoteFact::kNotFound) {
+ expected = Verdict::kUnofficialBuild;
+ }
+ EXPECT_EQ(v, expected);
+ }
+ }
+ }
+ }
+}
+
+TEST(ReleaseCheckTest, UnknownFactsNeverAssertAnything) {
+ for (const auto list :
+ {ListOutcome::kFailed, ListOutcome::kNoMatch, ListOutcome::kFound}) {
+ const auto v = Decide(
+ Result(list, RemoteFact::kUnknown, RemoteFact::kUnknown, "v2.1.8"));
+ EXPECT_NE(v, Verdict::kWithdrawnOrUnreleased);
+ EXPECT_NE(v, Verdict::kUnofficialBuild);
+ }
+}
+
+TEST(ReleaseCheckTest, OnlyAConfirmedUpdatePromptsAtStartup) {
+ EXPECT_TRUE(ShouldPromptAtStartup(Verdict::kUpdateAvailable));
+ EXPECT_FALSE(ShouldPromptAtStartup(Verdict::kUnknown));
+ EXPECT_FALSE(ShouldPromptAtStartup(Verdict::kUpToDate));
+ EXPECT_FALSE(ShouldPromptAtStartup(Verdict::kWithdrawnOrUnreleased));
+ EXPECT_FALSE(ShouldPromptAtStartup(Verdict::kUnofficialBuild));
+}
+
+TEST(ReleaseCheckTest, CompletenessAndAuthority) {
+ auto full = Result(ListOutcome::kFound, RemoteFact::kConfirmed,
+ RemoteFact::kNotFound);
+ EXPECT_TRUE(IsComplete(full));
+
+ auto partial =
+ Result(ListOutcome::kFound, RemoteFact::kConfirmed, RemoteFact::kUnknown);
+ EXPECT_TRUE(IsUsable(partial));
+ EXPECT_FALSE(IsComplete(partial));
+ partial.complete = IsComplete(partial);
+ EXPECT_FALSE(IsAuthoritative(partial));
+
+ auto failed = Result(ListOutcome::kFailed, RemoteFact::kConfirmed,
+ RemoteFact::kConfirmed);
+ EXPECT_FALSE(IsUsable(failed));
+ EXPECT_FALSE(IsComplete(failed));
+}
+
+TEST(ReleaseCheckTest, MergeKeepsPreviousOnFailure) {
+ const auto prev = Result(ListOutcome::kFound, RemoteFact::kConfirmed,
+ RemoteFact::kConfirmed);
+ const auto failed =
+ Result(ListOutcome::kFailed, RemoteFact::kUnknown, RemoteFact::kUnknown);
+ const auto merged = Merge(prev, failed);
+ ASSERT_TRUE(merged);
+ EXPECT_EQ(merged->list.outcome, ListOutcome::kFound);
+ EXPECT_EQ(merged->list.latest.version, "v2.1.9");
+
+ EXPECT_FALSE(Merge(std::nullopt, failed));
+}
+
+TEST(ReleaseCheckTest, MergeFillsUnknownFactsForSameBuildOnly) {
+ auto prev = Result(ListOutcome::kFound, RemoteFact::kConfirmed,
+ RemoteFact::kNotFound);
+ prev.complete = true;
+ auto fresh =
+ Result(ListOutcome::kNoMatch, RemoteFact::kUnknown, RemoteFact::kUnknown);
+ fresh.complete = IsComplete(fresh);
+
+ const auto merged = Merge(prev, fresh);
+ ASSERT_TRUE(merged);
+ EXPECT_EQ(merged->list.outcome, ListOutcome::kNoMatch);
+ EXPECT_EQ(merged->tag_fact, RemoteFact::kConfirmed);
+ EXPECT_EQ(merged->commit_fact, RemoteFact::kNotFound);
+ // Inherited facts fill the picture; they do not make the check authoritative.
+ EXPECT_FALSE(IsAuthoritative(*merged));
+
+ auto other_build = prev;
+ other_build.local_commit_hash = "fff000";
+ const auto unrelated = Merge(other_build, fresh);
+ ASSERT_TRUE(unrelated);
+ EXPECT_EQ(unrelated->tag_fact, RemoteFact::kUnknown);
+ EXPECT_EQ(unrelated->commit_fact, RemoteFact::kUnknown);
+}
+
+TEST(ReleaseCheckTest, MergeKeepsFreshKnownFacts) {
+ const auto prev =
+ Result(ListOutcome::kFound, RemoteFact::kNotFound, RemoteFact::kNotFound);
+ const auto fresh = Result(ListOutcome::kFound, RemoteFact::kConfirmed,
+ RemoteFact::kConfirmed);
+ const auto merged = Merge(prev, fresh);
+ ASSERT_TRUE(merged);
+ EXPECT_EQ(merged->tag_fact, RemoteFact::kConfirmed);
+ EXPECT_EQ(merged->commit_fact, RemoteFact::kConfirmed);
+}
+
+TEST(ReleaseCheckTest, SoftwareVersionJsonRoundTrip) {
+ auto r = Result(ListOutcome::kFound, RemoteFact::kConfirmed,
+ RemoteFact::kNotFound);
+ r.list.latest.notes = "## Changes";
+ r.list.latest.html_url = "https://example.invalid/r";
+ r.complete = true;
+ r.checked_at = QDateTime::fromSecsSinceEpoch(4102444800); // past 2038
+
+ const auto back = SoftwareVersion::FromJson(r.ToJson());
+ ASSERT_TRUE(back);
+ EXPECT_EQ(back->current_version, r.current_version);
+ EXPECT_EQ(back->local_commit_hash, r.local_commit_hash);
+ EXPECT_EQ(back->list.outcome, ListOutcome::kFound);
+ EXPECT_EQ(back->list.latest.version, "v2.1.9");
+ EXPECT_EQ(back->list.latest.notes, "## Changes");
+ EXPECT_EQ(back->list.latest.html_url, "https://example.invalid/r");
+ EXPECT_EQ(back->tag_fact, RemoteFact::kConfirmed);
+ EXPECT_EQ(back->commit_fact, RemoteFact::kNotFound);
+ EXPECT_TRUE(back->complete);
+ EXPECT_EQ(back->checked_at, r.checked_at);
+}
+
+TEST(ReleaseCheckTest, OldCacheFormatIsRejected) {
+ // What 1.5 and earlier wrote.
+ QJsonObject old;
+ old["api"] = "GitHub";
+ old["latest_version"] = "v2.1.9";
+ old["current_version"] = "v2.1.8";
+ old["current_version_publish_in_remote"] = true;
+ old["timestamp"] = 1700000000;
+ EXPECT_FALSE(SoftwareVersion::FromJson(old));
+
+ auto found_without_version =
+ Result(ListOutcome::kFound, RemoteFact::kUnknown, RemoteFact::kUnknown)
+ .ToJson();
+ found_without_version["latest"] = QJsonObject{};
+ EXPECT_FALSE(SoftwareVersion::FromJson(found_without_version));
+}
diff --git a/src/m_ver_check/test/UpdateCheckerTest.cpp b/src/m_ver_check/test/UpdateCheckerTest.cpp
new file mode 100644
index 0000000..f8ab883
--- /dev/null
+++ b/src/m_ver_check/test/UpdateCheckerTest.cpp
@@ -0,0 +1,340 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include <gtest/gtest.h>
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <memory>
+#include <vector>
+
+#include "ReleaseCheck.h"
+#include "UpdateChecker.h"
+
+namespace {
+
+const UpdateChecker::Build kBuild{"v2.1.8", "abc123"};
+
+/// Holds every request until the test answers it.
+struct FakeServer {
+ struct Pending {
+ QUrl url;
+ UpdateChecker::Reply reply;
+ };
+ std::vector<Pending> pending;
+ int requests = 0;
+
+ auto Fetcher() -> UpdateChecker::Fetcher {
+ return [this](const QUrl& url, const UpdateChecker::Reply& reply) {
+ ++requests;
+ pending.push_back({url, reply});
+ };
+ }
+
+ /// Answer every held request as a server would, @p ok or all failing.
+ void AnswerAll(bool ok, const QString& latest = "v2.1.9") {
+ auto held = std::move(pending);
+ pending.clear();
+ for (auto& p : held) {
+ const auto path = p.url.path();
+ if (!ok) {
+ p.reply(0, {});
+ } else if (path.endsWith("/releases")) {
+ QJsonObject r;
+ r["tag_name"] = latest;
+ r["body"] = "notes";
+ p.reply(200, QJsonDocument(QJsonArray{r}).toJson());
+ } else if (path.contains("/releases/tags/")) {
+ p.reply(200, R"({"tag_name":"v2.1.8"})");
+ } else {
+ p.reply(200, R"({"sha":"abc123ffff"})");
+ }
+ }
+ }
+
+ /// The list answers; the lookups hit a rate limit.
+ void AnswerPartial() {
+ auto held = std::move(pending);
+ pending.clear();
+ for (auto& p : held) {
+ if (p.url.path().endsWith("/releases")) {
+ QJsonObject r;
+ r["tag_name"] = "v2.1.8";
+ p.reply(200, QJsonDocument(QJsonArray{r}).toJson());
+ } else {
+ p.reply(403, R"({"message":"API rate limit exceeded"})");
+ }
+ }
+ }
+};
+
+struct Harness {
+ FakeServer server;
+ QByteArray stored;
+ QDateTime now = QDateTime::fromSecsSinceEpoch(1800000000);
+ int saves = 0;
+
+ auto Make(UpdateChecker::Build build = kBuild)
+ -> std::unique_ptr<UpdateChecker> {
+ return std::make_unique<UpdateChecker>(
+ build, server.Fetcher(),
+ UpdateChecker::Store{[this] { return stored; },
+ [this](const QByteArray& d) {
+ stored = d;
+ ++saves;
+ }},
+ [this] { return now; });
+ }
+};
+
+} // namespace
+
+TEST(UpdateCheckerTest, StartIsVisibleSynchronously) {
+ Harness h;
+ auto c = h.Make();
+ int changes = 0;
+ QObject::connect(c.get(), &UpdateChecker::Changed, [&] { ++changes; });
+
+ c->Start(CheckMode::kIfStale);
+ EXPECT_TRUE(c->State().checking);
+ EXPECT_EQ(changes, 1);
+ EXPECT_EQ(h.server.requests, 3);
+
+ h.server.AnswerAll(true);
+ EXPECT_FALSE(c->State().checking);
+ EXPECT_EQ(changes, 2);
+ ASSERT_TRUE(c->State().last_good);
+ EXPECT_EQ(Decide(*c->State().last_good), Verdict::kUpdateAvailable);
+ EXPECT_TRUE(c->State().last_good->complete);
+ EXPECT_EQ(h.saves, 1);
+}
+
+TEST(UpdateCheckerTest, SecondStartJoinsTheRunningCheck) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kIfStale);
+ c->Start(CheckMode::kForce);
+ c->Start(CheckMode::kIfStale);
+ EXPECT_EQ(h.server.requests, 3);
+
+ int changes = 0;
+ QObject::connect(c.get(), &UpdateChecker::Changed, [&] { ++changes; });
+ h.server.AnswerAll(true);
+ EXPECT_EQ(changes, 1);
+ EXPECT_FALSE(c->State().checking);
+}
+
+TEST(UpdateCheckerTest, FailedRefreshKeepsLastKnownGood) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true);
+ const auto first_checked = c->State().last_good->checked_at;
+
+ h.now = h.now.addSecs(3600);
+ c->Start(CheckMode::kForce);
+ EXPECT_TRUE(c->State().checking);
+ // The previous result stays readable while checking.
+ ASSERT_TRUE(c->State().last_good);
+ h.server.AnswerAll(false);
+
+ const auto& s = c->State();
+ EXPECT_TRUE(s.last_attempt_failed);
+ EXPECT_EQ(s.last_attempt, h.now);
+ ASSERT_TRUE(s.last_good);
+ EXPECT_EQ(s.last_good->checked_at, first_checked);
+ EXPECT_EQ(s.last_good->list.latest.version, "v2.1.9");
+ EXPECT_EQ(Decide(*s.last_good), Verdict::kUpdateAvailable);
+
+ // And it survives a restart.
+ auto restarted = h.Make();
+ ASSERT_TRUE(restarted->State().last_good);
+ EXPECT_EQ(restarted->State().last_good->list.latest.version, "v2.1.9");
+ EXPECT_TRUE(restarted->State().last_attempt_failed);
+}
+
+TEST(UpdateCheckerTest, SuccessfulRefreshClearsFailure) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(false);
+ EXPECT_TRUE(c->State().last_attempt_failed);
+ EXPECT_FALSE(c->State().last_good);
+
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.8");
+ EXPECT_FALSE(c->State().last_attempt_failed);
+ ASSERT_TRUE(c->State().last_good);
+ EXPECT_EQ(Decide(*c->State().last_good), Verdict::kUpToDate);
+}
+
+TEST(UpdateCheckerTest, ForceBypassesFreshCache) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true);
+ EXPECT_TRUE(c->IsFresh());
+
+ c->Start(CheckMode::kIfStale);
+ EXPECT_FALSE(c->State().checking);
+ EXPECT_EQ(h.server.requests, 3);
+
+ c->Start(CheckMode::kForce);
+ EXPECT_TRUE(c->State().checking);
+ EXPECT_EQ(h.server.requests, 6);
+}
+
+TEST(UpdateCheckerTest, StaleCacheIsRechecked) {
+ Harness h;
+ {
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true);
+ }
+ h.now = h.now.addSecs(UpdateChecker::kFreshSeconds + 1);
+ auto c = h.Make();
+ EXPECT_FALSE(c->IsFresh());
+ c->Start(CheckMode::kIfStale);
+ EXPECT_TRUE(c->State().checking);
+}
+
+TEST(UpdateCheckerTest, PartialResultDoesNotSuppressRetry) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kIfStale);
+ h.server.AnswerPartial();
+
+ const auto& s = c->State();
+ EXPECT_FALSE(s.last_attempt_failed);
+ ASSERT_TRUE(s.last_good);
+ EXPECT_FALSE(s.last_good->complete);
+ EXPECT_EQ(s.last_good->tag_fact, RemoteFact::kUnknown);
+ // A rate limit is not a withdrawn version.
+ EXPECT_EQ(Decide(*s.last_good), Verdict::kUpToDate);
+ EXPECT_FALSE(c->IsFresh());
+
+ c->Start(CheckMode::kIfStale);
+ EXPECT_TRUE(c->State().checking);
+}
+
+TEST(UpdateCheckerTest, StoredResultForAnotherBuildIsDropped) {
+ Harness h;
+ {
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true);
+ }
+ auto upgraded = h.Make({"v2.1.9", "def456"});
+ EXPECT_FALSE(upgraded->State().last_good);
+ EXPECT_FALSE(upgraded->IsFresh());
+
+ auto same = h.Make();
+ EXPECT_TRUE(same->State().last_good);
+}
+
+TEST(UpdateCheckerTest, StorageRejectsGarbageAndOldFormat) {
+ EXPECT_FALSE(UpdateChecker::FromStorage("garbage", kBuild).last_good);
+ EXPECT_FALSE(UpdateChecker::FromStorage(
+ R"({"api":"GitHub","latest_version":"v2.1.9"})", kBuild)
+ .last_good);
+}
+
+TEST(UpdateCheckerTest, NoCommitHashSkipsTheCommitLookup) {
+ Harness h;
+ auto c = h.Make({"v2.1.8", ""});
+ c->Start(CheckMode::kForce);
+ EXPECT_EQ(h.server.requests, 2);
+ h.server.AnswerAll(true, "v2.1.8");
+ ASSERT_TRUE(c->State().last_good);
+ EXPECT_EQ(c->State().last_good->commit_fact, RemoteFact::kUnknown);
+ EXPECT_FALSE(c->IsFresh());
+}
+
+TEST(UpdateCheckerTest, SynchronousFetcherStillFinishesOnce) {
+ QByteArray stored;
+ int changes = 0;
+ UpdateChecker c(
+ kBuild,
+ [](const QUrl&, const UpdateChecker::Reply& reply) { reply(0, {}); },
+ {[&] { return stored; }, [&](const QByteArray& d) { stored = d; }});
+ QObject::connect(&c, &UpdateChecker::Changed, [&] { ++changes; });
+ c.Start(CheckMode::kForce);
+ EXPECT_FALSE(c.State().checking);
+ EXPECT_TRUE(c.State().last_attempt_failed);
+ EXPECT_EQ(changes, 2);
+}
+
+TEST(UpdateCheckerTest, ReplyAfterDestructionIsIgnored) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ c.reset();
+ h.server.AnswerAll(true); // must not touch the dead checker
+ EXPECT_EQ(h.saves, 0);
+}
+
+TEST(UpdateCheckerTest, StartupPromptOnlyForConfirmedUpdate) {
+ Harness h;
+ auto c = h.Make();
+ EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // nothing known
+
+ c->Start(CheckMode::kIfStale);
+ EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // still checking
+ h.server.AnswerAll(false);
+ EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // failed
+
+ c->Start(CheckMode::kIfStale);
+ h.server.AnswerPartial();
+ EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // partial, no update
+
+ c->Start(CheckMode::kIfStale);
+ h.server.AnswerAll(true);
+ EXPECT_TRUE(ShouldPromptOnStartup(c->State())); // confirmed update
+
+ // A later failure does not unlearn a confirmed update.
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(false);
+ EXPECT_TRUE(ShouldPromptOnStartup(c->State()));
+}
+
+TEST(UpdateCheckerTest, StartupNeverPromptsForUnofficialOrWithdrawn) {
+ UpdateSnapshot s;
+ SoftwareVersion r;
+ r.current_version = "v2.1.8";
+ r.list.outcome = ListOutcome::kNoMatch;
+ r.tag_fact = RemoteFact::kNotFound;
+ r.commit_fact = RemoteFact::kNotFound;
+ s.last_good = r;
+ EXPECT_EQ(Decide(r), Verdict::kWithdrawnOrUnreleased);
+ EXPECT_FALSE(ShouldPromptOnStartup(s));
+
+ s.last_good->tag_fact = RemoteFact::kConfirmed;
+ EXPECT_EQ(Decide(*s.last_good), Verdict::kUnofficialBuild);
+ EXPECT_FALSE(ShouldPromptOnStartup(s));
+}
diff --git a/src/m_ver_check/test/UpdatePresentationTest.cpp b/src/m_ver_check/test/UpdatePresentationTest.cpp
new file mode 100644
index 0000000..a9ba7e5
--- /dev/null
+++ b/src/m_ver_check/test/UpdatePresentationTest.cpp
@@ -0,0 +1,161 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include <gtest/gtest.h>
+
+#include "UpdatePresentation.h"
+
+namespace {
+
+const QDateTime kNow = QDateTime::fromSecsSinceEpoch(1800000000);
+
+auto Known(ListOutcome list, const QString& latest, RemoteFact tag,
+ RemoteFact commit) -> SoftwareVersion {
+ SoftwareVersion r;
+ r.current_version = "v2.1.8";
+ r.local_commit_hash = "abc123";
+ r.list.outcome = list;
+ r.list.latest.version = latest;
+ r.list.latest.notes = "## What's new";
+ r.tag_fact = tag;
+ r.commit_fact = commit;
+ r.checked_at = kNow.addSecs(-5 * 60);
+ return r;
+}
+
+auto Update() -> SoftwareVersion {
+ return Known(ListOutcome::kFound, "v2.1.9", RemoteFact::kConfirmed,
+ RemoteFact::kConfirmed);
+}
+
+auto Current() -> SoftwareVersion {
+ return Known(ListOutcome::kFound, "v2.1.8", RemoteFact::kConfirmed,
+ RemoteFact::kConfirmed);
+}
+
+} // namespace
+
+TEST(UpdatePresentationTest, NothingKnownYet) {
+ const auto v = PresentUpdate({}, kNow);
+ EXPECT_FALSE(v.show_download);
+ EXPECT_FALSE(v.busy);
+ EXPECT_TRUE(v.check_enabled);
+ EXPECT_EQ(v.check_label, "Check now");
+ EXPECT_TRUE(v.freshness.isEmpty());
+}
+
+TEST(UpdatePresentationTest, FirstCheckInProgress) {
+ UpdateSnapshot s;
+ s.checking = true;
+ const auto v = PresentUpdate(s, kNow);
+ EXPECT_TRUE(v.busy);
+ EXPECT_FALSE(v.check_enabled);
+ EXPECT_FALSE(v.show_download);
+}
+
+TEST(UpdatePresentationTest, FailedWithNothingKnownOffersTryAgain) {
+ UpdateSnapshot s;
+ s.last_attempt_failed = true;
+ const auto v = PresentUpdate(s, kNow);
+ EXPECT_EQ(v.headline, "Couldn't check for updates");
+ EXPECT_EQ(v.check_label, "Try again");
+ EXPECT_FALSE(v.show_download);
+ // Generic: no guessed cause.
+ EXPECT_FALSE(v.detail.contains("offline", Qt::CaseInsensitive));
+ EXPECT_FALSE(v.detail.contains("rate", Qt::CaseInsensitive));
+}
+
+TEST(UpdatePresentationTest, UpdateAvailableOffersDownloadAndNotes) {
+ UpdateSnapshot s;
+ s.last_good = Update();
+ const auto v = PresentUpdate(s, kNow);
+ EXPECT_EQ(v.tone, UpdateTone::kAttention);
+ EXPECT_TRUE(v.show_download);
+ EXPECT_EQ(v.download_label, "Download v2.1.9");
+ EXPECT_TRUE(v.show_notes);
+ EXPECT_EQ(v.notes, "## What's new");
+ EXPECT_EQ(v.freshness, "Last checked 5 minute(s) ago ยท GitHub Releases");
+ EXPECT_TRUE(v.notice.isEmpty());
+}
+
+TEST(UpdatePresentationTest, UpToDateHasNoDownloadOrNotes) {
+ UpdateSnapshot s;
+ s.last_good = Current();
+ const auto v = PresentUpdate(s, kNow);
+ EXPECT_EQ(v.tone, UpdateTone::kGood);
+ EXPECT_FALSE(v.show_download);
+ EXPECT_FALSE(v.show_notes);
+ EXPECT_EQ(v.check_label, "Check again");
+}
+
+TEST(UpdatePresentationTest, FailedRefreshKeepsKnownUpdate) {
+ UpdateSnapshot s;
+ s.last_good = Update();
+ s.last_attempt = kNow;
+ s.last_attempt_failed = true;
+ const auto v = PresentUpdate(s, kNow);
+ EXPECT_TRUE(v.show_download);
+ EXPECT_EQ(v.download_label, "Download v2.1.9");
+ EXPECT_FALSE(v.notice.isEmpty());
+ EXPECT_EQ(v.check_label, "Try again");
+ // Freshness is the last success, not the failed attempt.
+ EXPECT_TRUE(v.freshness.contains("5 minute"));
+}
+
+TEST(UpdatePresentationTest, CheckingKeepsPreviousResultVisible) {
+ UpdateSnapshot s;
+ s.last_good = Update();
+ s.checking = true;
+ const auto v = PresentUpdate(s, kNow);
+ EXPECT_TRUE(v.busy);
+ EXPECT_FALSE(v.check_enabled);
+ EXPECT_TRUE(v.show_download);
+ EXPECT_EQ(v.headline, "GpgFrontend v2.1.9 is available");
+}
+
+TEST(UpdatePresentationTest, WithdrawnAndUnofficialAreCalmAndInDialogOnly) {
+ UpdateSnapshot s;
+ s.last_good = Known(ListOutcome::kFound, "v2.1.8", RemoteFact::kNotFound,
+ RemoteFact::kConfirmed);
+ auto v = PresentUpdate(s, kNow);
+ EXPECT_EQ(v.tone, UpdateTone::kAttention);
+ EXPECT_FALSE(v.show_download);
+ EXPECT_EQ(v.check_label, "Check again");
+
+ s.last_good = Known(ListOutcome::kFound, "v2.1.8", RemoteFact::kConfirmed,
+ RemoteFact::kNotFound);
+ v = PresentUpdate(s, kNow);
+ EXPECT_EQ(v.tone, UpdateTone::kAttention);
+ EXPECT_FALSE(v.show_download);
+}
+
+TEST(UpdatePresentationTest, RelativeTime) {
+ EXPECT_EQ(RelativeTime(kNow.addSecs(-10), kNow), "just now");
+ EXPECT_EQ(RelativeTime(kNow.addSecs(-3 * 3600), kNow), "3 hour(s) ago");
+ EXPECT_FALSE(RelativeTime(kNow.addDays(-3), kNow).contains("ago"));
+}