Skip to content

[agent] Add a native Swift client alongside Hammerspoon - #13

Closed
drycode wants to merge 4 commits into
mainfrom
dy/native-swift-agent
Closed

[agent] Add a native Swift client alongside Hammerspoon#13
drycode wants to merge 4 commits into
mainfrom
dy/native-swift-agent

Conversation

@drycode

@drycode drycode commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Part 1 of 2 for #2. Adds the native agent alongside the Hammerspoon client
and changes nothing about the existing path — client/init.lua and
install-client.sh are untouched, so a running install keeps working. Part 2
deletes the Lua client once both my machines are proven on the agent.

Draft because it has not been run yet. The bundle builds, signs, and
verifies in CI, and the scripted surface is under test, but no one has held the
hotkey and watched a transcript land. That needs a machine with the TCC prompts
answered. Marking ready once it is exercised end to end.

Three decisions the constraints made, not preference

An .app bundle, not a bare binary. TCC keys a bundle's grants to its
identifier and signature, so they survive a rebuild. A bare binary is keyed by
path and cdhash and loses the grant on every recompile — and macOS leaves the
stale row in the privacy pane with its toggle still ON, so there is no prompt
and no error, the hotkey just stops. Bundle stability is the one thing
Hammerspoon was still buying us. Microphone additionally requires
NSMicrophoneUsageDescription, which a bare binary has nowhere to put; without
it the process is killed outright the moment it opens the device.

Carbon RegisterEventHotKey, not NSEvent.addGlobalMonitorForEvents. A
global monitor cannot consume the event it observes, so Ctrl+Alt+Space would
reach the focused app as well as us. RegisterEventHotKey consumes it, delivers
pressed and released as distinct events, and needs no permission of its own — it
is what hs.hotkey used underneath, so it is already proven against this exact
key and hold pattern. Accessibility is still required, but for the synthesized
Cmd+V rather than the hotkey. One prompt, not two.

The mic-status file contract survives, at ~/.config/hark/agent-mic-status.
The reason it existed does not go away with Hammerspoon: TCC attributes a
request to the responsible process, so a probe run from a shell tests the
terminal's grant and produces a confidently wrong PASS. Only the agent can
answer for the agent.

Answering the rest of #2's open questions

  • Signing: ad-hoc for now (codesign -s -), which gives a cdhash identity —
    grants survive until the binary changes, so one re-grant per rebuild rather
    than one per launch. build-agent.sh takes HARK_SIGN_IDENTITY for a
    Developer ID build. The certificate is gated on Apple Developer paperwork, not
    on this PR.
  • Config: ~/.config/hark/client.json, beside the server's own config. JSON
    rather than TOML so this stays a single-file swiftc build — Swift has no
    stdlib TOML parser, and neither a SwiftPM manifest nor a hand-rolled one earns
    its keep for two fields.
  • Upgrade path: install-agent.sh reads ~/.hammerspoon/hark-config.lua and
    never modifies it, so rollback is --uninstall plus relaunching Hammerspoon.

They cannot both hold the hotkey

Ctrl+Alt+Space is a system-wide registration and exactly one process gets it.
Whichever starts first wins; the other reports it could not register. Coexist
means "both installed, one running". install-agent.sh quits Hammerspoon unless
given --keep-hammerspoon, and --doctor fails the check if Hammerspoon is up.

What is kept from init.lua

Everything #2 listed as non-optional: press/release with the double-key-down
guard, the indicator cleared from the process-exit callback rather than on
key-up, X-Hark-Key + audio/wav, the error mapping for 401/415/400/503 and
connection failure each naming its likely cause, the clipboard deliberately not
restored, Cmd+V and never Return, the startup mic probe and Accessibility
check, and rec's exit code and stderr appended to a persisted log
(~/Library/Logs/hark-agent.log).

