Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/gui/ConnectionPanel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,9 @@ void ConnectionPanel::setConnected(bool connected)
clearPendingIcomCredentials();
}

if (connected)
m_startupProbe = false;

m_connected = connected;
m_disconnectBtn->setVisible(connected);
updateActionState();
Expand Down Expand Up @@ -2493,6 +2496,7 @@ void ConnectionPanel::onManualConnectClicked()
return;

m_manualConnectPending = true;
m_startupProbe = false;
setManualMessage(QStringLiteral("Checking %1…").arg(ip));
probeRadio(ip);
}
Expand All @@ -2503,12 +2507,33 @@ void ConnectionPanel::onManualAdvancedToggled(bool checked)
m_manualAdvancedWidget->setVisible(checked);
}

bool ConnectionPanel::reportStartupProbeFailure(const QString& reason)
{
// A startup probe is the one probe with nobody reading the manual page.
// MainWindow has covered the window with "Looking for your radio…", and it
// suppressed the no-saved-radio dialog popup precisely BECAUSE a radio is
// saved — so setManualMessage() alone writes the reason onto a page behind a
// dialog that will never open. The operator is left with a spinner and no
// route back to the connection UI. Hand the reason up instead.
if (!m_startupProbe)
return false;
m_startupProbe = false;
emit startupConnectUnavailable(reason);
return true;
}

void ConnectionPanel::probeRadio(const QString& ip, bool restoreSavedFamily)
{
const QString trimmedIp = ip.trimmed();
if (trimmedIp.isEmpty())
return;

// Latched, not assigned: the Icom keychain read below re-enters probeRadio()
// without restoreSavedFamily, and that second pass is still the same startup
// attempt. Cleared on a proven connect and by onManualConnectClicked().
if (restoreSavedFamily)
m_startupProbe = true;

// Interactive and automation probes keep the family currently selected by
// the operator. Startup is the exception: it has no current operator
// choice, so restoreSavedFamily asks the retained route which wire protocol
Expand All @@ -2532,6 +2557,8 @@ void ConnectionPanel::probeRadio(const QString& ip, bool restoreSavedFamily)
updateManualAdvancedVisibility();
setManualMessage("Choose a live source path before trying again.", true);
m_manualConnectPending = false;
reportStartupProbeFailure(
QStringLiteral("The saved source path for this radio is unavailable."));
return;
}

Expand Down Expand Up @@ -2573,6 +2600,8 @@ void ConnectionPanel::probeRadio(const QString& ip, bool restoreSavedFamily)
"radio. Check Network Control is ON in the radio's menu, then "
"enter the same credentials here."),
true);
reportStartupProbeFailure(
QStringLiteral("This Icom needs its network user name and password."));
return;
}
if (pass.isEmpty()) {
Expand Down Expand Up @@ -2603,6 +2632,8 @@ void ConnectionPanel::probeRadio(const QString& ip, bool restoreSavedFamily)
"set on the radio, then connect once to remember it."),
true);
panel->m_manualConnectPending = false;
panel->reportStartupProbeFailure(
QStringLiteral("No saved password for this Icom."));
return;
}
if (panel->m_manualIcomPassEdit
Expand Down
11 changes: 11 additions & 0 deletions src/gui/ConnectionPanel.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class ConnectionPanel : public QWidget, public IConnectionAutomation {
void setConnected(bool connected);
void setStatusText(const QString& text);
void probeRadio(const QString& ip, bool restoreSavedFamily = false);
// Reports a startup-probe bail upward as startupConnectUnavailable, and
// returns whether this was in fact a startup probe. No-op for interactive
// and automation probes, whose operator can already read setManualMessage.
bool reportStartupProbeFailure(const QString& reason);

// Radio families the "Connect by IP" page can dial. The manual page can no
// longer guess: a FlexRadio answers TCP/4992 and a Hermes-Lite 2 answers
Expand Down Expand Up @@ -120,6 +124,10 @@ public slots:
void retryDiscoveryRequested();
void networkDiagnosticsRequested();
void smartLinkLoginRequested(const QString& email, const QString& password);
// A startup auto-connect gave up before it could reach the radio. Carries
// the operator-facing reason, which would otherwise be stranded on the
// manual page (see reportStartupProbeFailure).
void startupConnectUnavailable(const QString& reason);

private slots:
void onConnectionModeClicked(int id);
Expand Down Expand Up @@ -298,6 +306,9 @@ private slots:
QPushButton* m_manualConnectBtn{nullptr};
QString m_manualProfileIp;
bool m_manualConnectPending{false};
// Set for the duration of a startup probe (probeRadio's restoreSavedFamily
// call), which is the one probe with no operator watching the manual page.
bool m_startupProbe{false};
QCheckBox* m_autoConnectCheck{nullptr};
QCheckBox* m_showDemoCheck{nullptr}; // RFC #4288: offer the demo entry

Expand Down
15 changes: 15 additions & 0 deletions src/gui/MainWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,21 @@ MainWindow::MainWindow(QWidget* parent)
}
});

