Skip to content

Commit cdd5dbc

Browse files
w5jwpclaude
andauthored
Closes #3413 (phase 1 — see scope section below for what remains)
## Summary Adds **Help → Check for Updates…** — an on-demand update check that fires only when the operator explicitly requests it. This is the minimum viable slice of #3413 that gets the core network/comparison machinery in place without committing to a persistent-notification UX that hasn't been agreed upon yet. ## What this PR does ### New class: `UpdateChecker` (`src/core/UpdateChecker.h/.cpp`) - `GET https://api.github.com/repos/aethersdr/AetherSDR/releases/latest` with proper GitHub API headers including `User-Agent` and a 15 s transfer timeout (matches `WhatsNewDialog` precedent). - Parses `tag_name` from the JSON response, strips the leading `v`. - Compares against the running version using the existing `VersionNumber` class (CalVer `YY.M.patch[.hotfix]`). - Emits `updateAvailable(latestVersion)`, `upToDate(currentVersion)`, or `checkFailed()`. - `kReleasesPageUrl` is a public `static constexpr` in the header so the releases-page URL lives in exactly one place and is easy to update in the future. ### Changed: `MainWindow` - `UpdateChecker` instantiated in the constructor **before** `buildMenuBar()` is called — mirrors the existing `m_bandPlanMgr` pattern, eliminating any implicit ordering dependency on `buildUI()`. - **Help menu** gains **"Check for Updates…"** (positioned just before the final separator / About item). - **Update available** → `QMessageBox` titled _"AetherSDR Update Available"_ showing the available and current versions, with a **"View Latest Release"** button that opens the releases page in the system browser via `QDesktopServices`, and a **Close** button. - **Up to date** → `QMessageBox::information` _"AetherSDR is up to date (vX.Y.Z)"_. - **Check failed** (network error, 403, malformed JSON, unparseable tag) → `QMessageBox::warning` _"Could not reach GitHub. Check your connection and try again."_ — appropriate for an operator-initiated action where silent failure is indistinguishable from a hung click. - Both buttons have `autoDefault(false)` per widget guidelines. - Layout spacer injected into the `QMessageBox` grid to guarantee the full window title renders without truncation (`setMinimumWidth` is silently ignored by `QMessageBox` internally). - Stack-allocated `QMessageBox` + synchronous `exec()` — no heap leaks, no `WA_DeleteOnClose` needed. ## What this PR deliberately does NOT do This is **phase 1**. The following are explicitly out of scope: | Feature | Status | |---|---| | Automatic check on startup | Phase 2 | | Background periodic check | Phase 2 | | Persistent update badge/notification | Phase 2 — needs design agreement | | Check throttle / cooldown | Phase 2 | | AppSettings key for update preferences | Phase 2 | The check fires **only on explicit operator request**. Principle XIII. ## Phase 2 needs a design discussion before implementation Phase 2 will introduce an automatic check and a **persistent notification** visible without opening a menu. Before that is implemented, the team should agree on where that notification lives. The options currently on the table are: 1. **Title bar indicator** — a small button alongside PC Audio / PanLock. Always visible, but adds weight to an already-busy bar. 2. **Status bar badge** — an amber label/icon in the bottom strip. Low-profile but easy to miss. 3. **Blocking popup on launch** — guaranteed visibility, but disruptive during a QSO or in automated modes. 4. **Non-blocking toast / platform notification** — friendliest UX, but requires a custom widget or OS notification API. Resolving those questions in #3413 first is exactly why this PR ships phase 1 as a standalone change. ## Compliance - Follows `docs/style/dialog-patterns.md` (stack-alloc, `exec()`, no `WA_DeleteOnClose`, `autoDefault` disabled). - No `QSettings` — `AppSettings` used project-wide; nothing to persist in phase 1. - `kReleasesPageUrl` follows the `kPascalCase` constant naming convention from AGENTS.md. - Tested on **Debian 13 Trixie**. Squashed-from: #3468 Co-authored-by: W5JWP <281197059+w5jwp@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Pat Jensen <patjensen@gmail.com>
1 parent 84e9285 commit cdd5dbc

6 files changed

Lines changed: 130 additions & 1 deletion

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ set(CORE_SOURCES
549549
src/core/ProfileTransfer.cpp
550550
src/core/QsoRecorder.cpp
551551
src/core/FirmwareStager.cpp
552+
src/core/UpdateChecker.cpp
552553
src/core/OleCompoundFile.cpp
553554
src/core/CabExtractor.cpp
554555
src/core/RNNoiseFilter.cpp

src/core/UpdateChecker.cpp

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#include "UpdateChecker.h"
2+
#include "VersionNumber.h"
3+
4+
#include <QCoreApplication>
5+
#include <QJsonDocument>
6+
#include <QJsonObject>
7+
#include <QNetworkReply>
8+
#include <QNetworkRequest>
9+
#include <QUrl>
10+
11+
namespace AetherSDR {
12+
13+
static constexpr char kReleasesApiUrl[] =
14+
"https://api.github.com/repos/aethersdr/AetherSDR/releases/latest";
15+
16+
UpdateChecker::UpdateChecker(QObject* parent)
17+
: QObject(parent)
18+
{}
19+
20+
void UpdateChecker::checkNow()
21+
{
22+
if (m_inFlight) return;
23+
m_inFlight = true;
24+
25+
QNetworkRequest req{QUrl{QString(kReleasesApiUrl)}};
26+
req.setRawHeader("Accept", "application/vnd.github+json");
27+
req.setRawHeader("X-GitHub-Api-Version", "2022-11-28");
28+
req.setRawHeader("User-Agent",
29+
QByteArrayLiteral("AetherSDR/") + QCoreApplication::applicationVersion().toUtf8());
30+
req.setTransferTimeout(15000);
31+
32+
auto* reply = m_nam.get(req);
33+
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
34+
reply->deleteLater();
35+
m_inFlight = false;
36+
37+
if (reply->error() != QNetworkReply::NoError) {
38+
emit checkFailed();
39+
return;
40+
}
41+
42+
const auto doc = QJsonDocument::fromJson(reply->readAll());
43+
if (doc.isNull()) { emit checkFailed(); return; }
44+
45+
const QString tag = doc.object().value("tag_name").toString();
46+
if (tag.isEmpty()) { emit checkFailed(); return; }
47+
48+
const QString current = QCoreApplication::applicationVersion();
49+
const auto latest = VersionNumber::parse(tag);
50+
const auto running = VersionNumber::parse(current);
51+
52+
if (latest.isNull()) { emit checkFailed(); return; }
53+
54+
if (latest > running)
55+
emit updateAvailable(tag.startsWith('v') ? tag.mid(1) : tag);
56+
else
57+
emit upToDate(current);
58+
});
59+
}
60+
61+
} // namespace AetherSDR

src/core/UpdateChecker.h

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#pragma once
2+
3+
#include <QNetworkAccessManager>
4+
#include <QObject>
5+
6+
namespace AetherSDR {
7+
8+
// Checks the GitHub releases API for a newer AetherSDR version.
9+
// kReleasesPageUrl is the browser-facing page; the API endpoint lives in the .cpp.
10+
class UpdateChecker : public QObject {
11+
Q_OBJECT
12+
public:
13+
static constexpr char kReleasesPageUrl[] =
14+
"https://github.com/aethersdr/AetherSDR/releases/latest";
15+
16+
explicit UpdateChecker(QObject* parent = nullptr);
17+
void checkNow();
18+
19+
signals:
20+
void updateAvailable(const QString& latestVersion);
21+
void upToDate(const QString& currentVersion);
22+
void checkFailed();
23+
24+
private:
25+
QNetworkAccessManager m_nam;
26+
bool m_inFlight = false;
27+
};
28+
29+
} // namespace AetherSDR

src/gui/MainWindow.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@
188188
#include <QJsonObject>
189189
#include <QJsonArray>
190190
#include "core/VersionNumber.h"
191+
#include "core/UpdateChecker.h"
192+
#include <QDesktopServices>
191193
#include <QPointer>
192194
#include <QTextEdit>
193195
#include <QPlainTextEdit>
@@ -1655,6 +1657,36 @@ MainWindow::MainWindow(QWidget* parent)
16551657
m_bandPlanMgr = new BandPlanManager(this);
16561658
m_bandPlanMgr->loadPlans();
16571659

1660+
// UpdateChecker — must be created before buildMenuBar() which references it
1661+
m_updateChecker = new UpdateChecker(this);
1662+
connect(m_updateChecker, &UpdateChecker::updateAvailable, this, [this](const QString& ver) {
1663+
const QString current = QCoreApplication::applicationVersion();
1664+
QMessageBox box(this);
1665+
box.setWindowTitle("AetherSDR Update Available");
1666+
box.setIcon(QMessageBox::Information);
1667+
box.setText(QString("AetherSDR v%1 is available.").arg(ver));
1668+
box.setInformativeText(QString("You are running v%1.").arg(current));
1669+
QPushButton* viewBtn = box.addButton("View Latest Release", QMessageBox::ActionRole);
1670+
viewBtn->setAutoDefault(false);
1671+
QPushButton* closeBtn = box.addButton(QMessageBox::Close);
1672+
closeBtn->setAutoDefault(false);
1673+
// Force minimum width via layout spacer — setMinimumWidth() is ignored by QMessageBox
1674+
if (auto* grid = qobject_cast<QGridLayout*>(box.layout()))
1675+
grid->addItem(new QSpacerItem(480, 0, QSizePolicy::Minimum, QSizePolicy::Fixed),
1676+
grid->rowCount(), 0, 1, grid->columnCount());
1677+
box.exec();
1678+
if (box.clickedButton() == viewBtn)
1679+
QDesktopServices::openUrl(QUrl(UpdateChecker::kReleasesPageUrl));
1680+
});
1681+
connect(m_updateChecker, &UpdateChecker::upToDate, this, [this](const QString& ver) {
1682+
QMessageBox::information(this, "Check for Updates",
1683+
QString("AetherSDR is up to date (v%1).").arg(ver));
1684+
});
1685+
connect(m_updateChecker, &UpdateChecker::checkFailed, this, [this]() {
1686+
QMessageBox::warning(this, "Check for Updates",
1687+
"Could not reach GitHub. Check your connection and try again.");
1688+
});
1689+
16581690
buildMenuBar();
16591691
buildUI();
16601692
#ifdef Q_OS_WIN
@@ -9986,6 +10018,9 @@ void MainWindow::buildMenuBar()
998610018
});
998710019
dlg.exec();
998810020
});
10021+
helpMenu->addAction("Check for Updates...", this, [this]() {
10022+
m_updateChecker->checkNow();
10023+
});
998910024
helpMenu->addSeparator();
999010025
helpMenu->addAction("About AetherSDR", this, [this]{
999110026
auto* dlg = new QDialog(this);

src/gui/MainWindow.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ class NetworkDiagnosticsDialog;
8888
class AgcCalibrationDialog;
8989
class MemoryDialog;
9090
class PropDashboardDialog;
91+
class UpdateChecker;
9192
class TxBandDialog;
9293
class AetherDspDialog;
9394
class MqttSettingsDialog;
@@ -920,6 +921,7 @@ private slots:
920921
bool m_waitingForFirstPanadapterFrame{false};
921922
QString m_panadapterConnectionAnimationLabel;
922923
ShortcutManager m_shortcutManager;
924+
UpdateChecker* m_updateChecker{nullptr};
923925

924926
#ifdef HAVE_RADE
925927
RADEEngine* m_radeEngine{nullptr};

src/gui/WhatsNewDialog.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "WhatsNewDialog.h"
2+
#include "core/UpdateChecker.h"
23
#include "core/VersionNumber.h"
34
#include "core/AppSettings.h"
45

@@ -299,7 +300,7 @@ void WhatsNewDialog::buildUI(const QString& lastSeenVersion,
299300
upgradeBtn->setCursor(Qt::PointingHandCursor);
300301
upgradeBtn->setStyleSheet(secondaryButtonStyle());
301302
connect(upgradeBtn, &QPushButton::clicked, this, [this] {
302-
QDesktopServices::openUrl(QUrl("https://github.com/aethersdr/AetherSDR/releases/latest"));
303+
QDesktopServices::openUrl(QUrl(AetherSDR::UpdateChecker::kReleasesPageUrl));
303304
close();
304305
});
305306
footerLayout->addWidget(upgradeBtn, 0, Qt::AlignCenter);

0 commit comments

Comments
 (0)