diff --git a/src/gui/ConnectionPanel.cpp b/src/gui/ConnectionPanel.cpp index 56107bb0a..c67a2c104 100644 --- a/src/gui/ConnectionPanel.cpp +++ b/src/gui/ConnectionPanel.cpp @@ -1397,6 +1397,9 @@ void ConnectionPanel::setConnected(bool connected) clearPendingIcomCredentials(); } + if (connected) + m_startupProbe = false; + m_connected = connected; m_disconnectBtn->setVisible(connected); updateActionState(); @@ -2493,6 +2496,7 @@ void ConnectionPanel::onManualConnectClicked() return; m_manualConnectPending = true; + m_startupProbe = false; setManualMessage(QStringLiteral("Checking %1…").arg(ip)); probeRadio(ip); } @@ -2503,12 +2507,33 @@ void ConnectionPanel::onManualAdvancedToggled(bool checked) m_manualAdvancedWidget->setVisible(checked); } +void 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; + m_startupProbe = false; + emit startupConnectUnavailable(reason); +} + 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 when a bail is reported, 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 @@ -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; } @@ -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()) { @@ -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 @@ -2721,6 +2752,8 @@ void ConnectionPanel::probeRadio(const QString& ip, bool restoreSavedFamily) QStringLiteral("Could not resolve \"%1\". Check the name, or enter the " "radio's IP address instead.").arg(trimmedIp), true); + reportStartupProbeFailure( + QStringLiteral("Could not resolve \"%1\".").arg(trimmedIp)); return; } resolved = hostInfo.addresses().first(); @@ -2765,7 +2798,11 @@ void ConnectionPanel::probeRadio(const QString& ip, bool restoreSavedFamily) "radio is powered, idle, and reachable on UDP port 1024.") .arg(trimmedIp), true); + reportStartupProbeFailure( + QStringLiteral("No Hermes-Lite 2 answered at %1.").arg(trimmedIp)); } else if (probe == Hl2ProbeResult::NotAttempted) { + // probeHermesLite2() has already reported its own reason, upward + // included; only the button needs restoring here. resetManualConnectButton(); } return; @@ -2834,11 +2871,15 @@ ConnectionPanel::Hl2ProbeResult ConnectionPanel::probeHermesLite2( QStringLiteral("AetherSDR could not use that VPN source path. " "Try Auto or choose another path."), true); + reportStartupProbeFailure( + QStringLiteral("The saved source path for this radio is unavailable.")); } else { setManualMessage( QStringLiteral("Could not open a UDP socket to probe for a " "Hermes-Lite 2: %1").arg(hpsdr.errorString()), true); + reportStartupProbeFailure( + QStringLiteral("Could not open a UDP socket to reach the Hermes-Lite 2.")); } return Hl2ProbeResult::NotAttempted; } @@ -2876,6 +2917,8 @@ ConnectionPanel::Hl2ProbeResult ConnectionPanel::probeHermesLite2( : QStringLiteral("“%1” has no IPv4 address, and a Hermes-Lite 2 " "is reachable over IPv4 only.").arg(ip), true); + reportStartupProbeFailure( + QStringLiteral("Could not resolve “%1” to an IPv4 address.").arg(ip)); return Hl2ProbeResult::NotAttempted; } } @@ -2891,6 +2934,8 @@ ConnectionPanel::Hl2ProbeResult ConnectionPanel::probeHermesLite2( QStringLiteral("Could not send a discovery request to %1: %2") .arg(dest.toString(), hpsdr.errorString()), true); + reportStartupProbeFailure( + QStringLiteral("Could not send a discovery request to %1.").arg(dest.toString())); return Hl2ProbeResult::NotAttempted; } @@ -2951,6 +2996,9 @@ ConnectionPanel::Hl2ProbeResult ConnectionPanel::probeHermesLite2( QStringLiteral("The Hermes-Lite 2 at %1 is already in use by another client " "and can't be shared.").arg(ip), true); + reportStartupProbeFailure( + QStringLiteral("The Hermes-Lite 2 at %1 is in use by another client.") + .arg(ip)); return Hl2ProbeResult::Answered; } @@ -2987,6 +3035,8 @@ void ConnectionPanel::probeFlexRadio(const QString& trimmedIp, const RadioBindSe m_manualConnectPending = false; m_manualConnectBtn->setText("Connect by IP"); updateActionState(); + reportStartupProbeFailure( + QStringLiteral("The saved source path for this radio is unavailable.")); return; } @@ -3004,6 +3054,8 @@ void ConnectionPanel::probeFlexRadio(const QString& trimmedIp, const RadioBindSe "address and try Advanced only if your VPN exposes multiple adapters.") .arg(trimmedIp), true); + reportStartupProbeFailure( + QStringLiteral("No radio responded at %1.").arg(trimmedIp)); } }); @@ -3161,6 +3213,8 @@ void ConnectionPanel::probeFlexRadio(const QString& trimmedIp, const RadioBindSe m_manualConnectPending = false; m_manualConnectBtn->setText("Connect by IP"); updateActionState(); + reportStartupProbeFailure( + QStringLiteral("Could not reach %1: %2").arg(trimmedIp, sock->errorString())); }); } diff --git a/src/gui/ConnectionPanel.h b/src/gui/ConnectionPanel.h index 144a11fad..da62dd526 100644 --- a/src/gui/ConnectionPanel.h +++ b/src/gui/ConnectionPanel.h @@ -120,6 +120,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); @@ -184,6 +188,13 @@ private slots: }; Hl2ProbeResult probeHermesLite2(const QString& ip, const RadioBindSettings& bindSettings); void probeFlexRadio(const QString& ip, const RadioBindSettings& bindSettings); + // Reports a startup-probe bail upward as startupConnectUnavailable. Every + // probeRadio() exit that does not end in a connect request calls this — + // including the ones inside probeHermesLite2() and probeFlexRadio() — so + // that a saved radio which cannot be reached, for whatever reason, hands + // the window back. No-op for interactive and automation probes, whose + // operator can already read setManualMessage. + void reportStartupProbeFailure(const QString& reason); void resetManualConnectButton(); // Re-activate the body layout after a page change. The overlap this used to // guard against — the Advanced section expanding, or the result line @@ -298,6 +309,13 @@ 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. + // Cleared in three distinct places, none redundant: by + // reportStartupProbeFailure() once a bail has been handed up, by + // setConnected(true) when the probe led to a real connection, and by + // onManualConnectClicked() when the operator takes over mid-flight. + bool m_startupProbe{false}; QCheckBox* m_autoConnectCheck{nullptr}; QCheckBox* m_showDemoCheck{nullptr}; // RFC #4288: offer the demo entry diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 90c0a4997..27a211ec6 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -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(); diff --git a/tests/startup_autoconnect_lockout_test.cpp b/tests/startup_autoconnect_lockout_test.cpp new file mode 100644 index 000000000..6fc5e69aa --- /dev/null +++ b/tests/startup_autoconnect_lockout_test.cpp @@ -0,0 +1,192 @@ +// 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 +#include +#include +#include +#include + +#include +#include + +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 given family selected. +void selectManualFamily(ConnectionPanel& panel, const char* family) +{ + if (auto* manualMode = + panel.findChild(QStringLiteral("connectionManualModeButton"))) { + manualMode->click(); + } + auto* radioType = + panel.findChild(QStringLiteral("connectionManualRadioType")); + const int index = radioType ? radioType->findData(QString::fromLatin1(family)) : -1; + if (radioType && index >= 0) { + radioType->setCurrentIndex(index); + } + QApplication::processEvents(); +} + +// Icom with no credentials staged — the state a first launch after a +// credential loss is in, and a bail that needs no network at all. +void selectIcomManualFamily(ConnectionPanel& panel) +{ + selectManualFamily(panel, ConnectionPanel::kFamilyIcom); + if (auto* user = + panel.findChild(QStringLiteral("connectionManualIcomUser"))) { + user->clear(); + } + if (auto* pass = + panel.findChild(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 HL2 arm bails after a directed Metis probe rather than before it, so it +// is the path a wiring gap would most plausibly hide in: a saved Hermes-Lite 2 +// that is powered off at boot must hand the window back like any other bail. +// +// This row sends one discovery datagram from an ephemeral UDP port to +// TEST-NET-1 (RFC 5737, never routable) and waits out the probe's own 600 ms +// deadline. There is no peer. Whether the datagram is dropped, refused by the +// local network stack, or the socket cannot bind at all, every one of those +// outcomes is a startup bail and must report upward — so the assertion holds +// regardless of the runner's network. +void checkHl2NoAnswerStartupBailIsReportedUpward() +{ + ConnectionPanel panel; + selectManualFamily(panel, ConnectionPanel::kFamilyHl2); + + QSignalSpy spy(&panel, &ConnectionPanel::startupConnectUnavailable); + panel.probeRadio(QStringLiteral("192.0.2.10"), /*restoreSavedFamily=*/true); + QApplication::processEvents(); + + report("startup HL2 probe with no answer 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("HL2 reason names the address that did not answer", + reason.contains(QStringLiteral("192.0.2.10")), + 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(); + checkHl2NoAnswerStartupBailIsReportedUpward(); + checkInteractiveBailStaysSilent(); + checkLatchDoesNotLeakIntoInteractiveProbe(); + + std::printf("\n%s\n", g_failed == 0 ? "All checks passed." : "FAILURES PRESENT."); + return g_failed == 0 ? 0 : 1; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index 68bcf4e76..9e73eb1b9 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -3643,6 +3643,28 @@ 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. +# +# Socket disclosure: the HL2 row lets ConnectionPanel::probeHermesLite2() bind +# an ephemeral UDP port (0) on 0.0.0.0 and send one Metis discovery datagram to +# 192.0.2.10 (TEST-NET-1). No peer, no listener; bounded by the probe's own +# 600 ms deadline. A bind failure is itself a bail the test accepts. +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