From 4249f0192baf88e33bfb4e47828279ae2b569a8d Mon Sep 17 00:00:00 2001 From: saturneric Date: Thu, 24 Sep 2026 22:59:10 +0200 Subject: 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 --- src/m_ver_check/BKTUSVersionCheckTask.cpp | 309 --------------------- src/m_ver_check/BKTUSVersionCheckTask.h | 101 ------- src/m_ver_check/CMakeLists.txt | 21 +- src/m_ver_check/GitHubVersionCheckTask.cpp | 200 -------------- src/m_ver_check/GitHubVersionCheckTask.h | 101 ------- src/m_ver_check/ReleaseCheck.cpp | 181 ++++++++++++ src/m_ver_check/ReleaseCheck.h | 123 +++++++++ src/m_ver_check/SoftwareVersion.cpp | 114 +++++--- src/m_ver_check/SoftwareVersion.h | 102 +++---- src/m_ver_check/UpdateChecker.cpp | 162 +++++++++++ src/m_ver_check/UpdateChecker.h | 124 +++++++++ src/m_ver_check/UpdatePresentation.cpp | 154 +++++++++++ src/m_ver_check/UpdatePresentation.h | 71 +++++ src/m_ver_check/UpdateTab.cpp | 350 ++++++++++-------------- src/m_ver_check/UpdateTab.h | 62 ++--- src/m_ver_check/Utils.cpp | 54 ++-- src/m_ver_check/Utils.h | 15 +- src/m_ver_check/VersionCheckingModule.cpp | 243 ++++++++-------- src/m_ver_check/VersionCheckingModule.h | 9 + src/m_ver_check/module.json | 2 +- src/m_ver_check/test/CMakeLists.txt | 60 ++++ src/m_ver_check/test/ReleaseCheckTest.cpp | 336 +++++++++++++++++++++++ src/m_ver_check/test/UpdateCheckerTest.cpp | 340 +++++++++++++++++++++++ src/m_ver_check/test/UpdatePresentationTest.cpp | 161 +++++++++++ 24 files changed, 2206 insertions(+), 1189 deletions(-) delete mode 100644 src/m_ver_check/BKTUSVersionCheckTask.cpp delete mode 100644 src/m_ver_check/BKTUSVersionCheckTask.h delete mode 100644 src/m_ver_check/GitHubVersionCheckTask.cpp delete mode 100644 src/m_ver_check/GitHubVersionCheckTask.h create mode 100644 src/m_ver_check/ReleaseCheck.cpp create mode 100644 src/m_ver_check/ReleaseCheck.h create mode 100644 src/m_ver_check/UpdateChecker.cpp create mode 100644 src/m_ver_check/UpdateChecker.h create mode 100644 src/m_ver_check/UpdatePresentation.cpp create mode 100644 src/m_ver_check/UpdatePresentation.h create mode 100644 src/m_ver_check/test/CMakeLists.txt create mode 100644 src/m_ver_check/test/ReleaseCheckTest.cpp create mode 100644 src/m_ver_check/test/UpdateCheckerTest.cpp create mode 100644 src/m_ver_check/test/UpdatePresentationTest.cpp (limited to 'src') 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 - * - * 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 . - * - * 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 starting on May 12, 2021. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - */ - -#include "BKTUSVersionCheckTask.h" - -#include -#include - -#include -#include -#include - -#include "GFModule.h" -#include "SoftwareVersion.h" -#include "Utils.h" - -BKTUSVersionCheckTask::BKTUSVersionCheckTask() - : network_manager_(new QNetworkAccessManager(this)), - current_version_(GFAppVersion(GFModuleSdkContext())) { - qRegisterMetaType("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 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 - * - * 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 . - * - * 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 starting on May 12, 2021. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - */ - -#pragma once - -#include - -#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 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 - * - * 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 . - * - * 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 starting on May 12, 2021. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - */ - -#include "GitHubVersionCheckTask.h" - -#include -#include - -#include -#include - -#include "GFModule.h" -#include "SoftwareVersion.h" -#include "Utils.h" - -GitHubVersionCheckTask::GitHubVersionCheckTask() - : network_manager_(new QNetworkAccessManager(this)), - current_version_(GFAppVersion(GFModuleSdkContext())) { - qRegisterMetaType("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 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 - * - * 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 . - * - * 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 starting on May 12, 2021. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - */ - -#pragma once - -#include - -#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 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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "ReleaseCheck.h" + +#include + +#include +#include +#include +#include + +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 { + 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& prev, + const SoftwareVersion& fresh) -> std::optional { + 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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#pragma once + +#include +#include +#include + +#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/`: is this version released? +auto ParseTagLookup(int http_status, const QByteArray& body) -> RemoteFact; + +/// `GET /commits/`: 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& prev, + const SoftwareVersion& fresh) -> std::optional; + +/** + * @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 -#include - -#include #include #include +#include +#include + +namespace { + +// Names, not enum values, on disk: a reordered enum must not flip a cached +// "not found" into "confirmed". +constexpr std::array, 3> kFactNames = {{ + {RemoteFact::kUnknown, "unknown"}, + {RemoteFact::kConfirmed, "confirmed"}, + {RemoteFact::kNotFound, "not_found"}, +}}; + +constexpr std::array, 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 +auto NameOf(const std::array, 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 +auto ValueOf(const std::array, 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 { + 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 -#include +#include #include +#include /** - * @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; /** * @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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "UpdateChecker.h" + +#include +#include +#include + +#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 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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#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 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; + using Fetcher = std::function; + using Clock = std::function; + + struct Store { + std::function load; + std::function 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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "UpdatePresentation.h" + +#include +#include + +#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(minutes); + if (minutes < 60) { + return QCoreApplication::translate("UpdateTab", "%n minute(s) ago", "", m); + } + + const int h = static_cast(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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#pragma once + +#include +#include + +#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("
" + tr("Current Version") + - tr(": ") + "" + current_version_ + - "
"); - 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); - - 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); - - current_version_box_->hide(); - release_note_box_->hide(); - upgrade_info_box_->hide(); - - layout->addWidget(current_version_box_); - layout->addWidget(upgrade_info_box_); - layout->addWidget(release_note_box_); - - auto* hbox = new QHBoxLayout(); - hbox->addWidget(pb_, 1); - hbox->addWidget(check_update_btn_, 0, Qt::AlignRight); - layout->addLayout(hbox); - - connect(check_update_btn_, &QPushButton::clicked, this, - &UpdateTab::slot_check_version_update); - - setLayout(layout); - - slot_show_version_status(); -} +constexpr auto kDownloadPage = + "https://www.gpgfrontend.bktus.com/overview/downloads/"; -void UpdateTab::slot_show_version_status() { - check_update_btn_->setEnabled(true); - this->pb_->setHidden(true); - - auto is_loading_done = gf::sdk::StateBool( - GFModuleSdkContext(), GFGetModuleID(), "version.loading_done", 0); - - if (is_loading_done == 0) { - MLogDebug("version info loading haven't been done yet."); - - upgrade_label_->setText( - "
" + - tr("Unable to retrieve the latest version information. This may be " - "due to a network issue or the server being unavailable.") + - "
" + - tr("Please check your internet connection or try again later.") + - "
" + tr("Alternatively, you can visit the") + - " " + - tr("official download page") + " " + - tr("to check for the latest version.") + "
"); - upgrade_label_->show(); - upgrade_info_box_->show(); - return; +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); +} - auto is_need_upgrade = gf::sdk::StateBool( - GFModuleSdkContext(), GFGetModuleID(), "version.need_upgrade", 0); - - auto is_current_version_publish_in_remote = - gf::sdk::StateBool(GFModuleSdkContext(), GFGetModuleID(), - "version.current_version_publish_in_remote", 0); - - auto is_current_commit_hash_publish_in_remote = - gf::sdk::StateBool(GFModuleSdkContext(), GFGetModuleID(), - "version.current_commit_hash_publish_in_remote", 0); - - QString const latest_version = gf::sdk::StateText( - GFModuleSdkContext(), GFGetModuleID(), "version.latest_version", ""); - - QString const release_note = gf::sdk::StateText( - GFModuleSdkContext(), GFGetModuleID(), "version.release_note", ""); - - QString const api = gf::sdk::StateText(GFModuleSdkContext(), GFGetModuleID(), - "version.api", "Unknown"); - - FLOG_INFO("latest version from remote: %1", latest_version); - - latest_version_label_->setText("
" + - tr("Latest Version From %1").arg(api) + ": " + - latest_version + "
"); - current_version_box_->show(); - - if (is_need_upgrade != 0) { - upgrade_label_->setText( - "
" + tr("Your current version is outdated.") + - "
" + tr("Click") + - " " + - tr("here") + " " + tr("to download the latest version.") + - "
"); - upgrade_label_->show(); - upgrade_info_box_->show(); - } else if ((!latest_version.trimmed().isEmpty() && - is_current_version_publish_in_remote == 0)) { - upgrade_label_->setText( - "
" + - tr("This version is either withdrawn due to critical issues or is an " - "unreleased build. " - "Please stop using it and download the latest version.") + - "
" + tr("Click") + - " " + - tr("here") + " " + tr("to download the latest version.") + - "
"); - upgrade_label_->show(); - upgrade_info_box_->show(); - } else if (is_current_commit_hash_publish_in_remote == 0) { - upgrade_label_->setText( - "
" + - tr("The commit hash for this build was not found in the official " - "repository. This may indicate a modified or unofficial version.") + - "
" + tr("Click") + - " " + - tr("here") + " " + - tr("to verify your installation or download the official build.") + - "
"); - upgrade_label_->show(); - upgrade_info_box_->show(); - } else { - upgrade_label_->setText("
" + - tr("You are using the latest version. No " - "action is required.") + - "
"); - upgrade_label_->show(); - upgrade_info_box_->show(); +} // namespace + +UpdateTab::UpdateTab(QWidget* parent) + : QWidget(parent), checker_(VersionChecker()) { + icon_ = new QLabel(this); + icon_->setAlignment(Qt::AlignTop); + + 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); + + detail_ = new QLabel(this); + detail_->setWordWrap(true); + + notice_ = new QLabel(this); + notice_->setWordWrap(true); + + 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); + + 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* header = new QHBoxLayout(); + header->setSpacing(12); + header->addWidget(icon_, 0, Qt::AlignTop); + header->addLayout(text_column, 1); + + busy_ = new QProgressBar(this); + busy_->setRange(0, 0); + busy_->setTextVisible(false); + busy_->setMaximumHeight(6); + + check_btn_ = new QPushButton(this); + check_btn_->setIcon(QIcon::fromTheme("view-refresh")); + + download_btn_ = new QPushButton(this); + download_btn_->setIcon(QIcon::fromTheme("download")); + download_btn_->setDefault(true); + + auto* actions = new QHBoxLayout(); + actions->addWidget(busy_, 1); + actions->addStretch(); + actions->addWidget(check_btn_); + actions->addWidget(download_btn_); + + 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); + + notes_ = new QTextBrowser(this); + notes_->setOpenExternalLinks(true); + notes_->hide(); + + 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(); + + connect(notes_toggle_, &QToolButton::toggled, this, [this](bool open) { + notes_toggle_->setArrowType(open ? Qt::DownArrow : Qt::RightArrow); + notes_->setVisible(open); + }); + + connect(check_btn_, &QPushButton::clicked, this, [this] { + if (!checker_.isNull()) checker_->Start(CheckMode::kForce); + }); + + connect(download_btn_, &QPushButton::clicked, this, [] { + QDesktopServices::openUrl(QUrl(QString::fromLatin1(kDownloadPage))); + }); + + 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(); - - // 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(); - - 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(); +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()); + + busy_->setVisible(view.busy); + check_btn_->setText(view.check_label); + check_btn_->setEnabled(view.check_enabled); + + 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 #include +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 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 -#include - +#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 -#include #include #include -#include +#include #include #include -#include -#include -#include -#include +#include #include #include +#include -#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"; - -auto Api() -> QString { - return gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_MODULE, kApiKey, - "github") - .toString(); +/// The checker's state, last known good result included. +constexpr auto kStateKey = "update_checking_state"; + +std::atomic g_stopped{false}; +QPointer 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(nam)]( + const QUrl& url, const UpdateChecker::Reply& reply) { + if (nam.isNull()) { + reply(0, {}); + return; + } + + 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 +void OnGuiThread(F&& f) { + QMetaObject::invokeMethod(QCoreApplication::instance(), std::forward(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(); + 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( "update", {GC_TR("Check for Updates"), "", "", "", "", 500, 600}, [](const QCborMap& /*args*/) { return new UpdateWidget(); }); const bool settings = gf::ui::RegisterNativeWidget( "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 +# +# 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 . +# +# 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 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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include + +#include +#include +#include +#include + +#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 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, "", "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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include + +#include +#include +#include +#include +#include + +#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; + 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 { + return std::make_unique( + 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 + * + * 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 . + * + * 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 starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include + +#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")); +} -- cgit