Skip to content

feat(gui): QRZ callsign lookup — CW decoder contact card, lookup dialog, 7-day cache#3990

Merged
ten9876 merged 3 commits into
aethersdr:mainfrom
jensenpat:feat/qrz-callsign-lookup
Jul 4, 2026
Merged

feat(gui): QRZ callsign lookup — CW decoder contact card, lookup dialog, 7-day cache#3990
ten9876 merged 3 commits into
aethersdr:mainfrom
jensenpat:feat/qrz-callsign-lookup

Conversation

@jensenpat

@jensenpat jensenpat commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

What this adds

A framework for pulling station contact information from QRZ.com and popping a themed contact card wherever AetherSDR identifies a station — starting with the CW decoder, designed so the upcoming AI SSB voice-callsign decoder pops the exact same card.

Working CW: automatic screen-pop

When the CW decoder prints a station identifying itself — DE KI6BCJ KI6BCJ — a compact contact card pops beside the decoded text with callsign, license-class chip, name, location, grid/county, and the station photo. The doubled call is the decode-confidence gate: one garbled character breaks the repeat and correctly suppresses the spot. An ID sent at the very end of a transmission (… 73 DE K1AB K1AB then silence) still spots via a 3 s settle timer.

CW decoder contact card

View → Callsign Lookup… (Ctrl+Shift+L)

Manual lookups get a PersistentDialog with the large card variant, cache-age status line, and a force-refresh button.

Callsign Lookup dialog

Radio Setup → QRZ tab

Enable toggle, username, password (OS keychain via QtKeychain — never in the settings file), async Test Login, cache count + Clear Cache.

QRZ setup tab

Architecture

