diff options
| author | saturneric <[email protected]> | 2026-09-24 23:25:25 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-09-24 23:25:25 +0200 |
| commit | 1c95ebf2cbb4d4c841141f568eb394f4091b6ba7 (patch) | |
| tree | edc4ae7cddc999d9500cc10dd6c83821c14fd9e0 | |
| parent | feat(update): add robust GitHub update checking (diff) | |
| download | modules-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
| -rw-r--r-- | src/m_ver_check/ReleaseCheck.cpp | 4 | ||||
| -rw-r--r-- | src/m_ver_check/ReleaseCheck.h | 3 | ||||
| -rw-r--r-- | src/m_ver_check/UpdateChecker.cpp | 31 | ||||
| -rw-r--r-- | src/m_ver_check/UpdateChecker.h | 20 | ||||
| -rw-r--r-- | src/m_ver_check/UpdateTab.cpp | 9 | ||||
| -rw-r--r-- | src/m_ver_check/VersionCheckingModule.cpp | 26 | ||||
| -rw-r--r-- | src/m_ver_check/test/ReleaseCheckTest.cpp | 8 | ||||
| -rw-r--r-- | src/m_ver_check/test/UpdateCheckerTest.cpp | 125 |
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); } |
