Skip to content

fix(core): QRZ login broken end-to-end since #3990#4043

Merged
ten9876 merged 2 commits into
aethersdr:mainfrom
wa2n-code:fix/qrz-login-xml-root-element
Jul 5, 2026
Merged

fix(core): QRZ login broken end-to-end since #3990#4043
ten9876 merged 2 commits into
aethersdr:mainfrom
wa2n-code:fix/qrz-login-xml-root-element

Conversation

@wa2n-code

Copy link
Copy Markdown
Contributor

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

  • Built on Windows 11 (MSVC 2022, Qt 6.8.3) with qtkeychain staged via setup-qtkeychain.ps1
  • Radio Setup → QRZ tab → Test Login succeeds with a real QRZ.com account (previously always failed with "no session key")
  • 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)
  • 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

wa2n-code added 2 commits July 4, 2026 21:01
Every QRZ XML reply is wrapped in a root <QRZDatabase> element. Since
its name matched neither "Session" nor "Callsign", the loop fell
through to readElementText(SkipChildElements) on the ROOT element,
which reads to its own end tag — silently consuming and discarding
the entire <Session>/<Callsign> subtree before the loop ever revisits
it. Net effect: sessionKey/sessionError/info came back empty for every
login and lookup, regardless of whether the credentials were valid, so
Test Login (and real lookups) always reported "no session key".

Skip elements outside a recognized block instead of feeding them to
readElementText, so the root tag is passed over rather than consumed.

Untested — not built locally; PR aethersdr#3990 (aethersdr#3990) shipped this with no
parseXml test coverage against real root-wrapped QRZ XML, so this had
no regression test to catch it either. Principle IV.
…to the exe

Two gaps found while getting QRZ login working end-to-end on a Windows
source build:

- README's Windows 11 steps never ran scripts/setup/setup-qtkeychain.ps1,
  so a from-scratch build silently compiled out credential persistence
  (Qt6Keychain not found -> HAVE_KEYCHAIN undefined). Test Login worked
  regardless since it bypasses the keychain entirely, masking the gap.
- Even with qtkeychain staged and HAVE_KEYCHAIN on, a plain
  `cmake --build` dev run crashes with "qt6keychain.dll was not found":
  windeployqt (which the Windows installer CI job manually feeds the DLL
  through) isn't part of a local build, so nothing puts the DLL next to
  the exe.

Add the setup-qtkeychain.ps1 step to the README and a WIN32 POST_BUILD
copy in CMakeLists.txt so local Windows builds get working credential
persistence without a manual DLL copy. Principle IV.
@wa2n-code wa2n-code requested review from a team as code owners July 5, 2026 01:56
@jensenpat

Copy link
Copy Markdown
Collaborator

Need to split Windows qtkeychain CI into a separate issue. Thx

@jensenpat

Copy link
Copy Markdown
Collaborator

Verified — the fix is correct and the bug is real. readElementText() on the unrecognized <QRZDatabase> root consumed the entire document before <Session> was ever seen, so login has been broken end-to-end since #3990 (it escaped live testing because the test rig had no QRZ credentials; the cache/prefix paths masked it).

I added parseXml regression tests pinning the three live response shapes (login Session/Key, lookup Callsign+Session, session-timeout Error) in #4044 (stacked on this PR) and ran them both ways:

  • without this PR's guard: 6/6 fail (session key never parsed)
  • with the guard: all 53 checks pass