Two things improved rather than ported: the agent can actually trigger the
Accessibility prompt where the Lua client could only nag, and rec ships inside
the bundle so a nested binary is covered by the bundle's signature.

Tests

29 new cases, aimed at the failure modes that are silent:

  • Info.plist has a non-placeholder microphone usage string, LSUIElement, and
    the identifier TCC will key grants to
  • the LaunchAgent label matches the bundle id — --doctor looks the client up in
    TCC.db by that string
  • write_client_config emits JSON the Swift side decodes, at mode 600, with
    quotes and backslashes escaped
  • config precedence: an existing client.json beats the legacy Lua file beats
    the local server key; no key anywhere fails loudly rather than writing an empty one
  • migration never modifies hark-config.lua
  • --doctor never PASSes a denied or error mic status, or a missing one
  • agent_loaded survives the launchctl | grep -q + pipefail SIGPIPE trap that
    bit install-server.sh in 156bb69
  • the agent source never references kVK_Return, never restores the clipboard,
    and uses a WAV path the Hammerspoon client cannot collide with

CI gains a macOS job that builds the bundle and asserts both it and the nested
rec verify under codesign --strict.

drycode added 3 commits August 3, 2026 15:11
Accessibility is currently granted to Hammerspoon — a general-purpose
scriptable Lua runtime — and its config is a symlink into this repo, so a
git pull changes what that grant covers without re-prompting. A
single-purpose bundle asks for the same permission with far less behind
it.

Ships as an opt-in preview that changes nothing about the existing path:
client/init.lua and install-client.sh are untouched, so a running install
keeps working and rolling back is uninstall plus relaunching Hammerspoon.
The Lua client is deleted in a follow-up, once both machines are proven
on the agent.

Three decisions the constraints made rather than preference:

An .app bundle, not a bare binary. TCC keys a bundle's grants to its
identifier and signature, so they survive a rebuild; a bare binary is
keyed by path and cdhash and loses the grant every recompile, while
macOS leaves the stale row visible with its toggle still ON. Microphone
additionally needs NSMicrophoneUsageDescription, which needs a plist.

Carbon RegisterEventHotKey, not an NSEvent global monitor. A global
monitor cannot consume the event, so Ctrl+Alt+Space would also reach the
focused app. RegisterEventHotKey consumes it, delivers pressed and
released separately, and needs no permission of its own — it is what
hs.hotkey used underneath. Accessibility is still required, for the
synthesized Cmd+V.

The mic-status file contract survives. TCC attributes a request to the
responsible process, so a probe run from a shell tests the terminal's
grant and produces a confidently wrong PASS. Only the agent can answer
for the agent, so it writes the answer where the shell can read it.

Config moves to ~/.config/hark/client.json, beside the server's own
config. JSON rather than TOML so this stays a single-file swiftc build:
Swift has no stdlib TOML parser and neither a SwiftPM manifest nor a
hand-rolled one earns its keep for two fields.

Solves: GitHub issue #2, part 1 of 2
Tests: 29 pytest cases; shellcheck; new macOS CI job builds and verifies the signed bundle
The agent never appeared in System Settings -> Privacy & Security ->
Microphone, and rec exited 3 instantly on every launch.

Under the hardened runtime, which build-agent.sh enables and which
notarization will require, microphone access needs
com.apple.security.device.audio-input. Without it TCC does not deny the
request after asking - it refuses to ASK:

  Prompting policy for hardened runtime; service: kTCCServiceMicrophone
  requires entitlement com.apple.security.device.audio-input but it is
  missing for responsible={com.drycodeworks.hark-agent}
  Policy disallows prompt for ...; access to kTCCServiceMicrophone denied

Both ends of that are silent. The pane lists only apps that have
successfully requested, so a refused prompt means no row ever appears
and there is nothing to switch on. The code sees authorizationStatus ==
.denied immediately, which is exactly what a real user refusal looks
like. Only the unified log names the entitlement.

Dropping --options runtime would also have fixed it, and would have
broken notarization later instead. The entitlement is the real fix, so
the hardened runtime stays on and a test pins it there. Both binaries
are signed with it: the app is the responsible process tccd checks, rec
is the process that opens the device. The build now asserts the
entitlement survived into the signature, because a signature that merely
verifies proves nothing - the bundle signs, launches and runs perfectly
without it and only fails at the microphone, where it reads as a
permission problem rather than a build problem.

Two diagnostics the same run exposed:

The installer slept a fixed 3s before running --doctor, which raced the
consent dialog and reported a spurious microphone FAIL while the prompt
was still on screen. It now polls for the probe's own status file, since
that cannot land until the user has answered.

rec's permission message named Hammerspoon. rec is spawned by both
clients and TCC attributes the grant to whichever is responsible, so the
row to switch on reads "Hammerspoon" under the Lua client and "hark"
under the agent. Naming one sent users looking for a row that was never
going to be there.

Solves: hark issue #2 — first real run on the Studio
Tests: 6 cases pinning the entitlement, both signing calls, the build-time assertion, the hardened runtime and rec's app-agnostic help
The doctor reported "PASS Accessibility is granted" while the agent was
alerting on screen that it could not paste, and System Settings drew a
switched-ON toggle for a binary that nothing trusted. Three sources
agreeing with each other and disagreeing with reality.

The row in TCC.db outlives the grant it describes. An ad-hoc signature's
designated requirement is a bare cdhash:

  designated => cdhash H"6836bec46e8c7d394cf1ba94421ff18a31674867"

so every rebuild is a new identity. The old row survives with
auth_value=2, the pane keeps drawing its toggle, and the running binary
is trusted by nobody. Reading that row can only ever report what was
true for some earlier build.

This is the same rule the microphone probe already followed, arrived at
the expensive way: only the process can answer for the process. The
agent now records its own AXIsProcessTrusted() result to
~/.config/hark/agent-accessibility-status and the doctor reads that.
A missing report is a SKIP with guidance, never a PASS - an agent
predating this check must not be reported as granted.

Trust is re-checked at paste time rather than only at launch, since a
grant can be revoked, or silently invalidated by a rebuild, while the
process keeps running. Paste is the moment it matters.

Also holds Cmd+V for 200ms between key-down and key-up, matching
hs.eventtap.keyStroke's `local keyDelay = 200000`. A zero-duration
keystroke is silently dropped by some apps. That was not what broke this
- the process being untrusted was - but back-to-back posting was a real
difference from the implementation being replaced.

Verified end to end on the Studio: four consecutive dictations recorded,
transcribed and pasted at the cursor.

Solves: hark issue #2 — the false PASS found during first-run bring-up
Tests: 7 cases covering the doctor's three states, that the mechanism cannot come back, and that trust is rechecked at paste time
@drycode

drycode commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Verified end to end on the Studio

Four consecutive dictations recorded, transcribed and pasted at the cursor. Taking this out of draft.

Bring-up found two real defects, both now fixed here, and both of the same family: a permission failure that reports success.

1. The hardened runtime needs an entitlement to even ask

The agent never appeared in System Settings → Privacy & Security → Microphone, and rec exited 3 instantly. --options runtime is on (notarization will require it), and under it microphone access needs com.apple.security.device.audio-input. Without it TCC does not deny after prompting — it refuses to prompt:

Prompting policy for hardened runtime; service: kTCCServiceMicrophone requires
entitlement com.apple.security.device.audio-input but it is missing for
responsible={com.drycodeworks.hark-agent}
Policy disallows prompt for ...; access to kTCCServiceMicrophone denied

Both ends are silent. That pane lists only apps that have successfully requested, so a refused prompt leaves no row to switch on, and the code sees .denied — identical to a real user refusal. Only the unified log names it.

Dropping --options runtime would also have made the symptom go away, and would have broken notarization later instead. The build now asserts the entitlement survived into the signature, because a signature that merely verifies proves nothing: the bundle signs, launches and runs perfectly without it and only fails at the microphone, where it reads as a permission problem rather than a build problem.

2. The doctor reported a grant that had stopped applying

Worse than the first, because it sent the search in the wrong direction. --doctor printed PASS Accessibility is granted while the agent was alerting on screen that it could not paste, and System Settings drew a switched-ON toggle. Three sources agreeing with each other and disagreeing with reality.

An ad-hoc signature's designated requirement is a bare cdhash:

designated => cdhash H"6836bec46e8c7d394cf1ba94421ff18a31674867"

so every rebuild is a new identity. The old TCC row survives with auth_value=2 and the pane keeps drawing its toggle, while the running binary is trusted by nobody. Querying that row can only report what was true for some earlier build.

The fix is the rule the microphone probe already followed, reached the expensive way: only the process can answer for the process. The agent records its own AXIsProcessTrusted() to ~/.config/hark/agent-accessibility-status; the doctor reads that and never touches TCC.db. A missing report is a SKIP with guidance, never a PASS.

Trust is also re-checked at paste time rather than only at launch, and a failure now says the transcript is on the clipboard and to press Cmd+V — the diagnostic that would have made this a one-round-trip problem instead of three.

Also

Cmd+V is now held for 200 ms between key-down and key-up, matching hs.eventtap.keyStroke's local keyDelay = 200000. This was not what broke it — the untrusted process was — but posting the two events back to back was a real difference from the implementation being replaced, and zero-duration keystrokes are dropped by some apps.

rec's permission message no longer names Hammerspoon: it is spawned by both clients and TCC attributes the grant to whichever is responsible, so the row to enable reads "Hammerspoon" under the Lua client and "hark" under the agent.

This is what DRY-723 is for

The re-grant-per-rebuild cost is now measured rather than predicted. Every rebuild invalidates both grants, and the stale-ON-toggle is exactly the confusing state that ticket described. A Developer ID signature replaces the cdhash requirement with the certificate, so grants survive rebuilds outright.

124 tests, shellcheck, and the macOS bundle build all green.

@drycode
drycode marked this pull request as ready for review August 3, 2026 19:36
Every rebuild changes the ad-hoc cdhash, which is the whole of the
bundle's designated requirement, so the Accessibility grant stops
applying. macOS does not tidy up after it: the old row survives with
auth_value=2 and System Settings keeps drawing a switched-ON toggle for
a binary nothing trusts. Observed twice in one afternoon
(6836bec4... -> be5a5c92...), and it is genuinely misleading - you go to
grant the permission, find it already granted, and go looking somewhere
else for the fault.

The installer now records the installed cdhash and, when it changes,
says so plainly and clears the entry with tccutil so the agent's prompt
lands on a cleared slot rather than a stale one. Toggling it off and on
by hand does the same thing; this removes the detour.

Only Accessibility is reset. It is the grant observed to break, and the
microphone path already tells the truth on its own - the agent's probe
runs rec and reports what actually happened, so a stale microphone row
cannot produce the false PASS a stale Accessibility row did. Resetting
it anyway would cost a consent dialog for nothing.

A Developer ID signature makes this dead code, since the requirement
becomes the certificate rather than the hash. Until then this is the
difference between one clean prompt and a confusing detour on every
rebuild.

Solves: hark issue #2 — the re-grant dance, hit on the first real update
Tests: 5 cases covering first install, unchanged reinstall, a changed binary, and that the microphone grant is left alone
@drycode

drycode commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #12. See #14 for the full note — same reason, and the commits here are referenced from it.

For the record: I built this without checking open PRs, only open issues, and read #2's "Open questions" as live when #10/#11 had already settled them. That is on me. The hardware findings it produced are ported onto #12 on dy/swift-rewrite-integration.

Branch kept, not deleted.

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