Piece Role
core/QrzClient QRZ XML API: session-key login (agent=AetherSDR<ver>), key reuse, transparent one-shot re-login + retry on expiry (per-call retry guard — no login loops)
core/CallsignInfo Provider-neutral contact struct + JSON round-trip
core/CallsignLookupService Singleton facade: 7-day TTL disk cache (2×TTL prune, 1000-entry cap), photo download + cache, in-flight coalescing (a busy net asks QRZ once per station), stale-entry fallback when offline
core/CwCallsignSpotter Rolling-window DE <call> <call> watcher; per-call re-emit suppression (120 s)
core/QrzLookupSettings One AppSettings["QrzLookup"] nested blob (Principle V)
gui/CallsignCard Reusable themed card (accent bar / photo / details), Compact + Large variants; ThemeManager tokens, live theme-switch, a11y names, callsign is keyboard-activatable and opens the QRZ page
gui/MainWindow_Callsign.cpp Subsystem wiring (new sibling TU per the #3351 decomposition)

Agent automation bridge

New qrz verb group (docs updated): status / cached <call> / lookup <call> / spottext <text>spottext feeds the real spotter → service → card path, so the end-to-end screen-pop is provable with no radio and no QRZ account (seeded cache). Bonus: invoke setCurrentText/setCurrentIndex now drive QTabBar, making deferred setup-dialog tabs reachable from the bridge for the first time.

Testing

  • tests/qrz_callsign_test.cpp — 36/36 PASS: callsign regex, spotter stream behavior (char-by-char arrival, settle timer, garbled-repeat rejection, dedup, cross-station splice guard), JSON round-trip, 7-day TTL rule.
  • Proven live via the bridge against the FLEX-8400M (RX only, radio was Available/not in use): slice → CW mode, qrz spottext "CQ CQ CQ DE KI6BCJ KI6BCJ K" → card popped with cached data + photo (screenshots above are those captures); lookup dialog driven via menu action + callsignLookupInput/callsignLookupGo; QRZ tab via the new tab verb; enable-toggle round-trip verified through qrz status.
  • Full ctest: all pass except theme_manager_test, which fails identically on main on this machine (it loads the operator's live user settings — pre-existing, unrelated).

Notes for review

  • Session keys are memory-only; QRZ password lives only in the keychain. XML from QRZ is parsed with QXmlStreamReader; callsigns are regex-validated at the boundary before any network request (Principle VII).
  • Non-subscriber QRZ accounts get limited fields — the card degrades gracefully (name/callsign only).
  • Cache serves stale entries when a refresh fails, so a net roster keeps working offline.

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat

…og, 7-day cache — Principle V.

A framework for pulling station contact info from QRZ.com and popping a
themed contact card wherever AetherSDR identifies a station:

Core (src/core/):
- QrzClient — QRZ XML API client: session-key login (agent=AetherSDR),
  key reuse, transparent one-shot re-login + retry on session expiry,
  per-call retry guard so a flapping session can never loop.
- CallsignInfo — provider-neutral contact struct (name/location/grid/
  class/photo/QSL flags) with JSON round-trip for the disk cache.
- CallsignLookupService — app-wide facade: lazy on-disk cache (7-day
  TTL, 2×TTL prune, 1000-entry cap, debounced atomic-ish save), station
  photo download + cache, in-flight coalescing so a busy net never asks
  QRZ twice, stale-entry fallback when offline, keychain-backed
  credentials (password never touches the settings file).
- CwCallsignSpotter — rolling-window watcher for "DE <call> <call>"
  in decoded CW; requires the doubled call as a decode-confidence gate,
  holds end-of-window matches for a 3 s settle so end-of-transmission
  IDs still spot, per-call re-emit suppression.
- QrzLookupSettings — enabled/username as one AppSettings["QrzLookup"]
  nested blob (Principle V); password in the OS keychain.

GUI (src/gui/):
- CallsignCard — reusable themed card (accent bar / photo / details),
  Compact for the CW decode panel, Large for the lookup dialog; the
  same visual will serve the SSB voice-callsign decoder. Callsign is
  keyboard-activatable and opens the station's QRZ page.
- CW decode panel hosts a Compact card beside the decoded text; wired
  via MainWindow_Callsign.cpp through routeCwDecoderOutput() so the
  spotter follows the active-slice applet and clears across reroutes.
- View → Callsign Lookup… (Ctrl+Shift+L) — PersistentDialog with
  uppercase-normalized input, cache-age status line, force-refresh.
- Radio Setup → QRZ tab — enable toggle, username, keychain password,
  async Test Login, cache count + Clear Cache.

Automation bridge:
- New `qrz` verb group: status / cached <call> / lookup <call> /
  spottext <text> (feeds the real spotter → card path; with a seeded
  cache this proves the end-to-end screen-pop with no QRZ account).
- invoke setCurrentText/setCurrentIndex now drive QTabBar, reaching
  deferred setup-dialog tabs (previously unreachable from the bridge).
- docs/automation-bridge.md updated for both.

Tests: tests/qrz_callsign_test.cpp — 36 checks over the callsign regex,
spotter stream behavior (chunked arrival, settle timer, garble/dedup
rejection), CallsignInfo JSON round-trip, and the 7-day TTL rule.

Proven live via the bridge against the FLEX-8400M (RX only): CW-mode
spottext → card pop with cached data + photo; lookup dialog render;
QRZ tab render; enable-toggle round-trip through the settings blob.

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
@jensenpat jensenpat self-assigned this Jul 3, 2026
@jensenpat jensenpat marked this pull request as ready for review July 3, 2026 05:24
@jensenpat jensenpat requested review from a team as code owners July 3, 2026 05:24
…ith 5 s timeout — Principle IX.

Two upgrades to the QRZ contact card, both proven live via the bridge:

Distance & bearing:
- CallsignLookupService stamps great-circle distance + initial bearing
  onto every emitted CallsignInfo (transient — never cached, since the
  operator's position changes). Station position preference: exact QRZ
  lat/lon → grid-square center → DXCC country center (marked "~").
- Own position: radio GPS fix → radio grid locator (same preference as
  the PSK Reporter map) → zero-config fallback: the radio callsign's own
  QRZ record / cache entry supplies the operator's grid when the radio
  has no GPS. GPS always overrides the record-derived position.
- Card meta row renders "2,514 mi @ 067°" (locale-aware mi/km, PSK-map
  bearing format, "~" prefix for country-center estimates).

Country fallback (cty.dat prefix data):
- CtyDatParser now captures each entity's lat/lon from cty.dat (stored
  west-positive in the file; flipped east-positive) and grows an
  entityForCallsign() convenience. DxccColorProvider exposes its parser
  so cty.dat is parsed exactly once app-wide.
- When QRZ can't answer — network/provider failure, no credentials, or
  no reply within 5 s (kPrefixFallbackMs) — the service emits a
  country-level stand-in card: country, continent, CQ zone, approximate
  distance/bearing from the entity center, with a "prefix" provenance
  chip. Deliberately never cached; the full QRZ card replaces it
  whenever the real answer lands. Surfaced only when honest data exists
  (Principle IX): the CW screen-pop gate is now canResolve() =
  credentials || cached || prefix-resolvable.
- Lookup dialog explains the stand-in in its status line.

Bridge: `qrz status` reports hasOwnLocation (docs updated).

Tests (all pass): cty.dat sample parse with longitude flip, prefix →
entity resolution, CM97→FN31 distance/bearing sanity.

Live proof (FLEX-8400M, RX only, no QRZ credentials on this box):
own position adopted from the radio callsign's cached record
(hasOwnLocation:true); cached W1AW card shows "FN31pr · Hartford ·
2,514 mi @ 067°"; uncached G4ABC pops the England prefix card
"EU · CQ 14 · ~5,231 mi @ 033°".

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
@jensenpat

Copy link
Copy Markdown
Collaborator Author

Added: distance & bearing + country fallback (commit ff58ded)

Two follow-on upgrades, per operator direction:

Distance & bearing on every card

Great-circle distance + initial bearing from the operator's position, on both card variants (locale-aware mi/km). Station position: exact QRZ lat/lon → grid center → DXCC country center (~ marks the coarse estimate). Own position: radio GPS → radio grid → the radio callsign's own QRZ record (zero-config for radios without GPS; GPS overrides when present).

Cached W1AW, exact grid distance:

W1AW card with distance

Country fallback when QRZ can't answer (5 s timeout)

On network/provider failure, missing credentials, or no QRZ reply within 5 s, the card falls back to cty.dat prefix data — country, continent, CQ zone, approximate distance/bearing — with a prefix provenance chip. Never cached; the full QRZ card replaces it whenever the real answer lands. CtyDatParser now captures per-entity lat/lon (flipping cty.dat's west-positive longitude), shared from DxccColorProvider so cty.dat parses once.

Uncached G4ABC with no credentials — instant England prefix card:

G4ABC prefix fallback card

Proof

  • Unit tests extended (cty.dat parse + longitude flip, prefix resolution, CM97→FN31 distance/bearing sanity) — all pass.
  • Live via the bridge (FLEX-8400M, RX only, no QRZ credentials on the test box): qrz status reports hasOwnLocation:true adopted from the radio callsign's cached record; both cards above are bridge captures.
  • qrz status now reports hasOwnLocation (automation docs updated).

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely built feature — thanks @jensenpat. The architecture is clean and the AetherSDR conventions are all respected: config is one nested-JSON blob under AppSettings["QrzLookup"] (Principle V), the password goes to the OS keychain rather than the settings file, callsigns are regex-validated at the boundary before any request, network lookups coalesce in-flight and serve stale-on-failure, the photo fetch pins NoLessSafeRedirectPolicy, and the spotter/service split keeps the SSB path in mind. QObject ownership, QPointer guards, and deleteLater() on replies are all handled correctly, and CI is green across the board. Tests are thorough.

A few things worth addressing:

1. Set Qt::PlainText on the contact-card data labels (defensive, matches existing convention)

CallsignCard's data labels — m_nameLabel, m_locationLabel, m_metaLabel, and the message in showError() — display text sourced straight from the QRZ record (<name_fmt>, <nickname>, <addr2>, <county>, etc.), which are operator-editable free-text fields. QLabel defaults to Qt::AutoText, so a value that trips Qt::mightBeRichText() renders as HTML from a network source — an integrity/appearance issue at minimum, and a possible beacon vector via markup like <img src=…>.

This codebase already guards exactly this case for remote directory data — src/gui/KiwiSdrApplet.cpp calls setTextFormat(Qt::PlainText) on every label that shows server-provided text (lines 250, 258, 279, 300, 309, 317). Recommend the same on the card's data labels. The callsign label itself is regex-validated so it's lower risk, but the name/location/meta/error labels carry unvalidated provider text.

2. Confirm QRZ-on-by-default is intended

QrzLookupSettings::enabled() returns true when the key is absent, so the CW spotter is live for existing users immediately on upgrade. In practice this only pops a local cty.dat country-level card until credentials are configured (no network without setup, since onCwCallsignSpotted gates on canResolve), so it's benign — just flagging that a new subsystem defaulting on is a product choice worth a conscious nod.

3. (Optional) Cap photo download size

fetchPhoto()'s reply handler does reply->readAll() into memory with a 20 s transfer timeout but no byte ceiling. QRZ image URLs are provider-controlled so risk is low, but a bounded read (or a Content-Length sanity check) would harden it against an oversized/hostile image.

None of these block the feature — #1 is the one I'd want in before merge to stay consistent with the KiwiSDR label handling. Solid contribution.


🤖 aethersdr-agent · cost: $7.9345 · model: claude-opus-4-8

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely built feature — thanks for the thorough PR, @jensenpat. I read through the whole subsystem and it lands cleanly on AetherSDR conventions: password in the OS keychain (never the settings file), config as one nested JSON blob under AppSettings["QrzLookup"] (Principle V), a provider-neutral CallsignInfo with JSON round-trip, request coalescing + stale-serve in the service facade, and a genuinely reusable themed CallsignCard with solid a11y (keyboard-activatable callsign link, accessible names). The doubled-call decode-confidence gate and the settle-timer for end-of-transmission IDs are a clever, well-commented design, and the qrz spottext bridge verb making the end-to-end pop provable with no radio/account is excellent. All CI green.

A few small things, none blocking:

1. Photo download has no size cap (CallsignLookupService::fetchPhoto). The reply body is written straight to disk with only a 20 s transfer timeout — no maximum byte limit. The image URL comes from the QRZ <image> field (third-party-controlled), so this is an untrusted-input boundary. A hostile/misconfigured URL could stream an arbitrarily large file into the cache dir. Consider rejecting on an implausible Content-Length, or capping bytes written (a few MB is plenty for a station photo).

2. Shape-validation claim is slightly broader than the code. The notes say "callsigns are regex-validated at the boundary before any network request (Principle VII)." That holds for the spotter path (regex-gated) and the lookup dialog (isLikelyCallsign before lookup), but CallsignLookupService::lookup() and QrzClient::lookup() only check normalized(call).isEmpty() — the automation-bridge qrz lookup <call> path reaches the network with any uppercased/trimmed string. It's URL-encoded via QUrlQuery, so not injectable, but gating lookup() on Callsigns::isLikelyCallsign() would match the stated contract and avoid pointless QRZ round-trips on garble.

3. (Confirm, not a bug) QrzLookupSettings::enabled() defaults to "True", so on a fresh install with no credentials the CW screen-pop is active and will auto-pop country-level cty.dat cards. That's harmless (offline, no network without creds) and probably intended — just flagging the default-on behavior in case it wasn't deliberate.

Everything else — QRZ session-key relogin/retry guard, cache TTL + prune/cap, prefix fallback, own-location precedence (GPS → own QRZ grid), theme-switch handling — looks correct and well-reasoned. Nice work.


🤖 aethersdr-agent · cost: $8.6846 · model: claude-opus-4-8

@ten9876 ten9876 self-assigned this Jul 4, 2026

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: request changes

Well-architected feature (password correctly in the OS keychain, HTTPS API, single-flight queue, cty.dat fallback) — but a multi-agent + adversarial-verify pass turned up several confirmed correctness and security bugs. Each inline comment carries a fix (suggestion or sketch).

Blocking:

  1. Portable/prefixed callsigns break permanently — cache/in-flight keyed on the queried form but removed/cached on QRZ's returned base call → dead lookups + permanent cache miss.
  2. Tabbing through the QRZ password field before the async keychain read completes silently deletes the stored password (data loss, trivially hit).
  3. <lat> without <lon> → bogus (lat, 0) → wrong distance/bearing + corrupts the adopted home QTH.
  4. Unbounded profile-photo fetch — no size cap + GUI-thread decode (decompression-bomb/OOM/freeze), and http/cross-host redirects allowed (SSRF-lite).
  5. qrz lookup bridge verb = unbounded authenticated QRZ network trigger (no shape gate / rate limit).

Should fix: 6. no negative caching → re-queries QRZ every 120 s for not-found calls; 7. async-keychain race drops the first CW spot after startup.

Maintainer decision: 8. the feature ships ON for every existing user (enabled() defaults True) — recommend default-off / gate on credentials.

Quality: 9. contact-card a11y (interactive QLabel role + live-update announce); 10. a robustness bundle (retry-set leak, photo TTL pruning, atomic cache write, GUI-thread I/O, unbounded spotter map, per-char regex rebuild).

The architecture is sound — these are edge/security/robustness gaps. I can push fixes for the safe confirmed set (1–5, retry-leak) if you'd like.

Comment thread src/core/CallsignLookupService.cpp Outdated
Comment thread src/gui/RadioSetupDialog.cpp
Comment thread src/core/QrzClient.cpp
Comment thread src/core/CallsignLookupService.cpp Outdated
Comment thread src/core/AutomationServer.cpp
Comment thread src/core/CallsignLookupService.cpp
Comment thread src/core/CallsignLookupService.cpp
Comment thread src/core/QrzLookupSettings.h
Comment thread src/gui/CallsignCard.cpp
Comment thread src/core/QrzClient.cpp
… guard, geo/photo/bridge hardening (aethersdr#3990)

Addresses the confirmed correctness + security findings from review.

- Portable/prefixed callsigns: thread the queried call through
  QrzClient::lookupSucceeded(call, info) and key the service's in-flight/cache
  state on it, not on QRZ's returned base info.call — the mismatch leaked the
  in-flight entry (permanently dead lookup) and missed the cache for any
  W1AW/P / DL/KI6BCJ query.
- Credential loss: the Radio Setup QRZ password field's editingFinished no
  longer calls savePassword("") (which deletes the keychain entry) before the
  async keychain read has completed — guarded on a shared loaded flag.
- Geo: QRZ parseXml requires BOTH <lat> and <lon>; a valid lat with a missing
  lon no longer yields a bogus (lat, 0) that skews distance/bearing and could
  corrupt the adopted home QTH.
- Photo fetch: https-only (no http/cross-host SSRF-lite), an 8 MB download cap
  (abort on downloadProgress), and a 4096-px decode-dimension cap in
  CallsignCard (decompression-bomb / GUI-thread-freeze defense).
- Bridge: the `qrz lookup` verb now shape-gates on isLikelyCallsign so an agent
  can't drive unbounded authenticated QRZ queries over arbitrary tokens.
- QrzClient: clear m_retriedAfterRelogin on the network-error and login-fail
  paths so a leaked retry flag can't suppress the one-shot re-login recovery.

Deferred to the author/maintainer (see review): negative caching, the
async-keychain first-spot deferral, the enabled()-defaults-on product call, the
card a11y role/announce, and the remaining robustness items (photo TTL pruning,
atomic cache write, GUI-thread cache I/O, unbounded spotter map, per-char regex).

Both the app and qrz_callsign_test build/pass.

Fixes Are Demonstrated (Principle XI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ten9876

ten9876 commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Pushed fixes for the confirmed correctness + security findings (commit `e4cc7186`). App + `qrz_callsign_test` build/pass locally.

Fixed:

  1. Portable/prefixed callsigns — threaded the queried call through `QrzClient::lookupSucceeded(call, info)` and keyed the service's in-flight/cache state on it (not QRZ's base `info.call`), so `W1AW/P`/`DL/KI6BCJ` no longer dead-lock the in-flight set or miss the cache.
  2. Credential loss — the password field's `editingFinished` no longer calls `savePassword("")` (which deletes the keychain entry) before the async read completes.
  3. Geo — `parseXml` now requires both `` and ``; no more bogus `(lat, 0)`.
  4. Photo fetch — https-only, 8 MB download cap (abort), and a 4096-px decode-dimension cap in `CallsignCard` (SSRF-lite + decompression-bomb defense).
  5. Bridge — `qrz lookup` shape-gates on `isLikelyCallsign`.
  6. (partial) retry-set leak — `m_retriedAfterRelogin` cleared on the network-error and login-fail paths.

Deferred (your call / larger changes), left as review notes: 6 negative caching, 7 async-keychain first-spot deferral, 8 the `enabled()` defaults-ON product decision (maintainer), 9 card a11y role/announce, and the rest of the #10 robustness bundle (photo TTL pruning, atomic cache write, GUI-thread cache I/O, unbounded spotter map, per-char regex). Happy to take any of these in a follow-up.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving after the review-follow-up fixes (portable-call keying, credential-delete guard, geo/photo/bridge hardening, retry-leak); app + qrz_callsign_test build/pass locally, all threads resolved. Per maintainer direction, shipping with QrzLookup enabled-by-default as-is (finding #8 noted for a possible follow-up). Set to auto-merge on CI.

@ten9876 ten9876 enabled auto-merge (squash) July 4, 2026 15:42
@ten9876 ten9876 merged commit ba15046 into aethersdr:main Jul 4, 2026
5 checks passed
ten9876 pushed a commit that referenced this pull request Jul 5, 2026
## What was broken

PR #3990 added QRZ callsign lookup, but the login path was broken
end-to-end — verified by testing against a real QRZ.com account.

### 1. Every QRZ XML response failed to parse (`src/core/QrzClient.cpp`)

`QrzClient::parseXml()` walks `QXmlStreamReader` tokens, special-casing
`<Session>` and `<Callsign>` start tags. Every other start tag falls
through to `r.readElementText(QXmlStreamReader::SkipChildElements)`.

The problem: every QRZ response is wrapped in a root `<QRZDatabase>`
element. That root tag is the *first* `StartElement` the loop sees, and
it doesn't match `"Session"` or `"Callsign"` — so the loop calls
`readElementText(SkipChildElements)` on the **root itself**. Per Qt's
docs that reads "until the corresponding `EndElement`", i.e. it consumes
and discards the entire rest of the document (including the nested
`<Session><Key>…</Key></Session>` block) before the loop ever gets a
chance to inspect it.

Net effect: `sessionKey`/`sessionError`/`info` come back empty for
*every* login and lookup response, valid credentials or not, so every
login attempt reported `"QRZ login rejected (no session key)"`.

Fix: skip start tags encountered before entering a recognized block
instead of feeding them to `readElementText`, so the root wrapper is
passed over rather than consumed.

Note: `tests/qrz_callsign_test.cpp` (added in #3990) covers callsign
regex/spotter logic but never exercises `parseXml()` against a realistic
root-wrapped response, which is how this shipped.

### 2. Windows source builds silently compiled out credential
persistence, and even with it enabled, the app couldn't find the DLL

Found while confirming fix #1 on a real Windows 11 build:

- The README's "Windows 11" build steps never run
`scripts/setup/setup-qtkeychain.ps1`, so `find_package(Qt6Keychain)`
fails silently (`REQUIRE_KEYCHAIN` defaults `OFF`), `HAVE_KEYCHAIN`
never gets defined, and the QRZ password is never saved —
`CallsignLookupService::savePassword()`/`loadPasswordFromKeychain()`
become no-ops. The Setup dialog's "Test Login" button still worked
because it bypasses the keychain and tests raw typed credentials
directly, which masked the gap: real lookups (`View → Callsign Lookup`)
always got `AuthFailed` (empty password) and silently fell back to
offline `cty.dat` prefix data, appearing as "QRZ.com unavailable".
- After staging qtkeychain and confirming `Qt6Keychain found` in the
CMake configure log, running straight from the build directory still
failed with "qt6keychain.dll was not found" — `windeployqt` (which the
Windows installer CI job manually feeds the DLL through, see
`.github/workflows/windows-installer.yml`) isn't part of a plain local
build, so nothing put the DLL next to the exe.

Fix: add the `setup-qtkeychain.ps1` step to the README's Windows
instructions, and add a `WIN32`-guarded `POST_BUILD` step in
`CMakeLists.txt` that copies `qt6keychain.dll` next to the built exe
when present, so a local dev build gets working persistence without a
manual copy.

## Test plan

- [x] Built on Windows 11 (MSVC 2022, Qt 6.8.3) with qtkeychain staged
via `setup-qtkeychain.ps1`
- [x] Radio Setup → QRZ tab → Test Login succeeds with a real QRZ.com
account (previously always failed with "no session key")
- [x] Password persists across closing/reopening the Setup dialog
(previously lost every time — confirmed root cause was the missing
keychain DLL/build step, not app logic)
- [x] View → Callsign Lookup → enter callsign → Lookup returns real
QRZ.com data (previously always fell back to "QRZ.com unavailable"
prefix-only data)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jul 5, 2026
…l regression tests — Principle VIII.

Two gaps surfaced by live testing on the keychain-less test-Mac build:

1. Session-password fallback. In a build without QtKeychain (or when the
   keychain read fails — locked keychain, ACL denial on an unsigned test
   binary), a password typed in Radio Setup → QRZ was never handed to
   QrzClient at all: savePassword() only warned, so lookups fell back to
   prefix cards even seconds after the operator entered valid
   credentials. CallsignLookupService now keeps a memory-only
   m_sessionPassword and applies it whenever the keychain can't supply
   one — persistence and usability are separate concerns. Never written
   to disk; a restart still requires re-entry when no keychain exists.

2. Parser regression tests. The aethersdr#4043 root-element bug (readElementText
   on <QRZDatabase> swallowed the whole response; login broken end-to-end
   since aethersdr#3990) shipped because parseXml had zero test coverage.
   ParsedResponse/parseXml are now public-for-tests, and
   qrz_callsign_test pins the three live response shapes: login
   (Session/Key), lookup (Callsign + Session), and session-timeout
   (Error, no Key). Verified the tests catch the original bug: against
   the pre-aethersdr#4043 parser they fail 6/6; with the aethersdr#4043 guard, all pass.

Stacked on aethersdr#4043 (requires its parser guard for the new tests to pass).

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
ten9876 pushed a commit that referenced this pull request Jul 5, 2026
…l regression tests (stacked on #4043) (#4044)

**Stacked on #4043 — merge that first.** This branch contains #4043's
commits plus one of mine; after #4043 merges, the diff reduces to my
commit alone.

## What live testing on a keychain-less build surfaced

Deploying a test build whose worktree had never run
`scripts/setup/setup-qtkeychain.sh` (so `find_package(Qt6Keychain
QUIET)` silently compiled persistence out) exposed two gaps:

### 1. Typed password unusable without a keychain
In a keychain-less build — or when the keychain read fails (locked
keychain, ACL denial on an unsigned test binary) — a password typed in
**Radio Setup → QRZ** was never handed to `QrzClient` at all:
`savePassword()` only logged a warning. Lookups fell back to country
prefix cards seconds after the operator entered valid credentials, which
reads as "lookup is broken".

`CallsignLookupService` now keeps a **memory-only session password** and
applies it whenever the keychain can't supply one. Persistence and
usability are separate concerns: without a keychain the password still
works for the whole session and simply has to be re-entered after a
restart (the QRZ tab's Test Login already worked, since it uses the
field values directly). Never written to disk.

### 2. Zero test coverage let the #4043 bug ship
The `<QRZDatabase>` root-element bug (login broken end-to-end since
#3990) shipped because `parseXml` had no tests.
`ParsedResponse`/`parseXml` are now public-for-tests with a comment
saying why, and `qrz_callsign_test` pins the three live response shapes:

- login success (`Session`/`Key`)
- lookup success (`Callsign` + `Session`)
- session timeout (`Error`, no `Key`)

**Verified the tests catch the original bug** (Principle VIII/XI):
against the pre-#4043 parser they fail 6/6; with #4043's guard, all 53
checks pass.

## Testing
- `qrz_callsign_test`: ALL PASS with #4043; 6 failures without it
(regression coverage proven both ways).
- Root-caused from pulled test-Mac logs: `WRN aether.qrz: QRZ password
persistence unavailable: built without QtKeychain` →
prefix-fallback-only lookups; this PR + a properly staged qtkeychain
build fix both symptoms.

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture
by @jensenpat
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants