aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-24 23:25:25 +0200
committersaturneric <[email protected]>2026-09-24 23:25:25 +0200
commit1c95ebf2cbb4d4c841141f568eb394f4091b6ba7 (patch)
treeedc4ae7cddc999d9500cc10dd6c83821c14fd9e0 /src
parentfeat(update): add robust GitHub update checking (diff)
downloadmodules-1c95ebf2cbb4d4c841141f568eb394f4091b6ba7.tar.gz
modules-1c95ebf2cbb4d4c841141f568eb394f4091b6ba7.zip
feat(update): track unseen releases without prompting
- replace startup dialogs with quiet background update checks - track and persist the latest update seen by the user - expose update attention state for badges and UI indicators - acknowledge updates when the update page is opened - retain acknowledgements across failures, restarts, and rebuilds - expand tests for attention, persistence, and rediscovered releases
Diffstat (limited to 'src')
-rw-r--r--src/m_ver_check/ReleaseCheck.cpp4
-rw-r--r--src/m_ver_check/ReleaseCheck.h3
-rw-r--r--src/m_ver_check/UpdateChecker.cpp31
-rw-r--r--src/m_ver_check/UpdateChecker.h20
-rw-r--r--src/m_ver_check/UpdateTab.cpp9
-rw-r--r--src/m_ver_check/VersionCheckingModule.cpp26
-rw-r--r--src/m_ver_check/test/ReleaseCheckTest.cpp8
-rw-r--r--src/m_ver_check/test/UpdateCheckerTest.cpp125
8 files changed, 169 insertions, 57 deletions
diff --git a/src/m_ver_check/ReleaseCheck.cpp b/src/m_ver_check/ReleaseCheck.cpp
index ed9e799..3c63122 100644
--- a/src/m_ver_check/ReleaseCheck.cpp
+++ b/src/m_ver_check/ReleaseCheck.cpp
@@ -175,7 +175,3 @@ auto Decide(const SoftwareVersion& r) -> Verdict {
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
index 696e7fe..31e44cd 100644
--- a/src/m_ver_check/ReleaseCheck.h
+++ b/src/m_ver_check/ReleaseCheck.h
@@ -118,6 +118,3 @@ auto Merge(const std::optional<SoftwareVersion>& prev,
*/
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/UpdateChecker.cpp b/src/m_ver_check/UpdateChecker.cpp
index 82f62b3..d65ca03 100644
--- a/src/m_ver_check/UpdateChecker.cpp
+++ b/src/m_ver_check/UpdateChecker.cpp
@@ -28,6 +28,8 @@
#include "UpdateChecker.h"
+#include <GFSDKApp.h>
+
#include <QJsonDocument>
#include <QJsonObject>
#include <QPointer>
@@ -60,6 +62,13 @@ auto UpdateChecker::IsFresh() const -> bool {
good->checked_at.secsTo(clock_()) < kFreshSeconds;
}
+void UpdateChecker::Acknowledge() {
+ if (AttentionFor(state_) == UpdateAttention::kNone) return;
+ state_.last_seen_update_version = state_.last_good->list.latest.version;
+ if (store_.save) store_.save(ToStorage(state_));
+ emit Changed();
+}
+
void UpdateChecker::Start(CheckMode mode) {
if (state_.checking) return;
if (mode == CheckMode::kIfStale && IsFresh()) return;
@@ -130,6 +139,9 @@ auto UpdateChecker::ToStorage(const UpdateSnapshot& s) -> QByteArray {
obj["last_attempt"] = s.last_attempt.toSecsSinceEpoch();
}
obj["last_attempt_failed"] = s.last_attempt_failed;
+ if (!s.last_seen_update_version.isEmpty()) {
+ obj["last_seen_update_version"] = s.last_seen_update_version;
+ }
return QJsonDocument(obj).toJson(QJsonDocument::Compact);
}
@@ -142,6 +154,10 @@ auto UpdateChecker::FromStorage(const QByteArray& data, const Build& build)
const auto obj = doc.object();
if (obj.value("schema").toInt() != kStorageSchema) return s;
+ // Which update the user has seen is not a fact about this build: an
+ // upgrade, or a rebuild, must not advertise it again.
+ s.last_seen_update_version = obj.value("last_seen_update_version").toString();
+
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.
@@ -156,7 +172,16 @@ auto UpdateChecker::FromStorage(const QByteArray& data, const Build& build)
return s;
}
-auto ShouldPromptOnStartup(const UpdateSnapshot& s) -> bool {
- return !s.checking && s.last_good &&
- ShouldPromptAtStartup(Decide(*s.last_good));
+auto AttentionFor(const UpdateSnapshot& s) -> UpdateAttention {
+ if (!s.last_good || Decide(*s.last_good) != Verdict::kUpdateAvailable) {
+ return UpdateAttention::kNone;
+ }
+ const auto& latest = s.last_good->list.latest.version;
+ const auto& seen = s.last_seen_update_version;
+ if (!seen.isEmpty() &&
+ GFCompareSoftwareVersion(latest.toUtf8().constData(),
+ seen.toUtf8().constData()) <= 0) {
+ return UpdateAttention::kNone;
+ }
+ return UpdateAttention::kUpdateAvailable;
}
diff --git a/src/m_ver_check/UpdateChecker.h b/src/m_ver_check/UpdateChecker.h
index 51a3ede..f486a34 100644
--- a/src/m_ver_check/UpdateChecker.h
+++ b/src/m_ver_check/UpdateChecker.h
@@ -49,6 +49,17 @@ struct UpdateSnapshot {
std::optional<SoftwareVersion> last_good;
QDateTime last_attempt;
bool last_attempt_failed = false;
+
+ /// The newest update the user has already been shown. Kept across builds
+ /// and failed checks: seeing it once is enough.
+ QString last_seen_update_version;
+};
+
+/// Whether the update entry point asks for attention. Derived from the
+/// state, never stored: the UI does not read network results itself.
+enum class UpdateAttention {
+ kNone,
+ kUpdateAvailable, ///< a confirmed newer release the user has not seen
};
enum class CheckMode {
@@ -98,6 +109,10 @@ class UpdateChecker : public QObject {
/// A recent authoritative result for this build: no startup check needed.
[[nodiscard]] auto IsFresh() const -> bool;
+ /// The user has seen the update page: whatever update it advertises no
+ /// longer asks for attention. A no-op when there is none.
+ void Acknowledge();
+
/// 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,
@@ -120,5 +135,6 @@ class UpdateChecker : public QObject {
int pending_ = 0;
};
-/// Whether the startup check should open the update dialog now.
-auto ShouldPromptOnStartup(const UpdateSnapshot& s) -> bool;
+/// kUpdateAvailable while the last known result offers a release newer than
+/// the last one the user has seen; a failed check changes nothing.
+auto AttentionFor(const UpdateSnapshot& s) -> UpdateAttention;
diff --git a/src/m_ver_check/UpdateTab.cpp b/src/m_ver_check/UpdateTab.cpp
index bc998d4..e123f6e 100644
--- a/src/m_ver_check/UpdateTab.cpp
+++ b/src/m_ver_check/UpdateTab.cpp
@@ -141,7 +141,11 @@ UpdateTab::UpdateTab(QWidget* parent)
});
if (!checker_.isNull()) {
- connect(checker_, &UpdateChecker::Changed, this, &UpdateTab::render);
+ connect(checker_, &UpdateChecker::Changed, this, [this] {
+ // An update found while the page is open has been seen.
+ if (isVisible()) checker_->Acknowledge();
+ render();
+ });
}
// Readable at once: a check that finished before this dialog existed has
@@ -192,6 +196,9 @@ void UpdateTab::apply(const UpdateView& view) {
void UpdateTab::showEvent(QShowEvent* event) {
QWidget::showEvent(event);
+ // Opening the page is seeing whatever update it advertises: the badge on
+ // the menu entry goes, until a still newer release turns up.
+ if (!checker_.isNull()) checker_->Acknowledge();
render();
// The Host's own switch, the one the setup wizard and this module's
diff --git a/src/m_ver_check/VersionCheckingModule.cpp b/src/m_ver_check/VersionCheckingModule.cpp
index 73cf9fa..4fe5bd7 100644
--- a/src/m_ver_check/VersionCheckingModule.cpp
+++ b/src/m_ver_check/VersionCheckingModule.cpp
@@ -115,26 +115,6 @@ void OpenUpdateDialog() {
{gf::cmd::ViewRef{QStringLiteral(GF_MODULE_ID ".update")}});
}
-/// Once per run: the startup check's answer, or a fresh stored one.
-void CheckAtStartup() {
- auto* checker = VersionChecker();
- if (checker == nullptr) return;
-
- auto watch = std::make_shared<QMetaObject::Connection>();
- const auto decide = [checker, watch] {
- if (checker->State().checking) return;
- QObject::disconnect(*watch);
- if (ShouldPromptOnStartup(checker->State())) {
- LOG_INFO("a newer release is available, notifying user");
- OpenUpdateDialog();
- }
- };
-
- *watch = QObject::connect(checker, &UpdateChecker::Changed, checker, decide);
- checker->Start(CheckMode::kIfStale);
- decide();
-}
-
/// The update dialog, in a frame the Host owns.
class UpdateWidget : public QWidget, public gf::ui::DialogWidget {
public:
@@ -273,7 +253,11 @@ auto OnApplicationLoaded(const GFEvent& /*event*/) -> GFEventResult {
return GFEventResult::Ok();
}
- OnGuiThread(&CheckAtStartup);
+ // In the background, and quietly: a newer release shows as a badge on
+ // Help > Check for Updates, never as a window.
+ OnGuiThread([] {
+ if (auto* checker = VersionChecker()) checker->Start(CheckMode::kIfStale);
+ });
// An observation, answered at once: the check runs on its own.
return GFEventResult::Ok();
diff --git a/src/m_ver_check/test/ReleaseCheckTest.cpp b/src/m_ver_check/test/ReleaseCheckTest.cpp
index f546c80..5fe20b5 100644
--- a/src/m_ver_check/test/ReleaseCheckTest.cpp
+++ b/src/m_ver_check/test/ReleaseCheckTest.cpp
@@ -222,14 +222,6 @@ TEST(ReleaseCheckTest, UnknownFactsNeverAssertAnything) {
}
}
-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);
diff --git a/src/m_ver_check/test/UpdateCheckerTest.cpp b/src/m_ver_check/test/UpdateCheckerTest.cpp
index f8ab883..0dec1ee 100644
--- a/src/m_ver_check/test/UpdateCheckerTest.cpp
+++ b/src/m_ver_check/test/UpdateCheckerTest.cpp
@@ -299,31 +299,126 @@ TEST(UpdateCheckerTest, ReplyAfterDestructionIsIgnored) {
EXPECT_EQ(h.saves, 0);
}
-TEST(UpdateCheckerTest, StartupPromptOnlyForConfirmedUpdate) {
+TEST(UpdateCheckerTest, DiscoveredUpdateAsksForAttention) {
Harness h;
auto c = h.Make();
- EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // nothing known
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone); // nothing known
c->Start(CheckMode::kIfStale);
- EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // still checking
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone); // checking
+ h.server.AnswerAll(true, "v2.1.9");
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kUpdateAvailable);
+}
+
+TEST(UpdateCheckerTest, OpeningThePageAcknowledgesTheUpdate) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.9");
+
+ int changes = 0;
+ QObject::connect(c.get(), &UpdateChecker::Changed, [&] { ++changes; });
+ c->Acknowledge();
+ EXPECT_EQ(changes, 1);
+ EXPECT_EQ(c->State().last_seen_update_version, "v2.1.9");
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone);
+
+ // Nothing new to acknowledge: no change, no write.
+ const int saves = h.saves;
+ c->Acknowledge();
+ EXPECT_EQ(changes, 1);
+ EXPECT_EQ(h.saves, saves);
+
+ // The page still offers the update itself.
+ EXPECT_EQ(Decide(*c->State().last_good), Verdict::kUpdateAvailable);
+}
+
+TEST(UpdateCheckerTest, SameUpdateRediscoveredStaysQuiet) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.9");
+ c->Acknowledge();
+
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.9");
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone);
+}
+
+TEST(UpdateCheckerTest, NewerUpdateAsksAgain) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.9");
+ c->Acknowledge();
+
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.10");
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kUpdateAvailable);
+ c->Acknowledge();
+ EXPECT_EQ(c->State().last_seen_update_version, "v2.1.10");
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone);
+}
+
+TEST(UpdateCheckerTest, FailedOrUnknownCheckNeverAsksForAttention) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
h.server.AnswerAll(false);
- EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // failed
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone);
- c->Start(CheckMode::kIfStale);
- h.server.AnswerPartial();
- EXPECT_FALSE(ShouldPromptOnStartup(c->State())); // partial, no update
+ c->Start(CheckMode::kForce);
+ h.server.AnswerPartial(); // rate-limited lookups, no newer release
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone);
+}
- c->Start(CheckMode::kIfStale);
- h.server.AnswerAll(true);
- EXPECT_TRUE(ShouldPromptOnStartup(c->State())); // confirmed update
+TEST(UpdateCheckerTest, FailureAfterAcknowledgementStaysQuiet) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.9");
+ c->Acknowledge();
- // A later failure does not unlearn a confirmed update.
c->Start(CheckMode::kForce);
h.server.AnswerAll(false);
- EXPECT_TRUE(ShouldPromptOnStartup(c->State()));
+ EXPECT_TRUE(c->State().last_attempt_failed);
+ EXPECT_EQ(c->State().last_seen_update_version, "v2.1.9");
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kNone);
+}
+
+TEST(UpdateCheckerTest, FailureBeforeAcknowledgementKeepsAttention) {
+ Harness h;
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.9");
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(false);
+ // A confirmed update is not unlearned by a failed refresh.
+ EXPECT_EQ(AttentionFor(c->State()), UpdateAttention::kUpdateAvailable);
+}
+
+TEST(UpdateCheckerTest, AcknowledgementPersistsAcrossRestart) {
+ Harness h;
+ {
+ auto c = h.Make();
+ c->Start(CheckMode::kForce);
+ h.server.AnswerAll(true, "v2.1.9");
+ c->Acknowledge();
+ }
+ auto restarted = h.Make();
+ EXPECT_EQ(restarted->State().last_seen_update_version, "v2.1.9");
+ EXPECT_EQ(AttentionFor(restarted->State()), UpdateAttention::kNone);
+
+ // Also across a rebuild, whose stored result is dropped.
+ auto rebuilt = h.Make({"v2.1.8", "def456"});
+ EXPECT_FALSE(rebuilt->State().last_good);
+ EXPECT_EQ(rebuilt->State().last_seen_update_version, "v2.1.9");
+ rebuilt->Start(CheckMode::kIfStale);
+ h.server.AnswerAll(true, "v2.1.9");
+ EXPECT_EQ(AttentionFor(rebuilt->State()), UpdateAttention::kNone);
}
-TEST(UpdateCheckerTest, StartupNeverPromptsForUnofficialOrWithdrawn) {
+TEST(UpdateCheckerTest, WithdrawnOrUnofficialNeverAskForAttention) {
UpdateSnapshot s;
SoftwareVersion r;
r.current_version = "v2.1.8";
@@ -332,9 +427,9 @@ TEST(UpdateCheckerTest, StartupNeverPromptsForUnofficialOrWithdrawn) {
r.commit_fact = RemoteFact::kNotFound;
s.last_good = r;
EXPECT_EQ(Decide(r), Verdict::kWithdrawnOrUnreleased);
- EXPECT_FALSE(ShouldPromptOnStartup(s));
+ EXPECT_EQ(AttentionFor(s), UpdateAttention::kNone);
s.last_good->tag_fact = RemoteFact::kConfirmed;
EXPECT_EQ(Decide(*s.last_good), Verdict::kUnofficialBuild);
- EXPECT_FALSE(ShouldPromptOnStartup(s));
+ EXPECT_EQ(AttentionFor(s), UpdateAttention::kNone);
}