Skip to content

Give the Mac app a name, an icon and a microphone string - #1

Merged
jwussler merged 11 commits into
mainfrom
macos-bundle-identity
Sep 2, 2026
Merged

Give the Mac app a name, an icon and a microphone string#1
jwussler merged 11 commits into
mainfrom
macos-bundle-identity

Conversation

@jwussler

@jwussler jwussler commented Sep 2, 2026

Copy link
Copy Markdown
Owner

v0.1.29's DMG installs, launches and works — and Finder calls it hamdeck-qml and draws it with the blank generic-document icon. Nothing failed: CMake names a bundle after the target, and its stock Info.plist has no icon key. There was no default that could have been right, and nothing that looked.

What changed

  • packaging/icons/hamdeck.icns — 10 entries, generated by brand/build.sh in the same render→pack→verify pass as the .ico, so the two icon families cannot drift. Apple's grid: the artwork is 824 of 1024, centred, transparent margin. The inset moves the small-art boundary up a slot (the 32pt slot holds only 26px of artwork), so mark-small.svg covers 16 and 32 here. The 16pt 1x slot is full-bleed — at 13px the two-element drawing stops being legible. Rendered both and looked.
  • OUTPUT_NAMEHamDeck 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 — had not bitten yet, and would have. 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 nothing in the log.

The gate

tools/check_macos_bundle.py reads the built bundle with plistlib and struct — no plutil, no iconutil — so it runs on the Linux leg too. Wired into build.yml on every push, and into 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) 5 findings: bundle name, CFBundleName, CFBundleDisplayName, icon key, mic string
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"

Not proven yet

CI checks structure. Nobody has opened the renamed bundle in Finder or keyed up on a Mac.

⚠️ Every macOS path in both workflows now has a space in it and must stay quoted. The [ -f "$BIN" ] || BIN=client/build/hamdeck-qml fallbacks were removed — they would hide the one thing most likely to regress.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9

jwussler and others added 11 commits September 1, 2026 18:53
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 <Pointer>,
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.
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.
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.
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.
… hid it

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.
…on did nothing

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.
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e3NDHXQ4YZ96ouKz9XBhw
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9
@jwussler
jwussler merged commit 3dfb41e into main Sep 2, 2026
3 checks passed
@jwussler
jwussler deleted the macos-bundle-identity branch September 2, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant