diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8706cb8..d0a921e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -115,19 +115,41 @@ jobs:
- name: It is actually universal
run: |
set -euo pipefail
- # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle,
- # so client/build/hamdeck-qml does not exist - the tools report "no such file",
- # which reads as a build that produced nothing rather than a path that is wrong.
- BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml
- [ -f "$BIN" ] || BIN=client/build/hamdeck-qml
+ # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle
+ # is "HamDeck Remote.app" and the executable inside carries the same name, not
+ # the CMake target name - so every path here has a SPACE in it and must stay
+ # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no
+ # such file", which reads as a build that produced nothing.
+ BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote"
+ # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and
+ # it would now hide the thing most likely to break: if OUTPUT_NAME stops
+ # applying, the bundle is called hamdeck-qml.app again and a fallback would
+ # quietly build, test and ship the badly-named app that this rename exists to
+ # prevent. On APPLE the output is always a bundle, so a miss is a real fault.
+ [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; }
echo "architectures: $(lipo -archs "$BIN")"
lipo -archs "$BIN" | grep -q arm64 || { echo "missing arm64"; exit 1; }
lipo -archs "$BIN" | grep -q x86_64 || { echo "missing x86_64"; exit 1; }
+ # ⚠️ A BUILD IS NOT A BUNDLE. Everything above proves the binary is universal and
+ # runs; none of it looks at what macOS actually shows the operator. 0.1.29 passed
+ # every one of those checks and shipped an app Finder called "hamdeck-qml" with the
+ # blank generic icon, plus no microphone usage string - which SIGKILLs the app on
+ # the first PTT. This is the check that was missing.
+ - name: Is it a properly formed Mac application
+ run: python3 tools/check_macos_bundle.py "client/build/HamDeck Remote.app"
+
- name: Run it
run: |
- # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle,
- # so client/build/hamdeck-qml does not exist - the tools report "no such file",
- # which reads as a build that produced nothing rather than a path that is wrong.
- BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml
- [ -f "$BIN" ] || BIN=client/build/hamdeck-qml
+ # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle
+ # is "HamDeck Remote.app" and the executable inside carries the same name, not
+ # the CMake target name - so every path here has a SPACE in it and must stay
+ # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no
+ # such file", which reads as a build that produced nothing.
+ BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote"
+ # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and
+ # it would now hide the thing most likely to break: if OUTPUT_NAME stops
+ # applying, the bundle is called hamdeck-qml.app again and a fallback would
+ # quietly build, test and ship the badly-named app that this rename exists to
+ # prevent. On APPLE the output is always a bundle, so a miss is a real fault.
+ [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; }
QT_QPA_PLATFORM=offscreen "$BIN" --selftest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 607b7e5..6d28ffb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -553,11 +553,18 @@ jobs:
- name: Run the binary
run: |
- # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle,
- # so client/build/hamdeck-qml does not exist - the tools report "no such file",
- # which reads as a build that produced nothing rather than a path that is wrong.
- BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml
- [ -f "$BIN" ] || BIN=client/build/hamdeck-qml
+ # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle
+ # is "HamDeck Remote.app" and the executable inside carries the same name, not
+ # the CMake target name - so every path here has a SPACE in it and must stay
+ # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no
+ # such file", which reads as a build that produced nothing.
+ BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote"
+ # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and
+ # it would now hide the thing most likely to break: if OUTPUT_NAME stops
+ # applying, the bundle is called hamdeck-qml.app again and a fallback would
+ # quietly build, test and ship the badly-named app that this rename exists to
+ # prevent. On APPLE the output is always a bundle, so a miss is a real fault.
+ [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; }
QT_QPA_PLATFORM=offscreen "$BIN" --selftest
# ⚠️ ASK THE BINARY, NOT THE FLAG. CMAKE_OSX_ARCHITECTURES is a request, and if Qt
@@ -567,15 +574,30 @@ jobs:
- name: It is actually universal
run: |
set -euo pipefail
- # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle,
- # so client/build/hamdeck-qml does not exist - the tools report "no such file",
- # which reads as a build that produced nothing rather than a path that is wrong.
- BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml
- [ -f "$BIN" ] || BIN=client/build/hamdeck-qml
+ # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle
+ # is "HamDeck Remote.app" and the executable inside carries the same name, not
+ # the CMake target name - so every path here has a SPACE in it and must stay
+ # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no
+ # such file", which reads as a build that produced nothing.
+ BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote"
+ # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and
+ # it would now hide the thing most likely to break: if OUTPUT_NAME stops
+ # applying, the bundle is called hamdeck-qml.app again and a fallback would
+ # quietly build, test and ship the badly-named app that this rename exists to
+ # prevent. On APPLE the output is always a bundle, so a miss is a real fault.
+ [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; }
echo "architectures: $(lipo -archs "$BIN")"
lipo -archs "$BIN" | grep -q arm64 || { echo "missing arm64"; exit 1; }
lipo -archs "$BIN" | grep -q x86_64 || { echo "missing x86_64"; exit 1; }
+ # ⚠️ A BUILD IS NOT A BUNDLE. Everything above proves the binary is universal and
+ # runs; none of it looks at what macOS actually shows the operator. 0.1.29 passed
+ # every one of those checks and shipped an app Finder called "hamdeck-qml" with the
+ # blank generic icon, plus no microphone usage string - which SIGKILLs the app on
+ # the first PTT. This is the check that was missing.
+ - name: Is it a properly formed Mac application
+ run: python3 tools/check_macos_bundle.py "client/build/HamDeck Remote.app"
+
# ⚠️ A TEMPORARY KEYCHAIN, not the login keychain. The runner is shared
# infrastructure; importing a signing identity into the default keychain leaves it
# for whatever runs next. This one is created, used, and destroyed with the job.
@@ -631,7 +653,7 @@ jobs:
if: ${{ env.APPLE_CERT_P12 != '' }}
run: |
set -euo pipefail
- APP=client/build/hamdeck-qml.app
+ APP="client/build/HamDeck Remote.app"
[ -d "$APP" ] || { echo "no .app was built at $APP"; exit 1; }
macdeployqt "$APP" -qmldir=client/qml
# ⚠️ --deep signs the frameworks macdeployqt just copied in. Signing only the
@@ -644,6 +666,13 @@ jobs:
codesign --verify --deep --strict --verbose=2 "$APP"
echo "APP=$APP" >> "$GITHUB_ENV"
+ # ⚠️ AND AGAIN ON THE SIGNED BUNDLE. macdeployqt rewrites the bundle after the
+ # first check ran - it copies frameworks in and edits Info.plist - so the thing
+ # verified above is not the thing that goes into the DMG.
+ - name: Still a properly formed Mac application after macdeployqt
+ if: ${{ env.APPLE_CERT_P12 != '' }}
+ run: python3 tools/check_macos_bundle.py "$APP"
+
# ⚠️ A DMG WITH AN APPLICATIONS ALIAS - the idiomatic macOS install, and safer than a
# zip in practice: an unzipped .app tends to get run from Downloads, where quarantine
# behaves differently and the app is one cleanup away from vanishing. A DMG steers
diff --git a/CARRYOVER.md b/CARRYOVER.md
index 7f6935a..c63b85a 100644
--- a/CARRYOVER.md
+++ b/CARRYOVER.md
@@ -52,11 +52,40 @@ Auth: session cookie, also accepted as `?token=` or `Bearer`. Since v3.4.14 stat
audio require a session. `web_admin_only=true` makes `/` serve the admin page and 404s the old
browser rig UI.
+🔴 **TRAILING SLASHES ARE TRIMMED ON `/api/` PATHS — `ApiServer.cs:764-766`:**
+
+```csharp
+if (path.StartsWith("/api/")) { var trimmed = path.TrimEnd('/'); ... }
+```
+
+**The Stream Deck sends them.** Its amp tune button requests `/api/tune/amp/`, and the C++ host
+matched that against the PREFIX route rather than the exact one — the not-configured catch-all,
+which answers 200 and never tunes. The button reported success and did nothing, independent of
+any permission.
+
+⚠️ **A route inventory cannot see this.** `AUDIT-CSHARP.md` ticked `/api/tune/amp` because the
+route exists, and the 71/74 sweep drove clean paths. How a path is MATCHED is part of the
+contract, not an implementation detail. Normalise where the request is built, so the gates and
+the router agree — at the router alone, `/api/ptt/on/` skips `IsTransmitRoute` and still
+dispatches.
+
⚠️ **`/api/tune` is the rig's INTERNAL ATU and is the wrong tuner for this station.** The right
one is `/api/tune/tgxl`. Keep them separate and name them in any confirmation.
⚠️ **`/api/tune/amp` refuses every remote caller** (`AmpTuneOrDeny(isLocal)`). Do not expose it
remotely; a button that always errors is worse than a missing one.
+⚠️ **AND IT ANSWERS 200 WHEN IT REFUSES.** `AmpTuneOrDeny` returns an error *object*, not an
+error status, so a Stream Deck button reads the refusal as success: green tick, no tune. That
+is not a detail - it is why this went unnoticed for weeks after the host moved boxes.
+
+📌 **The C++ host deliberately diverges here, 09/01/2026.** `isLocal` was the right test on the
+reference host *because it ran on the station PC*, so loopback proved an operator was present
+and all 44 Stream Deck buttons hitting `localhost:5001` were local. Once the rig moved to its
+own box, loopback started proving the caller was on the **rig** box - the one place nobody
+sits - and amp tune became unreachable from the operating position. The C++ host therefore
+asks **who**, not **where**: the loopback console, or a session whose account carries
+`is_station`. It refuses with **403**. See `tools/amp_gate_check.sh`.
+
---
## 3. Audio — the whole reason a C++ port is interesting
@@ -181,7 +210,8 @@ Both lived only in the WPF host and were never ported to Linux. Check for more o
needs a second receiver or a net report.
- **WPF cannot be cross-compiled.** `Microsoft.NET.Sdk.WindowsDesktop` does not exist for Linux.
(Irrelevant to a C++ client, but it is why the .NET client is built on a Windows CI runner.)
-- **Amp tune is local-only** — see §2.
+- **Amp tune is local-only** — see §2. (On the C++ host: local console *or* an `is_station`
+ account, and the refusal is a 403. The restriction did not go away; the question changed.)
---
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 55604e7..1899a27 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -91,6 +91,13 @@ add_test(NAME cw COMMAND test_cw)
add_executable(test_transmit_gate tests/test_transmit_gate.cpp src/transmit_routes.cpp)
add_test(NAME transmit_gate COMMAND test_transmit_gate)
+# ⚠️ Drives the REAL binary over HTTP, because the amp gate's failure mode was a
+# refusal served as HTTP 200 - which every unit test and every route inventory
+# read as success while the Stream Deck button sat dead. Needs the host built.
+add_test(NAME amp_gate COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/amp_gate_check.sh
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
+set_tests_properties(amp_gate PROPERTIES DEPENDS transmit_gate)
+
add_executable(test_remote_active tests/test_remote_active.cpp src/auth.cpp)
target_link_libraries(test_remote_active PRIVATE OpenSSL::Crypto)
add_test(NAME remote_active COMMAND test_remote_active)
diff --git a/WIP.md b/WIP.md
index 3f5df18..53a1005 100644
--- a/WIP.md
+++ b/WIP.md
@@ -1264,3 +1264,250 @@ FTDX-101 emits before trusting every one.
- Bandmap→QSY (the reverse direction, HTTP :54321 in the C#) is **not** built. Ask before
building it: it is unauthenticated remote control of the VFO and the C# bound it to the
whole LAN with `Allow-Origin: *`.
+
+---
+
+## 09/01/2026 — the amp tune button, and why it was never "yanked"
+
+**Symptom:** the Stream Deck amp tune button does nothing. **Cause:** it has been refused
+since the rig moved to its own box, and the refusal was served as **HTTP 200**, which a deck
+button reads as success. Green tick, no carrier, no complaint, for weeks.
+
+The restriction was NOT added here. It is the reference host's, ported faithfully —
+`Services/ApiServer.cs`:
+
+ private object? AmpTuneOrDeny(bool isLocal)
+ => isLocal ? _amp.Tune() : ... "Amp tune is only available when connected locally."
+
+`isLocal` was correct *there* because the C# host ran on the station PC, so loopback proved
+an operator was present and all 44 deck buttons hitting `localhost:5001` were local. The gate
+never broke. It came to prove the wrong thing: loopback on the rig box means the caller is on
+the rig box, which is the one place nobody sits.
+
+**Fix:** ask WHO, not WHERE. Amp tune needs the loopback console, or a session whose account
+carries `is_station` **and** `can_transmit`. Refusals are 403.
+
+⚠️ **`is_station` is deliberately not implied by `can_transmit`.** "May key the rig, with a
+hand on it" and "may start a ten-second unattended carrier into an amplifier" are different
+claims. Default false, granted by an explicit admin act, so nothing gains it by upgrading.
+
+⚠️ **And `can_transmit` is required on top**, found by reading the live user list rather than
+assuming: the `pusher` account has `can_transmit=false`, and amp tune predates
+`IsTransmitRoute` so it is gated separately. Without that term, a station grant would have
+handed a carrier to an account explicitly denied transmit.
+
+### The test, and the hole in the first draft of the test
+`tools/amp_gate_check.sh` drives the real binary over HTTP on both listeners, refuses to run
+against anything that is not a simulator, and is wired into ctest.
+
+⚠️ **Its first version passed against the injected bug.** Step 3 asserted only "not 403", and
+the bug being guarded against refuses with **200** — so the assertion could not tell the
+working build from the broken one. It now checks the body came from the amp route. This is
+the same failure the fix itself addresses, reproduced inside its own test within the hour.
+
+### Closed 09/01/2026 — granted to `wa0o`
+Joe: the pusher logs in as **`wa0o`**, which already held `can_transmit`, so this was the
+station grant alone and widened nothing else. Config edited in place (backup
+`config.json.bak-station-*`, temp+rename, every unknown key preserved), host restarted, and
+the pusher reconnected on its own.
+
+ joe tx=True station=False
+ listener tx=False station=False
+ pusher tx=False station=False
+ wa0o tx=True station=True
+
+⚠️ **The last step is a button press, and it is Joe's.** `rig_connected` went true during this
+work, and `/api/tune/amp` keys the transmitter for ten seconds - so it was NOT fired from here
+to "confirm". The binary is proven by `amp_gate_check.sh` against the simulator on both build
+hosts; the live path is proven by pressing the button.
+
+ joe admin tx station=false
+ listener -- station=false
+ pusher -- station=false <- tx denied
+ wa0o tx station=false
+
+Deployed to the VM: build `41d38d6ba96c`, 16/16 tests green there.
+
+`/api/auth/status` now carries `is_station`, so a client can grey the button out rather than
+show a live one that answers 403 - and so the right can be confirmed without keying an amp.
+
+
+### 🔴 The grant that kept vanishing — an admin write flushes the WHOLE user list
+
+Granting `wa0o` the station right in the config file and restarting did not work, twice, and
+the second failure explained the first.
+
+1. `main.cpp` called `AddUser` without `is_station`. The `= false` default argument made that
+ compile cleanly, so **every startup dropped the right**. The file said the operator had it;
+ the running host said they did not.
+2. Worse, it did not just fail to load - it **erased the grant**. `persist_users` mirrors the
+ in-memory user list back over `config.json` on any admin write, so removing a temporary
+ account rewrote every user from memory, where `is_station` was already false. The grant was
+ overwritten by the cleanup step of the check that was verifying it.
+
+⚠️ **An admin write persists ALL users, not the one being changed.** A hand-edit to
+`config.json` on a running host survives only until the next admin call. Grant through the API,
+or edit and restart before anything else touches a user.
+
+⚠️ **`AddUser` has no default arguments now.** A missing right is a compile error, not a silent
+false. Removing them immediately surfaced seven call sites.
+
+⚠️ **`ctest` passed while the build was FAILING** during this work - it ran the stale binaries
+from the previous build. A green suite after a red build means nothing; read the build result.
+
+Live state, verified in the running host AND on disk after a flush:
+
+ joe tx=True station=False
+ listener tx=False station=False
+ pusher tx=False station=False
+ wa0o tx=True station=True
+
+Deployed: build `1c75acfd03ce`.
+
+
+### 🔴 ROOT CAUSE, found last instead of first: a trailing slash
+
+The amp button sends **`/api/tune/amp/`**. Read straight off the host's journal:
+
+ Sep 02 01:09:53 hamdeck-cpp hamdeck-host[18620]: dash GET /api/tune/amp/
+
+That matched the PREFIX route, not the exact one, so it hit the not-configured catch-all -
+200, no tune, no rights involved at all. The reference host trims trailing slashes on `/api/`
+paths (`ApiServer.cs:766`); this one did not.
+
+⚠️ **Process failure worth keeping.** Hours went into the permission gate - which was a real
+bug and did need fixing - while the thing actually breaking the button was routing. The first
+move should have been *what does the deck actually send, and what does the host answer*. One
+`journalctl | grep tune` answered it. Reasoning from the code found a true fact that was not
+the operative one.
+
+⚠️ **This may have been breaking other buttons silently.** The 71/74 route sweep used clean
+paths, so any button sending a trailing slash was never exercised.
+
+Verified live without transmitting: `/api/health/` and `/api/health//` now answer 200, and
+`/api/tune/amp/` answers 401 (the auth gate) instead of the catch-all's 200 - proving it now
+resolves to the real route. Build `ef607ca00190`.
+
+---
+
+## §12 — The radio moved and the station never noticed (09/02/2026)
+
+Joe moved a cable: unplugged the FTDX-101MP's USB and plugged it back in. The host went on
+serving a dashboard, `active (running)`, and reported **`rig_connected:false` indefinitely**.
+
+**The mechanism, measured, not guessed:**
+
+ /proc/1797/fd/3 -> /dev/ttyUSB0 (deleted)
+ /proc/1797/fd/5 -> /dev/snd/pcmC0D0c (deleted)
+ /proc/1797/fd/6 -> /dev/snd/pcmC0D0p (deleted)
+
+The host opens CAT and the codec **once, at startup**, and has no reconnect path
+(`main.cpp` — a failed open is fatal by design; a *dying* open is not handled at all).
+So it sat holding three device nodes that no longer existed.
+
+⚠️ **The held fd is also what renamed the port.** Minor 0 was still in use, so the returning
+CP2105 enumerated as `ttyUSB1`/`ttyUSB2`. `radio_port` said `/dev/ttyUSB0`. Every restart
+then failed FATAL (restart counter reached **209**) until one happened to catch a moment when
+a `ttyUSB0` existed. **A device that "came back on a different number" is a symptom of the
+old handle, not of the cable.**
+
+### The fix — recovery from OUTSIDE the process
+The host cannot rescan, so nothing inside it was changed. Three pieces, all in `deploy/`:
+
+1. **`99-hamdeck-radio.rules`** — `/dev/ttyRIG` symlink matched on **vid:pid + interface 00**
+ (the CAT half of the dual UART), never a minor number. `radio_port` is now `/dev/ttyRIG`.
+ The rule also sets `SYSTEMD_WANTS=hamdeck-cpp.service`, so plugging the radio in **starts
+ the host**.
+2. **`hamdeck-cpp.service.d/rig-device.conf`** — `BindsTo=dev-ttyRIG.device`, so unplugging
+ **stops** the host and drops the stale fds; plus `Restart=always` /
+ `StartLimitIntervalSec=0` so it keeps trying while the radio is away.
+3. **`hamdeck-rig-watchdog`** + timer (30s) — catches a re-enumeration systemd coalesced.
+ ⚠️ It fires **only** on a signature no healthy host can show: an fd on a *deleted* `/dev`
+ node, or a CAT fd that is not what `/dev/ttyRIG` points at. Deliberately **not** on
+ `rig_connected:false` — a radio switched off reads exactly like that, and that watchdog
+ would restart forever with nothing wrong.
+
+### The gate: `tools/rig_replug_test.sh` — PROVEN to fail
+Unbinds **both** USB devices from the kernel's `usb` driver and binds them back: the same
+udev remove/add a physical replug produces, without touching the hypervisor's passthrough.
+
+- recovery **disabled** → `FAIL: still not connected 30s after the radio came back`, unit
+ still `active`, three deleted fds. Tonight's bug, reproduced on demand.
+- watchdog alone, same broken state → `restarting hamdeck-cpp.service: stale device handle:
+ /dev/snd/pcmC0D0p (deleted)` → connected.
+- recovery **enabled** → unplug leaves the unit `inactive`; replug → `rig_connected=true in
+ 0s, CAT node /dev/ttyUSB0, stale fds 0`, **PASS**. Back on minor 0, because the fd was
+ released.
+
+⚠️ **A bug in the first version of the gate, worth keeping:** the rebind guard tested
+`[ -e /sys/bus/usb/devices/$p ]`. Unbinding does **not** remove the device from sysfs — it
+only detaches the driver — so the guard skipped the rebind every time and left the station
+off the air. Test for `$p/driver`, not for `$p`.
+
+⚠️ Running the gate restarts the host, which drops the Wavelog pusher's session. Do not run
+it while Joe is operating.
+
+---
+
+## 09/02/2026 — the Mac app had no name and no icon
+
+v0.1.29's DMG installed, launched and worked. Finder called it **`hamdeck-qml`** and drew it
+with the blank generic-document icon. Nothing had failed: CMake names a bundle after the
+**target**, and its stock `Info.plist` has no icon key, so there was no default that could
+have been right and nothing that looked.
+
+**A Mac app's identity is entirely in the bundle, not the binary.** Fixed in three places:
+
+1. **`packaging/icons/hamdeck.icns`** — 10 entries, generated by `brand/build.sh` in the same
+ render-pack-verify pass as the `.ico`, so the two families can never drift.
+ ⚠️ **Apple's icon grid: the artwork is 824 of 1024, centred, the rest transparent.** A
+ full-bleed square is the clearest tell of a ported icon — it sits visibly larger than every
+ neighbour in the Dock. `mark.svg` is already a rounded rect, so it needed the MARGIN, not
+ new artwork.
+ ⚠️ **The inset moves the small-art boundary up a slot.** The 32pt slot holds only
+ `32*824/1024 = 26px` of artwork, below the 32px floor where `mark.svg` turns to mush — so
+ `mark-small.svg` covers **16 and 32** here where it covers 16 and 24 in the `.ico`.
+ ⚠️ **The 16pt 1x slot is full-bleed, and that was measured, not assumed.** At 13px
+ mark-small's reflector merges into the boom: the drawing that exists to survive that size
+ stops surviving it. Rendered both, magnified, looked. Applies to that slot only — `ic11`
+ is the same 16pt slot on Retina, where there are 32 real pixels and the grid is kept.
+2. **`client/packaging/Info.plist.in`** + the `if(APPLE)` block in `client/CMakeLists.txt` —
+ `OUTPUT_NAME` renames the bundle to **HamDeck Remote.app**; CFBundleName and
+ CFBundleDisplayName are set **separately** (set one only and the other falls back to the
+ executable file name, which is how an app is called two different things in two places).
+ ⚠️ The target stays `hamdeck-qml` and `OUTPUT_NAME` applies on **APPLE only** — Linux ships
+ a binary a `.desktop` file points at, and a space in that name would be a gratuitous break.
+ ⚠️ **The icns is a `target_sources` file with `MACOSX_PACKAGE_LOCATION Resources`, not an
+ `install(FILES)`.** macdeployqt, codesign and notarisation all run against `client/build`
+ before anything is installed, so an icon added at install time is signed into nothing.
+3. ⚠️ **`NSMicrophoneUsageDescription`, which had not bitten yet and would have.** The
+ microphone *entitlement* says the app is allowed to ask; the usage string is what it asks
+ *with*. With the entitlement and no string macOS **SIGKILLs** the process the moment it
+ opens the mic — first PTT of the day, no dialog, nothing in the log.
+
+### The gate: `tools/check_macos_bundle.py` — PROVEN to fail
+Reads the built `.app` with `plistlib` and `struct` rather than `plutil`/`iconutil`, so it runs
+on the Linux leg and on a box with no Xcode. Runs in `build.yml` on every push, and in
+`release.yml` **twice** — before signing, and again after macdeployqt, which rewrites the
+bundle the first check looked at.
+
+Reconstructed the 0.1.29 bundle and each defect separately; every one is caught:
+
+| reintroduced | reported |
+|---|---|
+| target name + stock plist (0.1.29 exactly) | bundle name, CFBundleName, CFBundleDisplayName, icon key, mic string — 5 findings |
+| icns present but not in `Contents/Resources` | "the icon was added at INSTALL time, not build time" |
+| 512px art filed under the `ic10` (1024) slot | "artwork is 512x512, the slot needs 1024x1024" |
+| `NSMicrophoneUsageDescription` removed | "macOS SIGKILLs the app on the first PTT" |
+
+⚠️ It trusts each entry's **own PNG header**, not the slot it was filed under: an icns holding
+512px art under `ic10` is structurally perfect and looks soft on exactly the Retina display
+that entry exists for. `brand/build.sh` carries the same check against the source PNGs.
+
+⚠️ **Every macOS path in both workflows now has a SPACE in it** and must stay quoted. The old
+`[ -f "$BIN" ] || BIN=client/build/hamdeck-qml` fallbacks were **removed**: they would hide the
+one thing most likely to regress — if `OUTPUT_NAME` stops applying the bundle is called
+`hamdeck-qml.app` again, and a fallback would quietly build, test and ship it.
+
+**Not yet proven on hardware.** CI checks structure; nobody has opened the renamed bundle in
+Finder or keyed up on a Mac.
diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt
index 89c9c5b..72b7c12 100644
--- a/client/CMakeLists.txt
+++ b/client/CMakeLists.txt
@@ -18,6 +18,11 @@ set(CMAKE_AUTORCC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network WebSockets Multimedia Quick QuickControls2 Qml Test)
qt_standard_project_setup()
+# ⚠️ ONE DEFINITION OF THE MAC APP NAME. It is the .app directory name, the executable
+# name inside it, CFBundleName, and the install destination of the licence texts - four
+# places that must agree or the bundle is subtly broken in a way only a Mac shows.
+set(MACOS_APP_NAME "HamDeck Remote")
+
# ⚠️ The Qt Widgets front end was REMOVED here, not kept as a fallback.
#
# It worked and was screenshot-verified, but two front ends against one API
@@ -105,6 +110,41 @@ target_link_libraries(hamdeck-qml PRIVATE
# from a terminal. CI depends on that output.
set_target_properties(hamdeck-qml PROPERTIES WIN32_EXECUTABLE TRUE MACOSX_BUNDLE TRUE)
+# ── The macOS bundle: its name, its icon, and its microphone string ──────────
+# ⚠️ EVERYTHING VISIBLE ABOUT A MAC APP COMES FROM THE BUNDLE, NOT THE BINARY.
+# Left alone, CMake names the bundle after the TARGET and writes a stock Info.plist
+# with no icon key, so 0.1.29 shipped a signed, notarised, correctly working app that
+# Finder called "hamdeck-qml" and drew with the blank generic page. Nothing failed;
+# there is simply no default that could have been right.
+#
+# ⚠️ OUTPUT_NAME IS WHAT RENAMES THE .app - the target stays hamdeck-qml so add_test,
+# target_sources and every non-Apple path are untouched. It applies on APPLE only:
+# Linux ships an executable called hamdeck-qml that a .desktop file points at, and a
+# binary with a space in its name there would be a gratuitous break.
+if(APPLE)
+ set_target_properties(hamdeck-qml PROPERTIES
+ OUTPUT_NAME "${MACOS_APP_NAME}"
+ MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/packaging/Info.plist.in"
+ MACOSX_BUNDLE_BUNDLE_NAME "${MACOS_APP_NAME}"
+ MACOSX_BUNDLE_GUI_IDENTIFIER "com.wa0o.hamdeck.remote"
+ MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
+ MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
+ MACOSX_BUNDLE_COPYRIGHT "WA0O"
+ MACOSX_BUNDLE_ICON_FILE "hamdeck.icns")
+
+ # ⚠️ THE ICNS IS A SOURCE FILE, NOT AN install(FILES). CFBundleIconFile names a file
+ # that must already be in Contents/Resources of the BUILT bundle - macdeployqt,
+ # codesign and notarisation all run against client/build before anything is
+ # installed, so an icon added at install time is signed into nothing and the .app in
+ # the DMG has no icon anyway. MACOSX_PACKAGE_LOCATION puts it there at build time.
+ set(HAMDECK_ICNS "${CMAKE_CURRENT_SOURCE_DIR}/../packaging/icons/hamdeck.icns")
+ if(NOT EXISTS "${HAMDECK_ICNS}")
+ message(FATAL_ERROR "packaging/icons/hamdeck.icns is missing - run brand/build.sh")
+ endif()
+ target_sources(hamdeck-qml PRIVATE "${HAMDECK_ICNS}")
+ set_source_files_properties("${HAMDECK_ICNS}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
+endif()
+
# ⚠️ Embeds the .ico INTO the exe. Explorer, the taskbar and every shortcut read
# the icon from the binary's own resource - the installer's SetupIconFile does
# not give the installed app an icon, which is why it shipped blank.
@@ -166,9 +206,9 @@ endif()
# Resources/, not a Unix docdir.
if(APPLE)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/fonts/OFL.txt
- DESTINATION hamdeck-qml.app/Contents/Resources RENAME OFL-fonts.txt)
+ DESTINATION "${MACOS_APP_NAME}.app/Contents/Resources" RENAME OFL-fonts.txt)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/fonts/README.md
- DESTINATION hamdeck-qml.app/Contents/Resources RENAME FONTS.md)
+ DESTINATION "${MACOS_APP_NAME}.app/Contents/Resources" RENAME FONTS.md)
else()
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/fonts/OFL.txt
DESTINATION ${CMAKE_INSTALL_DOCDIR} RENAME OFL-fonts.txt)
diff --git a/client/packaging/Info.plist.in b/client/packaging/Info.plist.in
new file mode 100644
index 0000000..9584abe
--- /dev/null
+++ b/client/packaging/Info.plist.in
@@ -0,0 +1,44 @@
+
+
+
+
+
+ CFBundleDevelopmentRegion en
+ CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME}
+ CFBundleIdentifier ${MACOSX_BUNDLE_GUI_IDENTIFIER}
+ CFBundleInfoDictionaryVersion 6.0
+ CFBundlePackageType APPL
+
+
+ CFBundleName ${MACOSX_BUNDLE_BUNDLE_NAME}
+ CFBundleDisplayName ${MACOSX_BUNDLE_BUNDLE_NAME}
+
+ CFBundleShortVersionString ${MACOSX_BUNDLE_SHORT_VERSION_STRING}
+ CFBundleVersion ${MACOSX_BUNDLE_BUNDLE_VERSION}
+ CFBundleIconFile ${MACOSX_BUNDLE_ICON_FILE}
+ NSHumanReadableCopyright ${MACOSX_BUNDLE_COPYRIGHT}
+
+
+ NSHighResolutionCapable
+ LSMinimumSystemVersion 11.0
+ LSApplicationCategoryType public.app-category.utilities
+ NSPrincipalClass NSApplication
+
+
+ NSMicrophoneUsageDescription
+ HamDeck Remote sends your voice to the radio when you transmit.
+
+
diff --git a/deploy/99-hamdeck-radio.rules b/deploy/99-hamdeck-radio.rules
new file mode 100644
index 0000000..46f4002
--- /dev/null
+++ b/deploy/99-hamdeck-radio.rules
@@ -0,0 +1,13 @@
+# FTDX-101MP CAT link — Silicon Labs CP2105 dual UART bridge (10c4:ea70).
+#
+# Interface 00 is the CAT port; interface 01 is the bridge's second port and is
+# silent. Match on the INTERFACE, never on a minor number: on 09/02/2026 the
+# cable was moved and CAT came back as /dev/ttyUSB1, because the host was still
+# holding a dead fd on /dev/ttyUSB0 and minor 0 was therefore still taken. The
+# host came up pointed at a device that no longer existed. It names /dev/ttyRIG
+# now and nothing else.
+#
+# SYSTEMD_WANTS starts the host when the radio appears. The unit BindsTo the
+# same device, so unplugging stops it and drops the stale fd. Together that is
+# the "keep looking for the radio" behaviour, with no polling.
+SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea70", ENV{ID_USB_INTERFACE_NUM}=="00", SYMLINK+="ttyRIG", TAG+="systemd", ENV{SYSTEMD_WANTS}="hamdeck-cpp.service"
diff --git a/deploy/hamdeck-cpp.service.d/rig-device.conf b/deploy/hamdeck-cpp.service.d/rig-device.conf
new file mode 100644
index 0000000..907454d
--- /dev/null
+++ b/deploy/hamdeck-cpp.service.d/rig-device.conf
@@ -0,0 +1,21 @@
+# Tie the host to the radio, not to the boot order.
+#
+# 09/02/2026: the CAT cable was moved. The host kept running with a DEAD fd
+# (/proc//fd/3 -> /dev/ttyUSB0 (deleted)) and reported rig_connected:false
+# forever — it opens the port once at startup and has no reconnect path. So the
+# recovery has to come from outside the process: stop it when the radio leaves,
+# start it when the radio comes back.
+[Unit]
+# BindsTo, not Requires: BindsTo also STOPS this unit when the device vanishes,
+# which is what drops the stale fd. 99-hamdeck-radio.rules sets SYSTEMD_WANTS on
+# the same device, so plugging the radio back in starts the host again.
+BindsTo=dev-ttyRIG.device
+After=dev-ttyRIG.device
+
+[Service]
+# Keep trying forever while the radio is away or the codec has not enumerated
+# yet. The host exits 1 on a missing CAT port or capture device by design, and
+# StartLimit would otherwise let systemd give up on it.
+Restart=always
+RestartSec=5
+StartLimitIntervalSec=0
diff --git a/deploy/hamdeck-rig-watchdog b/deploy/hamdeck-rig-watchdog
new file mode 100755
index 0000000..1650800
--- /dev/null
+++ b/deploy/hamdeck-rig-watchdog
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Belt-and-braces for the 09/02/2026 failure: the host holding a device node
+# that no longer exists.
+#
+# The udev rule + BindsTo drop-in handle a normal unplug/replug. This catches
+# the case where the device unit never went away cleanly (a fast re-enumeration
+# that systemd coalesced, a codec that came back on a different node) and the
+# host is left holding dead handles.
+#
+# ⚠️ It restarts ONLY on a signature that is impossible for a healthy host:
+# a fd on a deleted /dev node, or a CAT fd that is not the node /dev/ttyRIG
+# currently points at. It deliberately does NOT restart on rig_connected=false
+# alone — a radio that is switched off reads exactly like that, and a watchdog
+# that restarts on it would loop forever with nothing wrong.
+set -uo pipefail
+
+UNIT=hamdeck-cpp.service
+LINK=/dev/ttyRIG
+
+systemctl is-active --quiet "$UNIT" || exit 0
+
+pid=$(systemctl show -p MainPID --value "$UNIT")
+[[ "$pid" =~ ^[0-9]+$ ]] && [ "$pid" -gt 0 ] || exit 0
+
+reason=""
+cat_fd=""
+for l in /proc/"$pid"/fd/*; do
+ t=$(readlink "$l" 2>/dev/null) || continue
+ case "$t" in
+ /dev/*"(deleted)") reason="stale device handle: $t" ;;
+ /dev/ttyUSB*) cat_fd="$t" ;;
+ esac
+done
+
+if [ -z "$reason" ] && [ -n "$cat_fd" ] && [ -e "$LINK" ]; then
+ want=$(readlink -f "$LINK")
+ [ "$cat_fd" = "$want" ] || reason="CAT fd is $cat_fd but $LINK is now $want"
+fi
+
+[ -n "$reason" ] || exit 0
+
+logger -t hamdeck-rig-watchdog "restarting $UNIT: $reason"
+echo "restarting $UNIT: $reason"
+systemctl restart "$UNIT"
diff --git a/deploy/hamdeck-rig-watchdog.service b/deploy/hamdeck-rig-watchdog.service
new file mode 100644
index 0000000..11f0277
--- /dev/null
+++ b/deploy/hamdeck-rig-watchdog.service
@@ -0,0 +1,7 @@
+[Unit]
+Description=HamDeck: recover the host from stale radio device handles
+ConditionPathExists=/opt/hamdeck-cpp/hamdeck-host
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/hamdeck-rig-watchdog
diff --git a/deploy/hamdeck-rig-watchdog.timer b/deploy/hamdeck-rig-watchdog.timer
new file mode 100644
index 0000000..719117c
--- /dev/null
+++ b/deploy/hamdeck-rig-watchdog.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Check every 30s that the HamDeck host still holds the real radio
+
+[Timer]
+OnBootSec=60
+OnUnitActiveSec=30
+AccuracySec=5
+
+[Install]
+WantedBy=timers.target
diff --git a/docs/AUDIT-AUDIO-ATTRIBUTION.md b/docs/AUDIT-AUDIO-ATTRIBUTION.md
new file mode 100644
index 0000000..739af81
--- /dev/null
+++ b/docs/AUDIT-AUDIO-ATTRIBUTION.md
@@ -0,0 +1,81 @@
+# Audio attribution: what NetLogger and the logbook can actually tell us
+
+Measured 09/01/2026 against the live NetLogger API and the 29,578-QSO logbook on the
+wavelog-test rig. Nothing here is inferred from documentation alone.
+
+## The wall: NetLogger records WHO, never WHEN
+
+A `` carries `SerialNo, Callsign, Status, FirstName, PreferredName, Street,
+CityCountry, State, Zip, Country, County, Grid, DXCC, MemberID, Remarks, QSLInfo`.
+Pulled live from `GetCheckins.php`, every field listed. **There is no timestamp on a
+check-in, and no per-station time anywhere in the API.** `GetPastNetCheckins` returns the
+final roster only.
+
+So a recording cannot be segmented from NetLogger history. That is the same shape as the
+7-day wall in [[netlogger-xml-api]]: whatever we want, we capture live or we never have it.
+
+## The one live signal: ``
+
+Spec v1.3 line 59, verbatim: **"`` is the SerialNo of the currently working
+station."** Returned on every `GetCheckins` call. Observed live: `Pointer=19` of
+`CheckinCount=20`.
+
+⚠️ **The pointer is net control's cursor, not a transmit detector.** It moves when the
+operator running the net clicks a station. It lags, it can sit still through a long
+exchange, and on a loosely-run net it may not move at all. It is a strong hint about who
+is being worked; it is not ground truth about who is making noise. Treat it as evidence,
+never as a fact — and keep the raw samples so a better rule can be applied later without
+re-recording.
+
+Rate limit is **3 GetCheckins/min = one sample per 20 seconds**, which is also the
+boundary precision. Do not shorten it; v1.2 added server-side anti-flooding.
+
+## The logbook is the better index — second resolution, genuinely ragged
+
+`COL_TIME_ON` on net-tagged QSOs (12,001 rows via `qsl_qso_net`):
+
+- **98.3% carry non-zero seconds.** These are not minute-rounded stamps.
+- **753 net sessions; exactly 1 has every QSO at one identical time (0.1%).**
+ Bulk-logging-at-the-end is not what happens.
+
+### ⚠️ The duplicate trap that nearly produced the wrong design
+The first spacing measurement said median gap **0 seconds**, 70.2% under 20s — which would
+have meant the timestamps were useless for slicing. That was too tidy to be true.
+**5,900 of 5,902 zero-gap pairs are the SAME CALLSIGN** — the known duplicate-QSO problem
+(5,320 groups logged under two station profiles). The duplicates, not the logging, made
+the median zero.
+
+Deduplicated on call+time, the real distribution:
+
+ n=2998 p10=17s median=211s p90=1641s under_20s=11.5%
+
+**A 3.5-minute median between consecutive net QSOs.** That is a sliceable timeline, and it
+is a far finer index than the 20-second pointer poll.
+
+By year, undeduplicated, the artifact is visible directly — 2025 and 2026 (live-logged via
+the NetLogger sync) show medians of 206s and 63s and only ~13% under 20s, while every year
+2019-2024 reads median 0. The older years are duplicate-polluted, not differently logged.
+
+## What this means for the build
+
+1. **Attribution is captured live or not at all.** A recorder that runs without a
+ simultaneous pointer/roster capture produces audio that can never be attributed.
+2. **The logbook timestamp is the primary index; the pointer track is corroboration.**
+ Where they disagree, neither is automatically right and the segment should say so
+ rather than pick a winner silently.
+3. **Keep the raw poll responses**, not just the derived segments. The segmentation rule
+ will change; the recordings and the XML are what was paid for.
+4. Segment boundaries are **uncertain by construction** — ±20s at best from the pointer,
+ and a logbook stamp marks when a contact was logged, not when the audio started. Any
+ page built on this must show that as a range, never as a precise clip.
+
+## Not established
+
+- Whether the pointer actually tracks transmissions closely enough to be useful. That
+ needs one real net recorded with the track running, then listened to against it.
+ **Nothing here proves it does.**
+- Joe's own logging latency: how long after an exchange he commits the row. That offset is
+ the single biggest term in the slicing error and it has not been measured.
+
+Related: `qsl-card-system` (the QR spot on the card is the consumer of this),
+[[netlogger-xml-api]], and section 1 of CARRYOVER.md for the recorder itself.
diff --git a/packaging/icons/hamdeck.icns b/packaging/icons/hamdeck.icns
new file mode 100644
index 0000000..f1ddfce
Binary files /dev/null and b/packaging/icons/hamdeck.icns differ
diff --git a/src/amp_tuner.h b/src/amp_tuner.h
index fe3aa1e..78b9814 100644
--- a/src/amp_tuner.h
+++ b/src/amp_tuner.h
@@ -18,9 +18,15 @@
// of tuning the amplifier is to then operate through it. Restoring 5 W after
// tuning an amplifier is not what anybody pressed the button for.
//
-// ⚠️ LOCAL CALLERS ONLY, enforced by the route, not here: this keys the
-// transmitter for ten unattended seconds. "Local" means the request arrived on
-// the loopback listener - a kernel guarantee, not a header a caller can set.
+// ⚠️ THE OPERATOR MUST BE AT THE STATION, enforced by the route, not here: this
+// keys the transmitter for ten unattended seconds.
+//
+// The reference host proved that with "did the request arrive on the loopback
+// listener", which was a kernel guarantee and a correct one - while the host ran
+// ON the station PC. It does not any more: the rig has its own box, and loopback
+// there proves the caller is on the rig box, where nobody sits. So the route asks
+// the loopback console OR an account carrying is_station. Same restriction, a
+// question that still means what it says.
//
// ⚠️ Ten seconds is a long carrier. Every exit path unkeys: the stop flag is
// checked every 100 ms, an exception unkeys and forces 100 W, and the
diff --git a/src/api.cpp b/src/api.cpp
index c2d050c..5af3422 100644
--- a/src/api.cpp
+++ b/src/api.cpp
@@ -732,10 +732,16 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port,
WriteJson(res, 200,
std::format(
R"({{"status":"ok","authenticated":{},"is_admin":{},"can_transmit":{},)"
- R"("username":{},"token":null}})",
+ R"("is_station":{},"username":{},"token":null}})",
JsonBool(ok || trusted),
JsonBool(ok && deps.auth->IsAdmin(token)),
JsonBool(ok && deps.auth->CanTransmit(token)),
+ // ⚠️ So a client can GREY THE AMP TUNE BUTTON instead of showing a
+ // live one that answers 403. CARRYOVER.md section 2: "a button that
+ // always errors is worse than a missing one" - and it is also the
+ // only way to confirm the right is live without keying an amplifier
+ // to find out.
+ JsonBool(trusted || (ok && deps.auth->IsStation(token))),
user ? "\"" + *user + "\"" : "null"));
});
@@ -1471,27 +1477,66 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port,
JsonBool(tgxl && tgxl->IsActive()),
JsonBool(tgxl && tgxl->configured())); }});
- // ⚠️ AMP TUNE REFUSES EVERY REMOTE CALLER. CARRYOVER.md section 2. The check is
- // the LISTENER the request arrived on - the control port is bound to loopback,
- // so "local" is a kernel guarantee, not a header a caller can set.
+ // ── Amp tune ───────────────────────────────────────────────────────────────
+ // ⚠️ WHAT THIS GUARDS: a TEN-SECOND UNATTENDED CARRIER at 20 W, ending at 100 W.
+ // That is why it is the most restricted route on the host, and the restriction
+ // stays. What changed is the QUESTION it asks.
+ //
+ // The reference host asked "did this arrive on the loopback listener":
+ // private object? AmpTuneOrDeny(bool isLocal)
+ // => isLocal ? _amp.Tune() : ... "only available when connected locally."
+ // That was a correct test THERE, because the C# host ran ON the station PC, so
+ // loopback proved an operator was sitting in front of it. The Stream Deck's 44
+ // buttons all point at localhost:5001 and every one of them was local.
+ //
+ // ⚠️ THE RIG MOVED TO ITS OWN BOX AND THE TEST STOPPED MEANING THAT. Loopback on
+ // the rig box proves the caller is on the rig box - which is the one place
+ // nobody sits. The gate still worked perfectly; it had simply come to prove the
+ // wrong thing, and the amp tune button went dead with a 200 and no explanation.
+ //
+ // So the question is now WHO, not WHERE: the loopback console, or a session
+ // belonging to an account marked as the operator at the station. is_station is
+ // granted by a deliberate admin act and defaults to false, so no existing
+ // account gains this by upgrading.
AmpTuner* amp = deps.amp;
- auto amp_tune = [amp](bool is_local) {
- if (!is_local) {
- return std::string(R"({"status":"error",)"
- R"("message":"Amp tune is only available when connected locally."})");
+ AuthService* amp_auth = deps.auth;
+ auto amp_tune = [amp, amp_auth, trusted](const HttpRequest& req, HttpResponse& res) {
+ // ⚠️ BOTH RIGHTS, and can_transmit is not redundant here. Amp tune is not in
+ // IsTransmitRoute - it predates that list and is gated separately - so without
+ // this an account with can_transmit=false could start a ten-second carrier the
+ // moment it was given the station right. Found on the live host, where the
+ // `pusher` account is exactly that shape: tx denied, and it is the account the
+ // Stream Deck's session belongs to.
+ //
+ // "Denied transmit" has to mean it everywhere, or it means nothing.
+ const std::string amp_token = ExtractToken(req);
+ const bool at_station =
+ trusted || (amp_auth && amp_auth->IsStation(amp_token) &&
+ amp_auth->CanTransmit(amp_token));
+ if (!at_station) {
+ // ⚠️ 403, NOT 200. The reference host answered 200 with an error body, and a
+ // Stream Deck button reads that as success: it lights up green and the amp
+ // never tunes. A refusal that looks like a success is how this went unnoticed.
+ WriteJson(res, 403,
+ R"({"status":"error","station":false,)"
+ R"("message":"Amp tune needs the station right. Grant it with )"
+ R"(/api/admin/user/station/enable/."})");
+ return;
}
if (!amp) {
- return std::string(R"({"status":"error","available":false,"tuner":"amp",)"
- R"("message":"Amp tuner is not configured on this host"})");
+ WriteJson(res, 200,
+ R"({"status":"error","available":false,"tuner":"amp",)"
+ R"("message":"Amp tuner is not configured on this host"})");
+ return;
}
const auto r = amp->Tune();
- return std::format(
+ WriteJson(res, 200, std::format(
R"({{"status":"{}","tuner":"amp","available":true,"tuning":{},)"
R"("action":"{}","message":"{}"}})",
- r.ok ? "ok" : "error", JsonBool(r.tuning), r.action, r.message);
+ r.ok ? "ok" : "error", JsonBool(r.tuning), r.action, r.message));
};
- generated.push_back({"/api/tune/amp", amp_tune});
- generated.push_back({"/api/amp/tune", amp_tune});
+ server.Get("/api/tune/amp", amp_tune);
+ server.Get("/api/amp/tune", amp_tune);
generated.push_back({"/api/tune/amp/status", [amp](bool) {
return std::format(R"({{"status":"ok","tuning":{},"available":{}}})",
JsonBool(amp && amp->IsActive()), JsonBool(amp != nullptr)); }});
@@ -1717,12 +1762,15 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port,
// ⚠️ Amp tune again, this time as a prefix. The refusal has to be repeated
// here: a caller reaching /api/tune/amp/anything must not slip past the exact
// route's check.
- server.GetPrefix("/api/tune/amp/", [trusted](const std::string&, const HttpRequest&,
- HttpResponse& res) {
- if (!trusted) {
- WriteJson(res, 200,
- R"({"status":"error",)"
- R"("message":"Amp tune is only available when connected locally."})");
+ server.GetPrefix("/api/tune/amp/", [trusted, amp_auth](const std::string&,
+ const HttpRequest& req,
+ HttpResponse& res) {
+ const std::string amp_token = ExtractToken(req);
+ if (!(trusted || (amp_auth && amp_auth->IsStation(amp_token) &&
+ amp_auth->CanTransmit(amp_token)))) {
+ WriteJson(res, 403,
+ R"({"status":"error","station":false,)"
+ R"("message":"Amp tune needs the station right."})");
return;
}
WriteJson(res, 200,
@@ -1933,6 +1981,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port,
cu.username = u.username;
cu.is_admin = u.is_admin;
cu.can_transmit = u.can_transmit;
+ cu.is_station = u.is_station;
cu.password_hash = auth->PasswordHashOf(u.username);
cfg->web_users.push_back(cu);
}
@@ -1944,8 +1993,10 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port,
if (auth) {
for (const auto& u : auth->ListUsers()) {
if (!rows.empty()) rows += ",";
- rows += std::format(R"({{"username":"{}","is_admin":{},"can_transmit":{}}})",
- u.username, JsonBool(u.is_admin), JsonBool(u.can_transmit));
+ rows += std::format(
+ R"({{"username":"{}","is_admin":{},"can_transmit":{},"is_station":{}}})",
+ u.username, JsonBool(u.is_admin), JsonBool(u.can_transmit),
+ JsonBool(u.is_station));
}
}
WriteJson(res, 200, std::format(R"({{"status":"ok","users":[{}]}})", rows));
@@ -1981,7 +2032,10 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port,
}
// ⚠️ Hashed here, immediately. A plaintext password must never reach the
// config file, and the only way to guarantee that is never to store one.
- auth->AddUser(user, AuthService::HashPassword(pass), is_admin, can_tx);
+ // ⚠️ A new account is never a station account. Granting it is a separate,
+ // named act - /api/admin/user/station/enable/.
+ auth->AddUser(user, AuthService::HashPassword(pass), is_admin, can_tx,
+ /*is_station=*/false);
std::string err;
if (!persist_users(err)) {
WriteJson(res, 500,
@@ -2100,6 +2154,47 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port,
user, JsonBool(allow)));
});
+ // /api/admin/user/station/enable/ and .../disable/
+ //
+ // ⚠️ Deliberately its own route rather than a flag on the tx one. Transmit is
+ // "may key the rig". This is "may start an unattended carrier into an
+ // amplifier", and granting the first must never quietly grant the second.
+ server.GetPrefix("/api/admin/user/station/",
+ [auth, persist_users](const std::string& suffix, const HttpRequest&,
+ HttpResponse& res) {
+ const auto slash = suffix.find('/');
+ if (slash == std::string::npos) {
+ WriteJson(res, 400,
+ R"({"status":"error","message":"expected enable|disable/"})");
+ return;
+ }
+ const std::string verb = suffix.substr(0, slash);
+ const std::string user = suffix.substr(slash + 1);
+ if (verb != "enable" && verb != "disable") {
+ WriteJson(res, 400, R"({"status":"error","message":"expected enable or disable"})");
+ return;
+ }
+ const bool allow = (verb == "enable");
+ if (!auth->SetIsStation(user, allow)) {
+ WriteJson(res, 404, R"({"status":"error","message":"no such user"})");
+ return;
+ }
+ std::string err;
+ // ⚠️ An unsaved grant is the friendlier failure; an unsaved REVOKE is a
+ // right you believe you took away and did not, and it comes back at the
+ // next power cut. Both are reported rather than assumed to have stuck.
+ if (!persist_users(err)) {
+ WriteJson(res, 500,
+ std::format(R"({{"status":"error","message":"station right changed on )"
+ R"(the running host but NOT saved - it reverts on restart: )"
+ R"({}"}})", err));
+ return;
+ }
+ WriteJson(res, 200,
+ std::format(R"({{"status":"ok","username":"{}","is_station":{}}})",
+ user, JsonBool(allow)));
+ });
+
server.GetPrefix("/api/admin/kick/", [auth](const std::string& user,
const HttpRequest&, HttpResponse& res) {
const int n = auth->KillUserSessions(user);
diff --git a/src/auth.cpp b/src/auth.cpp
index a1bce81..b16548f 100644
--- a/src/auth.cpp
+++ b/src/auth.cpp
@@ -87,9 +87,10 @@ bool AuthService::VerifyPassword(const std::string& password, const std::string&
}
void AuthService::AddUser(const std::string& username, const std::string& password_hash,
- bool is_admin, bool can_transmit) {
+ bool is_admin, bool can_transmit, bool is_station) {
std::lock_guard lock(mu_);
- users_[LowerTrim(username)] = UserInfo{password_hash, is_admin, can_transmit};
+ users_[LowerTrim(username)] =
+ UserInfo{password_hash, is_admin, can_transmit, is_station};
}
bool AuthService::IsConfigured() const {
@@ -127,7 +128,8 @@ std::optional AuthService::Login(const std::string& username,
const std::string token = ToHexLower(raw, sizeof(raw));
const auto now = std::chrono::steady_clock::now();
- sessions_[token] = SessionInfo{key, it->second.is_admin, it->second.can_transmit, now, now};
+ sessions_[token] = SessionInfo{key, it->second.is_admin, it->second.can_transmit,
+ it->second.is_station, now, now};
return token;
}
@@ -158,6 +160,12 @@ bool AuthService::CanTransmit(const std::string& token) const {
return it != sessions_.end() && it->second.can_transmit;
}
+bool AuthService::IsStation(const std::string& token) const {
+ std::lock_guard lock(mu_);
+ const auto it = sessions_.find(token);
+ return it != sessions_.end() && it->second.is_station;
+}
+
std::optional AuthService::Username(const std::string& token) const {
std::lock_guard lock(mu_);
const auto it = sessions_.find(token);
@@ -223,6 +231,21 @@ bool AuthService::SetCanTransmit(const std::string& username, bool allow) {
return true;
}
+bool AuthService::SetIsStation(const std::string& username, bool allow) {
+ const std::string key = LowerTrim(username);
+ std::lock_guard lock(mu_);
+ const auto it = users_.find(key);
+ if (it == users_.end()) return false;
+ it->second.is_station = allow;
+ // ⚠️ Same reason as SetCanTransmit, and it matters more here: this is the
+ // right to start an unattended carrier. Revoking it and leaving live sessions
+ // holding the old value is a permission you believe you took away and did not.
+ for (auto& [token, s] : sessions_) {
+ if (s.username == key) s.is_station = allow;
+ }
+ return true;
+}
+
int AuthService::KillUserSessions(const std::string& username) {
const std::string key = LowerTrim(username);
std::lock_guard lock(mu_);
@@ -242,7 +265,7 @@ std::vector AuthService::ListUsers() const {
std::lock_guard lock(mu_);
std::vector out;
for (const auto& [name, u] : users_) {
- out.push_back({name, u.is_admin, u.can_transmit});
+ out.push_back({name, u.is_admin, u.can_transmit, u.is_station});
}
return out;
}
@@ -255,7 +278,7 @@ std::vector AuthService::ListSessions() const {
// ⚠️ Only a PREFIX of the token. A full session token in an admin listing
// is a credential in a log, a screenshot and a support ticket.
out.push_back({token.substr(0, 8) + "...", s.username, s.is_admin,
- s.can_transmit,
+ s.can_transmit, s.is_station,
std::chrono::duration_cast(
now - s.last_activity).count()});
}
diff --git a/src/auth.h b/src/auth.h
index b86552c..8d6fb39 100644
--- a/src/auth.h
+++ b/src/auth.h
@@ -25,6 +25,9 @@ struct SessionInfo {
std::string username;
bool is_admin = false;
bool can_transmit = true;
+ // Copied from the user at login, like the two above, so a right taken away
+ // reaches live sessions through the same path and cannot be left behind.
+ bool is_station = false;
std::chrono::steady_clock::time_point created;
std::chrono::steady_clock::time_point last_activity;
};
@@ -33,6 +36,9 @@ struct UserInfo {
std::string password_hash;
bool is_admin = false;
bool can_transmit = true;
+ // ⚠️ See ConfigUser::is_station. "The operator is at the station", which is
+ // what the amp tune's loopback test used to prove and no longer can.
+ bool is_station = false;
};
class AuthService {
@@ -43,8 +49,16 @@ class AuthService {
static std::string HashPassword(const std::string& password);
static bool VerifyPassword(const std::string& password, const std::string& stored);
+ // ⚠️ NO DEFAULT ARGUMENTS, deliberately. is_station shipped with `= false` for
+ // exactly one build, and main.cpp's config loader - which never passed it -
+ // compiled cleanly and dropped the right on every startup. The config said the
+ // operator had it, the running host said they did not, and nothing warned.
+ //
+ // A missing right must be a COMPILE ERROR, not a silent false. Every call site
+ // states all three, so adding a fourth right breaks the build until each caller
+ // has decided what it means.
void AddUser(const std::string& username, const std::string& password_hash,
- bool is_admin = false, bool can_transmit = true);
+ bool is_admin, bool can_transmit, bool is_station);
bool IsConfigured() const;
@@ -55,6 +69,10 @@ class AuthService {
bool ValidateSession(const std::string& token); // sliding: refreshes last_activity
bool IsAdmin(const std::string& token) const;
bool CanTransmit(const std::string& token) const;
+ // ⚠️ NOT implied by CanTransmit, and deliberately so. Transmit is "may key the
+ // rig, with a hand on it". This is "may start a ten-second unattended carrier
+ // into an amplifier". The second is a strictly stronger claim.
+ bool IsStation(const std::string& token) const;
std::optional Username(const std::string& token) const;
void Logout(const std::string& token);
bool IsLockedOut(const std::string& username) const;
@@ -63,9 +81,13 @@ class AuthService {
bool RemoveUser(const std::string& username);
bool ChangePassword(const std::string& username, const std::string& new_hash);
bool SetCanTransmit(const std::string& username, bool allow);
+ bool SetIsStation(const std::string& username, bool allow);
int KillUserSessions(const std::string& username);
- struct UserRow { std::string username; bool is_admin; bool can_transmit; };
+ struct UserRow {
+ std::string username;
+ bool is_admin, can_transmit, is_station;
+ };
std::vector ListUsers() const;
// ⚠️ How many OTHER sessions have touched the host within `within_seconds`.
@@ -92,7 +114,7 @@ class AuthService {
struct SessionRow {
std::string token_short, username;
- bool is_admin, can_transmit;
+ bool is_admin, can_transmit, is_station;
long long idle_seconds;
};
std::vector ListSessions() const;
diff --git a/src/config.cpp b/src/config.cpp
index 566a511..2154813 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -109,6 +109,9 @@ bool Config::Load(const std::string& path, Config& out, std::string& error) {
Get(u, "password_hash", cu.password_hash);
Get(u, "is_admin", cu.is_admin);
Get(u, "can_transmit", cu.can_transmit);
+ // Absent in a config written before station rights existed, and Get
+ // leaves the default alone - so an upgraded host grants nobody this.
+ Get(u, "is_station", cu.is_station);
if (cu.username.empty() || cu.password_hash.empty()) {
// A user entry that cannot authenticate is a mistake, not a disabled
// account. Refuse the file rather than start with a user list that is
@@ -185,7 +188,8 @@ bool Config::Save(const std::string& path, std::string& error) const {
users.push_back({{"username", u.username},
{"password_hash", u.password_hash},
{"is_admin", u.is_admin},
- {"can_transmit", u.can_transmit}});
+ {"can_transmit", u.can_transmit},
+ {"is_station", u.is_station}});
}
j["web_users"] = users;
diff --git a/src/config.h b/src/config.h
index 227bb8e..1af306e 100644
--- a/src/config.h
+++ b/src/config.h
@@ -20,6 +20,18 @@ struct ConfigUser {
std::string password_hash; // pbkdf2::
bool is_admin = false;
bool can_transmit = true;
+ // ⚠️ "This account is the operator sitting at the station."
+ //
+ // It exists because the amp tune's old test - did the request arrive on the
+ // loopback listener - stopped meaning what it was written to mean. That test
+ // was correct when the host ran ON the station PC, so loopback proved a human
+ // was present. The rig now has its own box: loopback there proves the caller
+ // is on the rig box, which is the one place nobody sits.
+ //
+ // So the question moved from WHERE a request came from to WHO sent it. This
+ // right answers the new one, and it defaults to false: an account gets it by a
+ // deliberate act, never by upgrading.
+ bool is_station = false;
};
struct Config {
diff --git a/src/http.cpp b/src/http.cpp
index 8d0d5b6..4d39105 100644
--- a/src/http.cpp
+++ b/src/http.cpp
@@ -30,6 +30,27 @@ HttpRequest BuildRequest(mg_connection* conn) {
const mg_request_info* ri = mg_get_request_info(conn);
HttpRequest req;
req.path = ri->request_uri ? ri->request_uri : "";
+ // ⚠️ TRAILING SLASHES ARE TRIMMED ON /api/ PATHS, because the reference host does
+ // it and the API is the contract between the two:
+ //
+ // if (path.StartsWith("/api/")) { var trimmed = path.TrimEnd('/'); ... }
+ // Services/ApiServer.cs:764-766
+ //
+ // Without it "/api/tune/amp/" does not match the exact route "/api/tune/amp".
+ // It fell through to the PREFIX route "/api/tune/amp/" instead - the catch-all
+ // that answers "Amp tuner is not configured on this host" and never tunes. The
+ // Stream Deck's amp tune button sends exactly that trailing slash, so it got a
+ // cheerful 200 and a silent no-op.
+ //
+ // ⚠️ It is done HERE, where the path is first built, so the AUTH, ADMIN and
+ // TRANSMIT gates all see the same normalised path the router will match. Doing
+ // it at the router instead would let "/api/ptt/on/" skip IsTransmitRoute - the
+ // gate would look at one string and the dispatcher at another, which is how a
+ // permission check gets walked around with a keystroke.
+ if (req.path.rfind("/api/", 0) == 0) {
+ const auto end = req.path.find_last_not_of('/');
+ req.path = (end == std::string::npos) ? "" : req.path.substr(0, end + 1);
+ }
req.method = ri->request_method ? ri->request_method : "";
req.query = ri->query_string ? ri->query_string : "";
for (int i = 0; i < ri->num_headers; ++i) {
diff --git a/src/main.cpp b/src/main.cpp
index 88d87e0..4eca1e3 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -170,12 +170,17 @@ int main(int argc, char** argv) {
AuthService auth(config.web_session_timeout);
for (const auto& u : config.web_users) {
- auth.AddUser(u.username, u.password_hash, u.is_admin, u.can_transmit);
+ auth.AddUser(u.username, u.password_hash, u.is_admin, u.can_transmit,
+ u.is_station);
}
// Env override, for a throwaway run without writing a config file. It does not
// replace the configured users, it adds to them.
if (const char* hash = std::getenv("HAMDECK_ADMIN_HASH")) {
- auth.AddUser("admin", hash, /*is_admin=*/true);
+ auth.AddUser("admin", hash, /*is_admin=*/true, /*can_transmit=*/true,
+ // ⚠️ NOT a station account. This is the break-glass override for a
+ // throwaway run; it must not carry the right to start an unattended
+ // carrier just because it happens to be admin.
+ /*is_station=*/false);
}
// Synthetic RX audio: the codec is passed through to the reference host, so
diff --git a/tests/test_admin.cpp b/tests/test_admin.cpp
index 8204e51..c11fdcf 100644
--- a/tests/test_admin.cpp
+++ b/tests/test_admin.cpp
@@ -13,8 +13,8 @@
int main() {
setvbuf(stdout, nullptr, _IONBF, 0);
AuthService a(480);
- a.AddUser("boss", AuthService::HashPassword("pw1"), /*is_admin=*/true, true);
- a.AddUser("op", AuthService::HashPassword("pw2"), /*is_admin=*/false, true);
+ a.AddUser("boss", AuthService::HashPassword("pw1"), /*is_admin=*/true, true, false);
+ a.AddUser("op", AuthService::HashPassword("pw2"), /*is_admin=*/false, true, false);
CHECK(a.ListUsers().size() == 2);
CHECK(a.AdminCount() == 1);
@@ -56,7 +56,7 @@ int main() {
std::printf("remove: user gone, and their live session with them\n");
// ── Kick ─────────────────────────────────────────────────────────────────
- a.AddUser("op2", AuthService::HashPassword("pw4"), false, true);
+ a.AddUser("op2", AuthService::HashPassword("pw4"), false, true, false);
const auto k1 = a.Login("op2", "pw4");
const auto k2 = a.Login("op2", "pw4");
CHECK(k1 && k2);
diff --git a/tests/test_auth.cpp b/tests/test_auth.cpp
index c46da80..9017d3d 100644
--- a/tests/test_auth.cpp
+++ b/tests/test_auth.cpp
@@ -51,7 +51,8 @@ int main() {
// Sessions.
AuthService auth(480);
CHECK(!auth.IsConfigured());
- auth.AddUser("Joe", AuthService::HashPassword("s3cret"), /*is_admin=*/true);
+ auth.AddUser("Joe", AuthService::HashPassword("s3cret"), /*is_admin=*/true,
+ /*can_transmit=*/true, /*is_station=*/false);
CHECK(auth.IsConfigured());
CHECK(!auth.Login("joe", "wrong").has_value());
@@ -69,7 +70,7 @@ int main() {
// Throttle: five failures locks the account.
AuthService t(480);
- t.AddUser("bob", AuthService::HashPassword("pw"));
+ t.AddUser("bob", AuthService::HashPassword("pw"), false, true, false);
for (int i = 0; i < AuthService::kMaxLoginFails; ++i) {
CHECK(!t.Login("bob", "nope").has_value());
}
diff --git a/tests/test_remote_active.cpp b/tests/test_remote_active.cpp
index ba36472..2c75c7b 100644
--- a/tests/test_remote_active.cpp
+++ b/tests/test_remote_active.cpp
@@ -25,8 +25,8 @@
int main() {
setvbuf(stdout, nullptr, _IONBF, 0);
AuthService a(480);
- a.AddUser("pusher", AuthService::HashPassword("pw1"), false, true);
- a.AddUser("op", AuthService::HashPassword("pw2"), false, true);
+ a.AddUser("pusher", AuthService::HashPassword("pw1"), false, true, false);
+ a.AddUser("op", AuthService::HashPassword("pw2"), false, true, false);
// ── The desktop pusher logs in, and is the only thing on the host ────────
const auto pusher = a.Login("pusher", "pw1");
diff --git a/tools/amp_gate_check.sh b/tools/amp_gate_check.sh
new file mode 100755
index 0000000..e09fc7c
--- /dev/null
+++ b/tools/amp_gate_check.sh
@@ -0,0 +1,170 @@
+#!/usr/bin/env bash
+# Does the amp tune gate actually refuse, and actually open?
+#
+# ⚠️ THIS EXISTS BECAUSE THE ROUTE'S OWN REPLY PROVES NOTHING. The amp tune
+# button was dead for weeks while every check looked healthy: the host answered
+# HTTP 200 with an error body, and a Stream Deck button reads 200 as success. A
+# gate that refuses with a success code is indistinguishable from one that works.
+#
+# So this drives the REAL binary over HTTP on both listeners and asserts the
+# STATUS CODE, which is the thing the deck actually reacts to.
+#
+# 1 a session with no station right -> 403
+# 2 the same account, right granted -> not 403
+# 3 the loopback control listener -> not 403, with no session at all
+# 4 granting transmit does NOT grant station
+#
+# ⚠️ It refuses to run against anything but a simulator, the same fail-closed
+# check tools/walk_all_routes.py makes, because step 2 can key a transmitter.
+set -u
+
+FAIL=0
+say() { printf '%s\n' "$*"; }
+ok() { printf ' ok %s\n' "$*"; }
+bad() { printf ' FAIL %s\n' "$*"; FAIL=1; }
+
+DASH=18502
+CTRL=18501
+DIR="$(mktemp -d)"
+CFG="$DIR/config.json"
+trap 'kill %1 2>/dev/null; rm -rf "$DIR"' EXIT
+
+# ⚠️ Hash generated here, not pasted: the parameters must match src/auth.cpp and
+# a stale copy in a test fails in a way that looks like a broken gate.
+HASH=$(python3 - <<'PY'
+import hashlib, os, binascii
+salt = os.urandom(16)
+h = hashlib.pbkdf2_hmac('sha256', b'gatecheck', salt, 350000, 32)
+print("pbkdf2:%s:%s" % (binascii.hexlify(salt).decode(), binascii.hexlify(h).decode()))
+PY
+)
+
+cat > "$CFG" <"$DIR/host.log" 2>&1 &
+for _ in $(seq 1 50); do
+ curl -fsS "http://127.0.0.1:$DASH/api/health" >/dev/null 2>&1 && break
+ sleep 0.2
+done
+
+# ── Fail closed: only ever run this against the simulator ────────────────────
+if ! curl -fsS "http://127.0.0.1:$CTRL/api/backend" 2>/dev/null | grep -q '"simulated":[[:space:]]*true'; then
+ say "REFUSING: target did not prove it is a simulator (/api/backend simulated:true)"
+ say "step 2 of this check can key a transmitter. There is deliberately no --force."
+ exit 2
+fi
+
+login() { # login -> prints token
+ curl -fsS -X POST "http://127.0.0.1:$DASH/api/auth/login" \
+ -H 'Content-Type: application/json' \
+ -d "{\"username\":\"$1\",\"password\":\"gatecheck\"}" -D - -o /dev/null 2>/dev/null \
+ | sed -n 's/.*hamdeck_session=\([^;]*\).*/\1/p' | tr -d '\r'
+}
+code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
+
+DECK=$(login deckop)
+BOSS=$(login boss)
+[ -n "$DECK" ] && [ -n "$BOSS" ] || { say "could not log in - check $DIR/host.log"; exit 2; }
+
+say "0. the right arrives from the CONFIG FILE, not only from an admin call"
+# â ï¸ THE CHECK THAT WAS MISSING, and its absence shipped a broken host.
+# Every other step here grants the right through the admin API, which exercises
+# SetIsStation on a RUNNING host. The other way in - config -> AuthService at
+# startup - was never touched, and main.cpp did not pass is_station to AddUser at
+# all. A default argument of `false` made that compile silently, so the file said
+# the operator had the right and the running host said they did not.
+# Two mechanisms are two tests. cfgop gets it from the file and nothing else.
+CFG_TOK=$(login cfgop)
+b=$(curl -s "http://127.0.0.1:$DASH/api/tune/amp/probe?token=$CFG_TOK")
+c=$(code "http://127.0.0.1:$DASH/api/tune/amp/probe?token=$CFG_TOK")
+[ "$c" != "403" ] && ok "config-declared station right reached the running host" \
+ || bad "is_station in the config did not load (HTTP $c): $b"
+# and /api/auth/status must agree, since that is what a client greys the button on
+printf '%s' "$(curl -s "http://127.0.0.1:$DASH/api/auth/status?token=$CFG_TOK")" \
+ | grep -q '"is_station":true' \
+ && ok "/api/auth/status reports it too" \
+ || bad "/api/auth/status does not report is_station for a station account"
+
+say "1. no station right"
+c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK")
+[ "$c" = "403" ] && ok "refused with 403 (not a 200 the deck would read as success)" \
+ || bad "expected 403, got $c"
+
+say "2. transmit rights do NOT imply station rights"
+# deckop already has can_transmit=true and must still be refused above.
+c=$(code "http://127.0.0.1:$DASH/api/ptt/off?token=$DECK")
+[ "$c" = "200" ] && ok "the same account CAN transmit, and still cannot amp tune" \
+ || bad "expected the transmit route to work for this account, got $c"
+
+say "3. station right granted"
+# â ï¸ NOT "is it non-403". The bug being guarded against ANSWERS 200 WITH A
+# REFUSAL, so a status-only assertion here passes while the gate is broken -
+# which is exactly what happened the first time this check was run against an
+# injected bug. The body has to show the amp route actually ran.
+curl -fsS "http://127.0.0.1:$DASH/api/admin/user/station/enable/deckop?token=$BOSS" >/dev/null
+b=$(curl -s "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK")
+c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK")
+if [ "$c" = "200" ] && printf '%s' "$b" | grep -q '"tuner":"amp"' \
+ && ! printf '%s' "$b" | grep -qi 'station right\|connected locally'; then
+ ok "allowed, and the reply came from the amp route: $b"
+else
+ bad "expected the amp route to answer, got HTTP $c body: $b"
+fi
+
+say "3b. station right is NOT enough on its own - transmit must also be allowed"
+# â ï¸ The live host has an account shaped exactly like this: the `pusher`
+# account the Stream Deck session belongs to has can_transmit=false. Amp tune is
+# gated separately from IsTransmitRoute, so without this check a station grant
+# would hand a ten-second carrier to an account explicitly denied transmit.
+curl -fsS "http://127.0.0.1:$DASH/api/admin/user/tx/disable/deckop?token=$BOSS" >/dev/null
+c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK")
+[ "$c" = "403" ] && ok "station right alone does not key the rig" \
+ || bad "an account denied transmit could amp tune, got $c"
+curl -fsS "http://127.0.0.1:$DASH/api/admin/user/tx/enable/deckop?token=$BOSS" >/dev/null
+
+say "4. revoking reaches the LIVE session, not just the stored user"
+curl -fsS "http://127.0.0.1:$DASH/api/admin/user/station/disable/deckop?token=$BOSS" >/dev/null
+c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK")
+[ "$c" = "403" ] && ok "refused again without a re-login" \
+ || bad "revoke did not reach the live session, got $c"
+
+say "5. the loopback control listener still needs no session"
+b=$(curl -s "http://127.0.0.1:$CTRL/api/tune/amp")
+c=$(code "http://127.0.0.1:$CTRL/api/tune/amp")
+if [ "$c" = "200" ] && printf '%s' "$b" | grep -q '"tuner":"amp"'; then
+ ok "local console unchanged: $b"
+else
+ bad "control listener did not reach the amp route, HTTP $c body: $b"
+fi
+
+say "5b. THE ACTUAL STREAM DECK URL - note the trailing slash"
+# â ï¸ This is the bug the operator kept reporting. The deck button sends
+# "/api/tune/amp/", which did not match the exact route and fell through to the
+# prefix catch-all - answering 200 "Amp tuner is not configured" and never tuning.
+# The reference host trims trailing slashes on /api/ paths (ApiServer.cs:766);
+# this host did not. Measured from the live journal, not guessed:
+# dash GET /api/tune/amp/
+b=$(curl -s "http://127.0.0.1:$CTRL/api/tune/amp/")
+if printf '%s' "$b" | grep -q '"action":"started"\|"action":"stopped"'; then
+ ok "trailing-slash URL reaches the real tuner: $b"
+else
+ bad "trailing slash did not reach the tuner: $b"
+fi
+
+say "6. the prefix guard agrees with the exact route"
+c=$(code "http://127.0.0.1:$DASH/api/tune/amp/anything?token=$DECK")
+[ "$c" = "403" ] && ok "/api/tune/amp/... refuses too" || bad "prefix guard disagrees, got $c"
+
+[ "$FAIL" = "0" ] && say "PASS" || say "FAILED"
+exit "$FAIL"
diff --git a/tools/check_macos_bundle.py b/tools/check_macos_bundle.py
new file mode 100755
index 0000000..a43bc68
--- /dev/null
+++ b/tools/check_macos_bundle.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""Is the built .app actually a named, iconned, mic-capable Mac application?
+
+⚠️ WRITTEN BECAUSE 0.1.29 SHIPPED SIGNED, NOTARISED AND WRONG. The DMG installed
+and the app ran, and macOS called it "hamdeck-qml" and drew it with the blank
+generic-document icon, because CMake names a bundle after the target and its
+stock Info.plist has no icon key. Nothing failed. There was no default that could
+have been right, and no check that looked.
+
+It also covers the one that had not bitten yet: an app with the microphone
+ENTITLEMENT but no NSMicrophoneUsageDescription is SIGKILLed by macOS the moment
+it opens the mic. That is the first PTT of the day, on the operator's machine,
+with nothing in the log - so it is checked here, on every push, on Linux too.
+
+Everything is read with plistlib and struct rather than plutil/iconutil, so this
+same gate runs on the Linux CI leg and on a workstation with no Xcode.
+
+Usage: check_macos_bundle.py [expected name]
+"""
+import os, plistlib, struct, sys
+
+# The OSTypes brand/build.sh packs, and the pixel size each one must contain.
+ICNS_TYPES = {b'icp4': 16, b'ic11': 32, b'icp5': 32, b'ic12': 64, b'ic07': 128,
+ b'ic13': 256, b'ic08': 256, b'ic14': 512, b'ic09': 512, b'ic10': 1024}
+
+
+def check_icns(path, fails):
+ d = open(path, 'rb').read()
+ if d[:4] != b'icns':
+ fails.append(f"{path} is not an icns (magic {d[:4]!r})")
+ return
+ declared = struct.unpack('>I', d[4:8])[0]
+ if declared != len(d):
+ fails.append(f"icns declares {declared} bytes but the file is {len(d)}")
+ off, seen = 8, {}
+ while off + 8 <= len(d):
+ t = d[off:off + 4]
+ n = struct.unpack('>I', d[off + 4:off + 8])[0]
+ if n < 8 or off + n > len(d):
+ fails.append(f"icns entry {t!r} has a bad length {n}")
+ return
+ blob = d[off + 8:off + n]
+ off += n
+ # ⚠️ Trust the PNG's own header, not the slot it was filed under. An icns
+ # holding the 512px art under ic10 is structurally perfect and looks soft
+ # on exactly the Retina display the 1024 entry exists for.
+ if blob[:8] == b'\x89PNG\r\n\x1a\n':
+ w, h = struct.unpack('>II', blob[16:24])
+ want = ICNS_TYPES.get(t)
+ if want and (w, h) != (want, want):
+ fails.append(f"icns {t.decode()}: artwork is {w}x{h}, the slot needs {want}x{want}")
+ seen[t] = (w, h)
+ else:
+ fails.append(f"icns {t.decode()} is not PNG data")
+ missing = sorted(t.decode() for t in ICNS_TYPES if t not in seen)
+ if missing:
+ fails.append("icns is missing types: " + " ".join(missing) +
+ " (macOS then scales a smaller entry up, on Retina, forever)")
+ return len(seen)
+
+
+def main():
+ if not 2 <= len(sys.argv) <= 3:
+ print("usage: check_macos_bundle.py [expected name]")
+ return 2
+ app = sys.argv[1].rstrip('/')
+ want_name = sys.argv[2] if len(sys.argv) == 3 else "HamDeck Remote"
+ fails = []
+
+ if not os.path.isdir(app):
+ print(f"FAIL: no bundle at {app}")
+ return 1
+ base = os.path.basename(app)
+ if base != f"{want_name}.app":
+ fails.append(f"the bundle is called {base}, not {want_name}.app "
+ f"- OUTPUT_NAME did not apply, and Finder shows this name")
+
+ plist_path = os.path.join(app, "Contents", "Info.plist")
+ if not os.path.isfile(plist_path):
+ print(f"FAIL: no Info.plist in {app}")
+ return 1
+ with open(plist_path, 'rb') as fh:
+ info = plistlib.load(fh)
+
+ for key in ("CFBundleName", "CFBundleDisplayName"):
+ got = info.get(key)
+ if got != want_name:
+ fails.append(f"{key} is {got!r}, not {want_name!r} "
+ f"(this is the name in the menu bar and under the Dock icon)")
+ if not info.get("CFBundleIdentifier"):
+ fails.append("CFBundleIdentifier is empty")
+ if not info.get("CFBundleShortVersionString"):
+ fails.append("CFBundleShortVersionString is empty - the Finder Get Info version")
+ if info.get("NSHighResolutionCapable") is not True:
+ fails.append("NSHighResolutionCapable is not true - the app renders at 1x and is scaled")
+
+ mic = info.get("NSMicrophoneUsageDescription")
+ if not mic:
+ fails.append("NSMicrophoneUsageDescription is MISSING - macOS SIGKILLs the app "
+ "on the first PTT, with no prompt and no log line")
+
+ exe = info.get("CFBundleExecutable")
+ exe_path = os.path.join(app, "Contents", "MacOS", exe or "")
+ if not exe or not os.path.isfile(exe_path):
+ fails.append(f"CFBundleExecutable is {exe!r}, and Contents/MacOS/{exe} is not a file")
+
+ entries = None
+ icon = info.get("CFBundleIconFile")
+ if not icon:
+ fails.append("CFBundleIconFile is MISSING - this is the blank generic icon, exactly "
+ "what 0.1.29 shipped")
+ else:
+ if not icon.endswith(".icns"):
+ icon += ".icns"
+ icon_path = os.path.join(app, "Contents", "Resources", icon)
+ if not os.path.isfile(icon_path):
+ fails.append(f"CFBundleIconFile names {icon}, which is not in Contents/Resources "
+ f"- the icon was added at INSTALL time, not build time")
+ else:
+ entries = check_icns(icon_path, fails)
+
+ if fails:
+ print(f"FAIL: {os.path.basename(app)} is not a properly formed Mac application")
+ for f in fails:
+ print(f" - {f}")
+ return 1
+ print(f'ok: "{base}" - CFBundleName/DisplayName {want_name!r}, id {info["CFBundleIdentifier"]}, '
+ f'v{info["CFBundleShortVersionString"]}, {entries} icns entries, mic string present')
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/rig_replug_test.sh b/tools/rig_replug_test.sh
new file mode 100755
index 0000000..7c30766
--- /dev/null
+++ b/tools/rig_replug_test.sh
@@ -0,0 +1,89 @@
+#!/bin/bash
+# GATE: prove the station recovers by itself when the radio is unplugged and
+# plugged back in.
+#
+# 09/02/2026 it did not. The cable moved, CAT came back as a different minor
+# number, and the host sat there holding /dev/ttyUSB0 (deleted) reporting
+# rig_connected:false until someone noticed.
+#
+# The replug is simulated by unbinding BOTH USB devices from the kernel's usb
+# driver and binding them back. That produces the same udev remove/add events a
+# physical replug does, which is what the recovery is built on. It does not
+# touch the hypervisor's passthrough.
+#
+# Run it ON THE HOST BOX (VM 105). sudo ./tools/rig_replug_test.sh
+set -uo pipefail
+
+HEALTH=http://127.0.0.1:5001/api/health
+CAT_ID=10c4:ea70 # CP2105 dual UART (CAT)
+CODEC_ID=08bb:29c3 # PCM2903C (audio)
+DEADLINE=${DEADLINE:-90}
+
+[ "$(id -u)" = 0 ] || { echo "FAIL: run with sudo"; exit 1; }
+
+connected() { curl -s -m3 "$HEALTH" | grep -q '"rig_connected":true'; }
+
+# The sysfs name (e.g. "2-1") of a usb device, by vendor:product.
+usb_path() {
+ local vid=${1%:*} pid=${1#*:} d
+ for d in /sys/bus/usb/devices/*; do
+ [ -r "$d/idVendor" ] || continue
+ if [ "$(cat "$d/idVendor")" = "$vid" ] && [ "$(cat "$d/idProduct")" = "$pid" ]; then
+ basename "$d"; return 0
+ fi
+ done
+ return 1
+}
+
+cat_path=$(usb_path $CAT_ID) || { echo "FAIL: CP2105 not present - nothing to test"; exit 1; }
+codec_path=$(usb_path $CODEC_ID) || { echo "FAIL: codec not present - nothing to test"; exit 1; }
+echo "CAT at $cat_path, codec at $codec_path"
+
+connected || { echo "FAIL: rig is not connected BEFORE the test - fix that first"; exit 1; }
+echo "before: rig_connected=true"
+
+# Always try to put the radio back, even if the script dies mid-way.
+#
+# ⚠️ Test for the DRIVER symlink, not for the device directory. Unbinding does
+# not remove the device from /sys/bus/usb/devices - it only detaches the driver -
+# so a "does the device still exist" guard here skips the rebind every time and
+# leaves the station off the air. That happened on the first run of this script.
+rebind() {
+ for p in "$codec_path" "$cat_path"; do
+ [ -e "/sys/bus/usb/devices/$p/driver" ] && continue
+ echo -n "$p" > /sys/bus/usb/drivers/usb/bind 2>/dev/null
+ sleep 1
+ done
+}
+trap rebind EXIT
+
+echo -n "$cat_path" > /sys/bus/usb/drivers/usb/unbind
+echo -n "$codec_path" > /sys/bus/usb/drivers/usb/unbind
+sleep 3
+echo "unplugged: /dev/ttyRIG exists? $([ -e /dev/ttyRIG ] && echo yes || echo no); unit $(systemctl is-active hamdeck-cpp.service)"
+
+rebind
+trap - EXIT
+
+start=$SECONDS
+while [ $((SECONDS - start)) -lt $DEADLINE ]; do
+ if connected; then
+ took=$((SECONDS - start))
+ pid=$(systemctl show -p MainPID --value hamdeck-cpp.service)
+ stale=$(ls -l /proc/"$pid"/fd 2>/dev/null | grep -c "(deleted)")
+ node=$(readlink -f /dev/ttyRIG)
+ echo "after: rig_connected=true in ${took}s, CAT node $node, stale fds $stale"
+ [ "$stale" = 0 ] || { echo "FAIL: recovered but still holding $stale dead handles"; exit 1; }
+ # Connected is not the same as reading the rig. Ask it something.
+ curl -s -m3 http://127.0.0.1:5001/api/status | grep -q '"freq":[1-9]' \
+ || { echo "FAIL: rig_connected=true but /api/status has no frequency"; exit 1; }
+ echo "PASS"
+ exit 0
+ fi
+ sleep 2
+done
+
+echo "FAIL: still not connected ${DEADLINE}s after the radio came back"
+systemctl is-active hamdeck-cpp.service
+curl -s -m3 "$HEALTH"; echo
+exit 1