// A startup auto-connect that gives up must hand the window back. The
// no-saved-radio popup above is suppressed whenever LastConnectedRadioSerial
// is set, so without this a saved radio that cannot be reached leaves the
// "Looking for your radio…" overlay up forever with no offered way into the
// connection dialog — the operator has to know the Settings menu item exists.
connect(m_connPanel, &ConnectionPanel::startupConnectUnavailable,
this, [this](const QString& reason) {
if (m_userDisconnected || m_radioModel.isConnected()) return;
setPanadapterConnectionAnimation(false);
m_connPanel->setStatusText(reason);
// show, not toggle: toggleConnectionDialog() would hide a dialog the
// operator had already opened while the probe was still in flight.
showConnectionDialog();
});

// Probe saved routed radio on startup
{
auto& s = AppSettings::instance();
Expand Down
156 changes: 156 additions & 0 deletions tests/startup_autoconnect_lockout_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Regression harness: a startup auto-connect that gives up must hand the window
// back to the operator.
//
// MainWindow suppresses its "no saved radio" connection-dialog popup whenever
// LastConnectedRadioSerial is set, and covers the window with a
// "Looking for your radio…" overlay instead. So when the saved radio cannot be
// reached, a bail that only calls setManualMessage() writes the reason onto a
// page nobody can see, behind a dialog that never opens — leaving a spinner and
// no offered route back into the connection UI.
//
// ConnectionPanel::startupConnectUnavailable is what closes that hole. These
// checks pin the contract MainWindow relies on: startup bails report upward,
// interactive ones stay silent (their operator is already reading the panel).

#include "TestSettingsProfile.h"
#include "core/AppSettings.h"
#include "core/backends/icom/IcomSettings.h"
#include "gui/ConnectionPanel.h"

#include <QAbstractButton>
#include <QApplication>
#include <QComboBox>
#include <QLineEdit>
#include <QSignalSpy>

#include <cstdio>
#include <string>

using namespace AetherSDR;

namespace {

int g_failed = 0;

void report(const char* name, bool ok, const std::string& detail = {})
{
std::printf("%s %-58s %s\n",
ok ? "[ OK ]" : "[FAIL]",
name,
detail.c_str());
if (!ok) {
++g_failed;
}
}

// Puts the panel on the manual page with the Icom family selected and no
// credentials staged — the state a first launch after a credential loss is in.
void selectIcomManualFamily(ConnectionPanel& panel)
{
if (auto* manualMode =
panel.findChild<QAbstractButton*>(QStringLiteral("connectionManualModeButton"))) {
manualMode->click();
}
auto* radioType =
panel.findChild<QComboBox*>(QStringLiteral("connectionManualRadioType"));
const int icomIndex = radioType
? radioType->findData(QString::fromLatin1(ConnectionPanel::kFamilyIcom))
: -1;
if (radioType && icomIndex >= 0) {
radioType->setCurrentIndex(icomIndex);
}
if (auto* user =
panel.findChild<QLineEdit*>(QStringLiteral("connectionManualIcomUser"))) {
user->clear();
}
if (auto* pass =
panel.findChild<QLineEdit*>(QStringLiteral("connectionManualIcomPassword"))) {
pass->clear();
}
QApplication::processEvents();
}

// A startup probe with no credentials must not fail silently: it is the only
// probe whose failure the operator cannot read off the manual page.
void checkStartupBailIsReportedUpward()
{
ConnectionPanel panel;
selectIcomManualFamily(panel);

QSignalSpy spy(&panel, &ConnectionPanel::startupConnectUnavailable);
panel.probeRadio(QStringLiteral("192.0.2.10"), /*restoreSavedFamily=*/true);
QApplication::processEvents();

report("startup probe with no credentials reports upward",
spy.count() == 1,
"emitted " + std::to_string(spy.count()) + " time(s)");

const QString reason = spy.isEmpty() ? QString()
: spy.at(0).at(0).toString();
report("reported reason is non-empty for the operator",
!reason.trimmed().isEmpty(),
reason.toStdString());
}

// The interactive path already shows its reason in the dialog the operator is
// looking at. Reporting upward there would pop the dialog they already have
// open — and MainWindow would overwrite the specific message with a status line.
void checkInteractiveBailStaysSilent()
{
ConnectionPanel panel;
selectIcomManualFamily(panel);

QSignalSpy spy(&panel, &ConnectionPanel::startupConnectUnavailable);
panel.probeRadio(QStringLiteral("192.0.2.10"), /*restoreSavedFamily=*/false);
QApplication::processEvents();

report("interactive probe stays silent",
spy.count() == 0,
"emitted " + std::to_string(spy.count()) + " time(s)");
}

// The latch must not leak across attempts: one startup probe is one report,
// and a later interactive probe must not inherit the startup flag.
void checkLatchDoesNotLeakIntoInteractiveProbe()
{
ConnectionPanel panel;
selectIcomManualFamily(panel);

QSignalSpy spy(&panel, &ConnectionPanel::startupConnectUnavailable);
panel.probeRadio(QStringLiteral("192.0.2.10"), /*restoreSavedFamily=*/true);
QApplication::processEvents();
const int afterStartup = spy.count();

panel.probeRadio(QStringLiteral("192.0.2.10"), /*restoreSavedFamily=*/false);
QApplication::processEvents();

report("startup latch clears after it reports",
afterStartup == 1 && spy.count() == afterStartup,
"startup=" + std::to_string(afterStartup)
+ " total=" + std::to_string(spy.count()));
}

} // namespace

int main(int argc, char** argv)
{
TestSettingsProfile settingsProfile(QStringLiteral("aether-startup-autoconnect-lockout-test"));
if (!settingsProfile.isValid()) {
return 1;
}
if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) {
qputenv("QT_QPA_PLATFORM", "offscreen");
}
QApplication app(argc, argv);
AppSettings::instance().load();
// A saved username would satisfy the credential gate this harness drives.
IcomSettings::setUsername(QString());
std::printf("Startup auto-connect lockout harness\n\n");

checkStartupBailIsReportedUpward();
checkInteractiveBailStaysSilent();
checkLatchDoesNotLeakIntoInteractiveProbe();

std::printf("\n%s\n", g_failed == 0 ? "All checks passed." : "FAILURES PRESENT.");
return g_failed == 0 ? 0 : 1;
}
17 changes: 17 additions & 0 deletions tests/tests.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -3643,6 +3643,23 @@ add_test(NAME connection_panel_size_test COMMAND connection_panel_size_test)
set_tests_properties(connection_panel_size_test PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")

# A startup auto-connect that gives up must reopen the connection dialog rather
# than leave the "Looking for your radio…" overlay up with no way back.
add_executable(startup_autoconnect_lockout_test
tests/startup_autoconnect_lockout_test.cpp
src/gui/ConnectionPanel.cpp
src/gui/FramelessResizer.cpp
src/gui/FramelessWindowTitleBar.cpp
)
target_include_directories(startup_autoconnect_lockout_test PRIVATE src tests)
target_link_libraries(startup_autoconnect_lockout_test PRIVATE
aethercore Qt6::Core Qt6::Network Qt6::Widgets Qt6::Test
)
set_target_properties(startup_autoconnect_lockout_test PROPERTIES AUTOMOC ON)
add_test(NAME startup_autoconnect_lockout_test COMMAND startup_autoconnect_lockout_test)
set_tests_properties(startup_autoconnect_lockout_test PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")

# FramelessResizer's clampManualResize()/windowOwnsChain() pure-logic
# helpers (#4827/#4829 review). offscreen never exercises the real manual
# resize path end-to-end (see FramelessResizer.h), so this is coverage for
Expand Down