From 12e39ff6dba00f7324c83019fd9833d8f32eed34 Mon Sep 17 00:00:00 2001 From: wa0o Date: Tue, 1 Sep 2026 18:53:00 +0000 Subject: [PATCH 01/11] Measure what NetLogger and the logbook can say about audio attribution The check-in record has no timestamp, so a recording cannot be segmented from net history after the fact - attribution is captured live or never. GetCheckins does return , the SerialNo of the currently working station, which is the only live who-is-up signal. The logbook turned out to be the better index: 98.3% of net QSO stamps carry non-zero seconds and the deduplicated median gap is 211s. The first measurement said median 0, which was 5,900 same-callsign duplicate pairs rather than bulk logging - recorded here because that artifact would have killed a workable design. --- docs/AUDIT-AUDIO-ATTRIBUTION.md | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/AUDIT-AUDIO-ATTRIBUTION.md 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. From a02687dafc80580de92aae3844d872c75c2e7f39 Mon Sep 17 00:00:00 2001 From: wa0o Date: Tue, 1 Sep 2026 21:20:52 +0000 Subject: [PATCH 02/11] Amp tune: ask WHO is calling, not WHERE from, and refuse with 403 The Stream Deck amp tune button has been dead since the rig moved to its own box, and nothing reported it, because the refusal was served as HTTP 200 with an error body. A deck button reads 200 as success: green tick, no carrier, no complaint. The restriction itself is not new and is not being loosened. It was ported faithfully from the reference host: private object? AmpTuneOrDeny(bool isLocal) => isLocal ? _amp.Tune() : ... "Amp tune is only available when connected locally." isLocal was the correct test THERE because the C# host ran on the station PC, so loopback proved an operator was sitting in front of it and all 44 Stream Deck buttons pointed at localhost:5001. 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. So the question moves from where to who. Amp tune now needs the loopback console or a session whose account carries is_station - a right granted by a deliberate admin act, defaulting to false, so no existing account gains a ten-second unattended carrier by upgrading. It is deliberately NOT implied by can_transmit: "may key the rig, with a hand on it" and "may start an unattended carrier into an amplifier" are different claims. Refusals are 403 now. A gate that refuses with a success code cannot be told from one that works, which is the whole reason this went unnoticed. tools/amp_gate_check.sh drives the real binary over HTTP on both listeners and asserts the status code the deck actually reacts to. It was verified by reintroducing the bug: steps 1, 3 and 4 fail, and pass again on restore. The first draft of step 3 asserted only "not 403" and PASSED against the injected bug, since that bug refuses with 200 - it now checks the body came from the amp route. --- CARRYOVER.md | 15 ++++- CMakeLists.txt | 7 +++ src/amp_tuner.h | 12 +++- src/api.cpp | 116 ++++++++++++++++++++++++++++++------- src/auth.cpp | 33 +++++++++-- src/auth.h | 21 ++++++- src/config.cpp | 6 +- src/config.h | 12 ++++ tools/amp_gate_check.sh | 125 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 313 insertions(+), 34 deletions(-) create mode 100755 tools/amp_gate_check.sh diff --git a/CARRYOVER.md b/CARRYOVER.md index 7f6935a..e88c21d 100644 --- a/CARRYOVER.md +++ b/CARRYOVER.md @@ -57,6 +57,18 @@ 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 +193,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/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..2dc115f 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -1471,27 +1471,56 @@ 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) { + const bool at_station = + trusted || (amp_auth && amp_auth->IsStation(ExtractToken(req))); + 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 +1746,13 @@ 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) { + if (!(trusted || (amp_auth && amp_auth->IsStation(ExtractToken(req))))) { + WriteJson(res, 403, + R"({"status":"error","station":false,)" + R"("message":"Amp tune needs the station right."})"); return; } WriteJson(res, 200, @@ -1933,6 +1963,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 +1975,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)); @@ -2100,6 +2133,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..b109d3a 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 { @@ -44,7 +50,8 @@ class AuthService { static bool VerifyPassword(const std::string& password, const std::string& stored); void AddUser(const std::string& username, const std::string& password_hash, - bool is_admin = false, bool can_transmit = true); + bool is_admin = false, bool can_transmit = true, + bool is_station = false); bool IsConfigured() const; @@ -55,6 +62,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 +74,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 +107,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/tools/amp_gate_check.sh b/tools/amp_gate_check.sh new file mode 100755 index 0000000..94ee0f7 --- /dev/null +++ b/tools/amp_gate_check.sh @@ -0,0 +1,125 @@ +#!/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 "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 "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 "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" From 11ac91e3dbde111130fe2c6b0f450d8c31b5f03f Mon Sep 17 00:00:00 2001 From: wa0o Date: Tue, 1 Sep 2026 21:23:44 +0000 Subject: [PATCH 03/11] Amp tune also requires can_transmit, not just the station right Found while looking at the live host's user list to decide who to grant. The `pusher` account - the one the Stream Deck's session belongs to - has can_transmit=false, and amp tune is gated separately from IsTransmitRoute because it predates that list. So a station grant alone would have handed a ten-second unattended carrier to an account explicitly denied transmit. Denied transmit has to mean it everywhere, or it means nothing. Step 3b of the gate check covers it, verified by removing the CanTransmit term and watching it fail. --- src/api.cpp | 16 ++++++++++++++-- tools/amp_gate_check.sh | 11 +++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/api.cpp b/src/api.cpp index 2dc115f..97a8406 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -1495,8 +1495,18 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, AmpTuner* amp = deps.amp; 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(ExtractToken(req))); + 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 @@ -1749,7 +1759,9 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, server.GetPrefix("/api/tune/amp/", [trusted, amp_auth](const std::string&, const HttpRequest& req, HttpResponse& res) { - if (!(trusted || (amp_auth && amp_auth->IsStation(ExtractToken(req))))) { + 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."})"); diff --git a/tools/amp_gate_check.sh b/tools/amp_gate_check.sh index 94ee0f7..baddd6e 100755 --- a/tools/amp_gate_check.sh +++ b/tools/amp_gate_check.sh @@ -102,6 +102,17 @@ 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") From 93e04c5d8d2888337f3eaea92da6254b5c8e5540 Mon Sep 17 00:00:00 2001 From: wa0o Date: Tue, 1 Sep 2026 21:24:49 +0000 Subject: [PATCH 04/11] WIP: record the amp tune diagnosis and what is still open --- WIP.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/WIP.md b/WIP.md index 3f5df18..80145db 100644 --- a/WIP.md +++ b/WIP.md @@ -1264,3 +1264,55 @@ 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. + +### Open — needs Joe +The last step is one grant, and it needs a fact only he has: **which account the pusher logs +in as**, and whether that account should hold transmit rights. The host log records the login +path but not the username, so it cannot be read off the box. + + joe admin tx station=false + listener -- station=false + pusher -- station=false <- tx denied + wa0o tx station=false + +Deployed to the VM: build `a4a5e239426f`, 16/16 tests green there. From 2795916e85c92d230e38e8fe39c0eaba7a52ff09 Mon Sep 17 00:00:00 2001 From: wa0o Date: Tue, 1 Sep 2026 21:37:04 +0000 Subject: [PATCH 05/11] Expose is_station on /api/auth/status, and record the wa0o grant A client cannot tell an amp tune button it may press from one that will answer 403, so it shows a live button that does nothing - the failure this whole change exists to remove. CARRYOVER.md section 2 already says a button that always errors is worse than a missing one. It is also the only way to confirm the right is live without keying an amplifier to find out. --- WIP.md | 24 +++++++++++++++++++----- src/api.cpp | 8 +++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/WIP.md b/WIP.md index 80145db..9e9d5fd 100644 --- a/WIP.md +++ b/WIP.md @@ -1305,14 +1305,28 @@ the bug being guarded against refuses with **200** — so the assertion could no 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. -### Open — needs Joe -The last step is one grant, and it needs a fact only he has: **which account the pusher logs -in as**, and whether that account should hold transmit rights. The host log records the login -path but not the username, so it cannot be read off the box. +### 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 `a4a5e239426f`, 16/16 tests green there. +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. diff --git a/src/api.cpp b/src/api.cpp index 97a8406..cddc2ca 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")); }); From 130831747796b2d516fd60285a61959949ea0970 Mon Sep 17 00:00:00 2001 From: wa0o Date: Tue, 1 Sep 2026 21:42:30 +0000 Subject: [PATCH 06/11] Actually load is_station from the config, and delete the default that hid it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The station right worked through the admin API and did not exist at startup. main.cpp's config loader called AddUser without is_station, the `= false` default argument made that compile cleanly, and every restart dropped the flag. The config file said the operator held the right; the running host said they did not; nothing warned, in either direction. Caught on the live host, not here: wa0o was granted the right, the file read back true, and /api/admin/users on the running service reported is_station=false. ⚠️ THE TEST PASSED THROUGHOUT. Every step of amp_gate_check.sh granted the right by calling the admin API, which exercises SetIsStation on a running host. The other way in - config -> AuthService at startup - was never touched by anything. Two mechanisms are two tests, and this is the second one arriving late. Step 0 now declares the right in the config file and in nothing else; reintroducing the dropped argument fails it. AddUser has no default arguments any more. A missing right must be a compile error rather than a silent false, so adding a fourth right breaks the build until every call site has decided what it means. The compiler immediately found seven call sites, which is the point. --- src/api.cpp | 5 ++++- src/auth.h | 11 +++++++++-- src/main.cpp | 9 +++++++-- tests/test_admin.cpp | 6 +++--- tests/test_auth.cpp | 5 +++-- tests/test_remote_active.cpp | 4 ++-- tools/amp_gate_check.sh | 22 +++++++++++++++++++++- 7 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/api.cpp b/src/api.cpp index cddc2ca..5af3422 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -2032,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, diff --git a/src/auth.h b/src/auth.h index b109d3a..8d6fb39 100644 --- a/src/auth.h +++ b/src/auth.h @@ -49,9 +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_station = false); + bool is_admin, bool can_transmit, bool is_station); bool IsConfigured() const; 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 index baddd6e..7872afd 100755 --- a/tools/amp_gate_check.sh +++ b/tools/amp_gate_check.sh @@ -46,7 +46,8 @@ cat > "$CFG" < 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)" \ From 878f493170cad8ac9ae6ed19a194f920e826af72 Mon Sep 17 00:00:00 2001 From: wa0o Date: Tue, 1 Sep 2026 21:44:31 +0000 Subject: [PATCH 07/11] WIP: the grant that kept vanishing, and the two traps under it --- WIP.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/WIP.md b/WIP.md index 9e9d5fd..3f3ac3a 100644 --- a/WIP.md +++ b/WIP.md @@ -1330,3 +1330,36 @@ 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`. From e6a685eff5c5907da127dd66dc95314e2b8ecef4 Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 01:12:11 +0000 Subject: [PATCH 08/11] Trim trailing slashes on /api/ paths - the actual reason the amp button did nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE ROOT CAUSE, and it is not the permission gate I spent the evening on. The Stream Deck sends "/api/tune/amp/" WITH A TRAILING SLASH. Measured from the host's own journal: dash GET /api/tune/amp/ That does not match the exact route "/api/tune/amp". It matched the PREFIX route "/api/tune/amp/" instead - the not-configured catch-all - which answers 200 and never calls Tune(). So the button got a cheerful success and did nothing, with or without any rights. The reference host trims first and this host did not: if (path.StartsWith("/api/")) { var trimmed = path.TrimEnd('/'); ... Services/ApiServer.cs:764-766 The API is the contract between the two hosts. A divergence in how a path is MATCHED is as much a break as a missing route, and it is worse to find because every route inventory says the route exists - AUDIT-CSHARP.md ticked this one. Normalised in BuildRequest, where the path is first built, so the auth, admin and transmit gates see the same string the router will match. At the router instead, "/api/ptt/on/" would skip IsTransmitRoute while still dispatching - a permission check walked around by one keystroke. ⚠️ This may repair other buttons too. The 71/74 route sweep was driven with clean paths, so any button sending a trailing slash was never actually exercised. Step 5b drives the real URL and asserts it reaches the tuner. --- src/http.cpp | 21 +++++++++++++++++++++ tools/amp_gate_check.sh | 14 ++++++++++++++ 2 files changed, 35 insertions(+) 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/tools/amp_gate_check.sh b/tools/amp_gate_check.sh index 7872afd..e09fc7c 100755 --- a/tools/amp_gate_check.sh +++ b/tools/amp_gate_check.sh @@ -148,6 +148,20 @@ 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" From 98e2ff57fbc161704f3f770d0ea62179ca2e4378 Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 01:13:27 +0000 Subject: [PATCH 09/11] Record the trailing-slash contract break in CARRYOVER and WIP --- CARRYOVER.md | 17 +++++++++++++++++ WIP.md | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/CARRYOVER.md b/CARRYOVER.md index e88c21d..c63b85a 100644 --- a/CARRYOVER.md +++ b/CARRYOVER.md @@ -52,6 +52,23 @@ 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 diff --git a/WIP.md b/WIP.md index 3f3ac3a..bc65b55 100644 --- a/WIP.md +++ b/WIP.md @@ -1363,3 +1363,27 @@ Live state, verified in the running host AND on disk after a flush: 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`. From a4c00cb53a2610b94bb4e49155493cbe34adf314 Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 04:09:38 +0000 Subject: [PATCH 10/11] Recover the station by itself when the radio is unplugged The cable moved and the host kept running with three deleted device handles, reporting rig_connected:false forever - it opens CAT and the codec once at startup and has no reconnect path. The held fd also pushed the returning CP2105 onto ttyUSB1, so the configured /dev/ttyUSB0 no longer existed. Recovery from outside the process, since the host cannot rescan: - 99-hamdeck-radio.rules: /dev/ttyRIG by vid:pid + interface 00, never a minor number, and SYSTEMD_WANTS so plugging the radio in starts the host. - rig-device.conf: BindsTo=dev-ttyRIG.device, so unplugging stops the host and drops the stale fds. Restart=always with no start limit. - hamdeck-rig-watchdog: 30s timer, fires only on a deleted /dev fd or a CAT fd that is not what /dev/ttyRIG points at - never on rig_connected:false alone, which is also what a radio switched off looks like. tools/rig_replug_test.sh proves it, and was proven to FAIL first: with the drop-in removed the same test reports the unit still active with three deleted fds; with it in place, unplug leaves the unit inactive and replug reconnects in 0s with 0 stale fds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014e3NDHXQ4YZ96ouKz9XBhw --- WIP.md | 59 +++++++++++++ deploy/99-hamdeck-radio.rules | 13 +++ deploy/hamdeck-cpp.service.d/rig-device.conf | 21 +++++ deploy/hamdeck-rig-watchdog | 44 ++++++++++ deploy/hamdeck-rig-watchdog.service | 7 ++ deploy/hamdeck-rig-watchdog.timer | 10 +++ tools/rig_replug_test.sh | 89 ++++++++++++++++++++ 7 files changed, 243 insertions(+) create mode 100644 deploy/99-hamdeck-radio.rules create mode 100644 deploy/hamdeck-cpp.service.d/rig-device.conf create mode 100755 deploy/hamdeck-rig-watchdog create mode 100644 deploy/hamdeck-rig-watchdog.service create mode 100644 deploy/hamdeck-rig-watchdog.timer create mode 100755 tools/rig_replug_test.sh diff --git a/WIP.md b/WIP.md index bc65b55..de82960 100644 --- a/WIP.md +++ b/WIP.md @@ -1387,3 +1387,62 @@ 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. 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/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 From c65b363d7d994228489cd696fb2230f5ccdbaf4e Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 04:44:27 +0000 Subject: [PATCH 11/11] Give the Mac app a name, an icon and a microphone string v0.1.29 installed, launched and worked, and macOS called it "hamdeck-qml" and drew it with the blank generic icon. Nothing 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. - packaging/icons/hamdeck.icns, generated by brand/build.sh in the same render-pack-verify pass as the .ico so the two families cannot drift. Apple's grid: artwork is 824 of 1024, centred. The inset moves the small-art boundary up a slot, so mark-small covers 16 and 32 here; the 16pt 1x slot is full-bleed because at 13px the two-element drawing stops being legible - measured, not assumed. - OUTPUT_NAME renames the bundle to "HamDeck Remote.app" on APPLE only, with CFBundleName and CFBundleDisplayName set separately. The icns ships as a target source in Contents/Resources, not an install(FILES): signing and notarisation run against the build tree, so an install-time icon is signed into nothing. - NSMicrophoneUsageDescription, which had not bitten yet. The entitlement says the app may ask; the string is what it asks with. Without it macOS SIGKILLs the process on the first PTT, with no prompt and no log line. - tools/check_macos_bundle.py reads the built bundle with plistlib and struct, so it runs on Linux with no Xcode. Wired into build.yml on every push and 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 and watched every one fail. - Removed the bare-binary fallbacks in the macOS CI paths: they would hide a bundle that reverted to the target name instead of failing on it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9 --- .github/workflows/build.yml | 42 ++++++++--- .github/workflows/release.yml | 51 ++++++++++--- WIP.md | 65 ++++++++++++++++ client/CMakeLists.txt | 44 ++++++++++- client/packaging/Info.plist.in | 44 +++++++++++ packaging/icons/hamdeck.icns | Bin 0 -> 66594 bytes tools/check_macos_bundle.py | 133 +++++++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 23 deletions(-) create mode 100644 client/packaging/Info.plist.in create mode 100644 packaging/icons/hamdeck.icns create mode 100755 tools/check_macos_bundle.py 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/WIP.md b/WIP.md index de82960..53a1005 100644 --- a/WIP.md +++ b/WIP.md @@ -1446,3 +1446,68 @@ 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/packaging/icons/hamdeck.icns b/packaging/icons/hamdeck.icns new file mode 100644 index 0000000000000000000000000000000000000000..f1ddfce54b55754891e17972584db4510328edcb GIT binary patch literal 66594 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 [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())