The Windows qtkeychain DLL staging + README steps also look right — and topical: the same missing-qtkeychain failure mode just bit our macOS test build (find_package QUIET silently compiles persistence out; see #4044 for the session-password usability fix that pairs with this).

Recommend merge. 💻 Generated with Claude Code (claude-fable-5 7/1/26), verification 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.

Thanks for the thorough writeup and the real-account verification, @wa2n-code — I traced all three claims through the code and they hold up.

The parseXml() fix is correct

I confirmed the root cause. Every QRZ response is wrapped in <QRZDatabase>, which is the first StartElement the loop sees. Since it matches neither "Session" nor "Callsign", the pre-fix code fed it straight to r.readElementText(QXmlStreamReader::SkipChildElements) (QrzClient.cpp:222) — which, per Qt semantics, reads until the root's </QRZDatabase> and discards every child, including the <Session><Key>…</Key></Session> block, before the loop can enter it. block never leaves Block::None, so sessionKey/info come back empty for every response. The block == Block::None guard is the right minimal fix: the root (and any other pre-block start tag) is stepped over via readNext() instead of consumed, so the loop reaches <Session>/<Callsign> intact. Nested elements inside those blocks are still handled by SkipChildElements.

CMake / README changes are sound and in scope

The WIN32 + EXISTS-guarded POST_BUILD copy targets third_party/qtkeychain/bin/qt6keychain.dll, which is exactly where scripts/setup/setup-qtkeychain.ps1 installs it ($OutDir\bin\qt6keychain.dll, script lines 25/104) — so the path is consistent, and the EXISTS guard makes it a safe no-op when the DLL isn't staged. Adding the setup step to the README's Windows flow is a reasonable doc fix. No AppSettings/RAII concerns; nothing outside the stated scope.

One suggestion (non-blocking): lock this in with a regression test

You correctly note this shipped because nothing exercised parseXml() against a root-wrapped response — and it couldn't, since parseXml is private static (QrzClient.h:76) and qrz_callsign_test.cpp never touches it. Since the method is already a pure static with no side effects, promoting it to public (or adding a friend test seam) and asserting that a realistic <QRZDatabase><Session><Key>…</Key></Session></QRZDatabase> payload yields a non-empty sessionKey would prevent a future token-loop refactor from silently reintroducing this. Worth doing given it was a total end-to-end breakage that passing CI didn't catch.

Nice catch on a genuinely broken-since-#3990 path. LGTM once you decide on the test.


🤖 aethersdr-agent · cost: $7.8837 · 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.

Thanks for the careful diagnosis here, @wa2n-code — this is a well-tracked root-cause fix, and the writeup made it easy to verify. I traced each change against the source; notes below.

The parseXml() fix is correct ✅

The diagnosis holds up. Every QRZ response is wrapped in a <QRZDatabase> root, which is the first StartElement the loop sees. With block == None it fell through to readElementText(SkipChildElements) on the root — which per Qt reads to the matching </QRZDatabase>, consuming the nested <Session>/<Callsign> blocks before the loop could inspect them. So sessionKey came back empty for every response, which matches the reported "no session key" on valid credentials.

Tracing the fixed loop confirms it: root <QRZDatabase>block == Nonecontinue, reader advances into <Session> → matched → block = Session, then <Key> is read via readElementText with the block set. Minimal and targeted. r.name() returning the local name means the default xmlns namespace on the root doesn't interfere, as before.

CMake + README changes are consistent ✅

  • The POST_BUILD copy reads third_party/qtkeychain/bin/qt6keychain.dll, which is exactly where setup-qtkeychain.ps1 installs it (CMAKE_INSTALL_PREFIX=$OutDir$OutDir\bin\qt6keychain.dll, confirmed at script line 104). The WIN32 AND EXISTS guard keeps it a no-op when keychain isn't staged.
  • The README step is the missing companion to the existing find_package(Qt6Keychain) path (CMakeLists.txt:127 already prepends third_party/qtkeychain to CMAKE_PREFIX_PATH), so the instructions now actually populate what the configure step looks for.

One suggestion (non-blocking)

You correctly note that parseXml() never saw a realistic root-wrapped response in tests — which is precisely how this shipped. A small regression test asserting sessionKey is populated from a <QRZDatabase><Session><Key>…</Key></Session></QRZDatabase> fixture (plus a <Callsign> lookup fixture) would lock this in cheaply. Heads-up that parseXml is private static (QrzClient.h:76) and tests/qrz_callsign_test.cpp doesn't currently touch QrzClient, so you'd need a test seam — a friend test class, or promoting the parse to a public static / free function. Worth it given this is the exact gap that let the bug through.

Scope is coherent — the CMake/README changes are a logically separate concern from the XML parse, but both serve the "QRZ login end-to-end" goal and are small, so bundling is reasonable. CI is green across all six checks. Nice work.


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

jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jul 5, 2026
…inciple XI.

macOS twin of the Windows DLL staging fix in aethersdr#4043: a staged
third_party/qtkeychain resolves via an absolute rpath into the build
checkout, so a bundle copied to another Mac (SFTP test deploys) fails
to load libqt6keychain and the app dies at launch.  Copy the dylib
into Contents/Frameworks post-build and add the standard
@executable_path/../Frameworks rpath at link time (linker-added rpath
keeps the ad-hoc signature valid; macdeployqt release DMGs are
unaffected).  Proven by hiding the staging dir and launching — the
app resolves the bundled copy.

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
@ten9876 ten9876 assigned ten9876 and unassigned ten9876 Jul 5, 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.

Good catch and a clean, well-diagnosed fix — thank you for chasing it down against a real QRZ.com account rather than stopping at "Test Login works." I traced parseXml against both real response shapes (login = <Session> only; lookup = <Callsign> then <Session> siblings under the <QRZDatabase> root) and the guard does exactly what you describe: the root wrapper is skipped instead of having readElementText swallow the whole document. The genuinely valuable side effect is that QRZ's real error strings (Username/password incorrect) now reach the user instead of the generic "no session key." The default xmlns is a non-issue since r.name() returns the local name. I also verified the build half end-to-end: setup-qtkeychain.ps1 installs to third_party/qtkeychain via CMAKE_INSTALL_PREFIX and builds --config Release (so the DLL name matches, no debug suffix), find_package prepends that same path, and the README renumbering is self-contained with Linux/macOS parity intact.

Nothing here is blocking — this fixes a real end-to-end breakage. Two things I'd like before merge, then a couple of nits.

Please add before merge

  1. A parseXml regression test. This is the one that matters: QRZ login was broken end-to-end and shipped precisely because no test drove parseXml against a root-wrapped response — and this PR fixes it without adding that test, so the exact failure can silently return on any future token-walk refactor. You already name the gap in the PR body; closing it is the difference between "demonstrated" and "demonstrated to stay fixed" (Principle VIII). parseXml is private static, so it needs a friend-test or a thin exposure. Assert sessionKey is extracted from a full <QRZDatabase version="1.34"><Session><Key>ABC123</Key></Session></QRZDatabase> login body, and sessionError from an error body (<Session><Error>Username/password incorrect</Error></Session>).

  2. Reset block on the closing tag (inline suggestion). Your new if (block == None) continue; guard is correct but never re-armed, because block is never set back to None when a <Session>/<Callsign> ends. It's latent today (login/lookup roots contain only those two children), but the moment the client issues a DXCC/bio query — or QRZ adds a top-level sibling — that element's children get read under the stale block. One line makes the guard actually load-bearing.

Nits (optional)

  • The DLL-copy guard couples "staged in third_party" to "DLL exists." If Qt6Keychain resolves from a system/vcpkg install, Qt6Keychain_FOUND is true but EXISTS(third_party/...) is false → no copy → the exact "qt6keychain.dll not found" crash you're fixing. Narrow (the README path stages third_party), but copying from the found target's own location — $<TARGET_FILE:Qt6Keychain::Qt6Keychain> — handles any provider and drops the hardcoded path. Inline note on the CMake block.
  • Co-locate the copy with the other bundled-DLL staging. Every other Windows DLL (hidapi, fftw3/fftw3f, ONNX, deepfilter) is copied in one clustered section with a COMMENT progress line; this one sits ~200 lines away with no COMMENT. Cheap to keep the pattern single-sourced.

Housekeeping: @jensenpat is already assigned (infrastructure tier owns CMakeLists.txt + *.md per CODEOWNERS, so this routes to them, not full maintainer) — leaving this review as the maintainer-side pass, not a claim on the merge. Commit correctly cites Principle IV. 73 — reviewed with Claude Code on behalf of KK7GWY.

Comment thread src/core/QrzClient.cpp
Comment thread CMakeLists.txt

@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 — this fixes a real end-to-end breakage (QRZ login has been non-functional since #3990), verified against a live QRZ.com account, with the parseXml root-wrapper fix traced against both real response shapes and the Windows keychain build/DLL path confirmed consistent. CI green across build/macOS/Windows.

Merging with two non-blocking follow-ups tracked from my review above, to fold into a quick follow-up PR rather than gate this one:

  • a parseXml regression test against a root-<QRZDatabase>-wrapped body (the guard that would keep this exact bug from silently recurring — Principle VIII);
  • the one-line block reset on EndElement so the new root-guard is re-armed for future non-Session/Callsign top-level siblings.

The $<TARGET_FILE:Qt6Keychain::Qt6Keychain> DLL-copy robustness tweak is optional polish. Thanks for the careful diagnosis. 73 de KK7GWY (reviewed with Claude Code)

@ten9876 ten9876 enabled auto-merge (squash) July 5, 2026 03:17
@ten9876 ten9876 merged commit 1e301dc into aethersdr:main Jul 5, 2026
6 checks passed
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 to jensenpat/AetherSDR that referenced this pull request Jul 5, 2026
…inciple XI.

macOS twin of the Windows DLL staging fix in aethersdr#4043: a staged
third_party/qtkeychain resolves via an absolute rpath into the build
checkout, so a bundle copied to another Mac (SFTP test deploys) fails
to load libqt6keychain and the app dies at launch.  Copy the dylib
into Contents/Frameworks post-build and add the standard
@executable_path/../Frameworks rpath at link time (linker-added rpath
keeps the ad-hoc signature valid; macdeployqt release DMGs are
unaffected).  Proven by hiding the staging dir and launching — the
app resolves the bundled copy.

💻 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.

PHONE applet: DEXP commands rejected on firmware v1.4.0.0

3 participants