Skip to content

Swift rewrite of the dictation client (hark serve / hark agent) - #12

Merged
drycode merged 7 commits into
DRYCodeWorks:mainfrom
STRML:swift-full-rewrite
Aug 3, 2026
Merged

Swift rewrite of the dictation client (hark serve / hark agent)#12
drycode merged 7 commits into
DRYCodeWorks:mainfrom
STRML:swift-full-rewrite

Conversation

@STRML

@STRML STRML commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the native Swift client that replaces Hammerspoon (supersedes #10 design and #11 implementation plan). A single hark binary with two roles:

  • hark serve — HTTP server on 127.0.0.1:8911 (/health, /dictate) with key auth, wildcard-bind guard, WAV validation, and whisper forwarding.
  • hark agent — hold-to-talk recorder + hotkey + DictateClient for the native app.

Packaging under swift/Packaging/ (build-app.sh produces a signed Hark.app).

Test Plan

Full SwiftPM suite is green — 48/48 tests (Config, Sanitize, WAV, Server, Integration, E2E, DictateClient, KeyFile). Two fixes landed during bring-up:

  • DictateClient: construct URLSession with delegate: self. A bare URLSession(configuration:) never delivers delegate callbacks, which hung the synchronous dictate() path on its semaphore forever.
  • IntegrationTests: retain the HarkServer strongly (released in tearDown) so it isn't deallocated mid-test — its connection handler holds weak self.

Also verified end-to-end: release build, ad-hoc codesign, and GET /health{"status":"ok"} HTTP 200.

STRML added 7 commits August 1, 2026 08:14
Spec for DRYCodeWorks#2. Decisions: an LSUIElement bundle with a menu bar status item,
capture absorbed in-process so rec stops being a child binary, config at
~/.config/hark/client.json with the key read from the server's own key file on
a single-machine install, and Carbon RegisterEventHotKey rather than an event
tap.

The hotkey choice is the load-bearing one. RegisterEventHotKey needs no
Accessibility grant, so the agent asks for permission to type rather than
permission to watch you type. An event-tap agent would need exactly the
all-or-nothing grant that made the Hammerspoon arrangement uncomfortable. It
also makes two currently-silent failures visible: a missing Accessibility grant
and a hotkey chord already owned by another app.

Measured while writing this and folded into the design: identical Swift sources
produce a different CDHash on every build. install-client.sh recompiles
unconditionally today, which is harmless only because TCC attributes to
Hammerspoon. Once the agent is the TCC principal, an unguarded rebuild
invalidates its own Accessibility grant, so the installer has to hash its build
inputs and skip the build when they match.

Nothing on the user's machine gets deleted on migration. client/init.lua becomes
a stub that binds no hotkey, so an existing symlink keeps resolving and the two
clients never fight over the hotkey.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
All six reviewers returned REVISE on revision 1, and two of them independently
killed its central argument.

Revision 1 chose Carbon RegisterEventHotKey on the grounds that it asks for
permission to type rather than permission to watch you type. That is wrong:
synthesising Cmd+V needs Accessibility, and Accessibility is one atomic grant
that also permits creating an event tap. It also claimed the API errors when
another app owns the chord; the SDK header says the opposite and exclusivity
needs kEventHotKeyExclusive. The mechanism is now undecided behind a Phase 0
spike, whose real question is whether a keyboard tap additionally needs Input
Monitoring — README:245-247 suggests from this project's own experience that it
might, which would revive the permission argument by a different route.

Assuming a Developer ID deletes rather than fixes a whole section. CI builds,
signs and notarises; the installer downloads instead of compiling, so it can no
longer mint a new CDHash on every re-run and the input-hash guard goes away. It
also drops the Xcode command line tools from the client machine, which is the
point of the surrounding dependency work.

Security findings that were missing entirely: the client never sanitised the
server's response, so a spoofed server on the plaintext two-machine path could
return a newline and have it typed into a terminal. There was no transport
policy and no ATS declaration, so the client would have captured audio and then
failed every POST. 400 was mapped to "microphone permission" inherited from the
Lua client, but capture failures no longer reach the server and the server
returns 400 for every InvalidAudioError, including a malformed header this
client now builds by hand.

Migration was insufficient rather than merely incomplete. A stub does not unload
Lua already running in Hammerspoon, and leaving its grants in place preserves
exactly the privilege escape this work exists to close.

Also corrected: revision 1 claimed tests/test_audio.py pins the wire format. It
validates sample width only, so a stereo 44.1 kHz WAV passes today.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Round 2 of the panel returned 5 REVISE / 1 APPROVED. Almost every remaining objection was the same shape: the design named a mechanism without saying what it actually does. A length cap with no number is not a security contract, and a generation stamp that bash cannot verify is not a freshness check.

Concrete now: 1 MiB response body enforced before JSON decoding, 8 KiB sanitised text, reject rather than truncate; a 5 s starting deadline separate from a 120 s capture cap, because rec.swift:167 arms its ceiling only after the first buffer; and a status file bound to the agent by PID plus process start time with a 30 s heartbeat, which is something --doctor can check from bash.

The transport section contradicted itself, prohibiting non-loopback HTTP while promising a Tailscale opt-in that had nowhere to live. It now has one: an explicit per-host allowlist in client.json. Tailnets already encrypt at the network layer, so HTTP there is defensible, but it should be a stated choice rather than a silent default.

Two things the panel found in shipped code rather than in the design. app.py:103-107 returns microphone advice for every InvalidAudioError and test_app.py:107 asserts that wording, so a malformed header from this client would send users to the wrong settings pane. And migration cannot claim to confirm Hammerspoon released the hotkey, because macOS exposes no way to ask WindowServer who owns a chord; confirmed process exit is the most that can be said.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Round 3 found two contradictions I introduced. Per-host NSExceptionDomains cannot follow client.json, because that key lives in the signed Info.plist and writing to it after download invalidates the signature. Restricting insecure HTTP to IP literals lets a static NSAllowsLocalNetworking cover it without the plist naming any host.

The migration also deadlocked: it required the agent verified healthy before quitting Hammerspoon, while the agent cannot register the hotkey until Hammerspoon releases it. Registration is now split out of the health check and happens after handover, with rollback that restores the symlink and relaunches Hammerspoon.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Round 3 brought the panel down to two contradictions and a format bug, all of
which I had introduced.

Per-host NSExceptionDomains cannot follow client.json: that key lives in the
signed Info.plist, so writing to it after download invalidates the signature and
macOS kills the app at launch. Restricting insecure HTTP to IP literals lets a
static NSAllowsLocalNetworking cover the case without the plist ever naming a
host. A MagicDNS name is consequently not usable over HTTP; use the tailnet IP.

The migration deadlocked. It required the agent verified healthy before quitting
Hammerspoon, while the agent cannot register the hotkey until Hammerspoon
releases it. Registration is now split out of the health check and happens after
handover, with rollback that restores the symlink and relaunches Hammerspoon.

The status file proposed matching an ISO timestamp against ps -o lstart, which
prints a localised non-ISO string. It now stores what ps prints, verbatim, plus
an epoch integer, so --doctor compares strings and integers rather than parsing
dates. It also carries hotkey and login-item state, since a heartbeat that omits
them passes while the product does not work.

Also settled rather than deferred: response bounds now cover the error detail
string, not just the transcript; the paste-target guarantee is stated at
application granularity because that is all it enforces; and the clipboard
self-clears after 90 seconds when changeCount is unchanged, which keeps the
paste-recovery window the original decision wanted while bounding an exposure
any local process could harvest.

Rebased onto 8d12f7b. That matters more than usual here: the branch was cut from
4e35977 and main has since moved by eleven commits, so every line number in the
document was stale. All of them are re-verified against 8d12f7b.

Two sections changed meaning as a result. The zero-frames concern is no longer
speculative, because DRYCodeWorks#9 was confirmed and fixed in 559aafe, and that commit is
now cited as the reference implementation for the agent's permission handling
rather than something this design has to invent. And two thirds of the
superseded-spec work in the blast radius is already done by ef47aeb and c852392,
so the document now points at what remains instead of repeating it.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
The verification pass returned 4 APPROVED / 2 REVISE, and both holdouts found real contradictions.

Rollback restored the symlink, but this same change turns client/init.lua into a no-op stub, so the restored symlink pointed at a client that does nothing. The installer now preserves the working Lua client to ~/.config/hark/legacy-client.lua before cutover and repoints there on failure. It also writes an ownership record, because content-matching the symlink target cannot survive the repo moving: an absolute symlink to a moved checkout is dangling, and there is no content left to match.

Rollback also left the agent installed while SMAppService.register() ran unconditionally at first launch, so a rolled-back install would start the agent at the next login and fight the Hammerspoon it had just restored. Registration is now the last step of a successful cutover rather than part of launch, and rollback unregisters.

And xcrun stapler was required on the client two hundred lines after this document claimed the client no longer needs the Xcode command line tools. stapler ships with Xcode; the staple check moves to CI, where the toolchain already exists, and the client verifies with stock spctl and codesign. A downloaded artifact is quarantined, so spctl exercises the notarisation path anyway.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Single hark binary with two roles (hark serve, hark agent) plus packaging
under swift/Packaging. Full SwiftPM suite is green (48 tests): Config,
Sanitize, WAV, Server, Integration, E2E, DictateClient, KeyFile.

- DictateClient: construct URLSession with delegate:self so delegate
  callbacks fire. A bare URLSession(configuration:) never delivers them,
  which hung the synchronous dictate() path on its semaphore forever.
- IntegrationTests: retain the HarkServer strongly (released in tearDown)
  so it isn't deallocated mid-test — its connection handler holds weak self.
@drycode

drycode commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Heads up: I duplicated a chunk of this in #13/#14 before spotting this PR — I checked open issues and not open PRs, so I read #2's "Open questions" as still live when #10/#11 had already settled them. My fault, and #13/#14 will be closed as superseded by this one.

That does mean the agent side got run on real hardware — two Macs, both now dictating on it, Hammerspoon uninstalled and its grants revoked. Everything below is from that bring-up rather than from review, and two of them are live in this PR. Sharing so you don't have to pay for them twice.

The paste is a zero-duration keystroke

AgentController.swift:167-171 posts key-down and key-up back to back:

down?.flags = .maskCommand
down?.post(tap: .cghidEventTap)
up?.flags = .maskCommand
up?.post(tap: .cghidEventTap)

Some apps silently drop that. The implementation being replaced held the key for 200 ms — from Hammerspoon's eventtap.lua:

local keyDelay = 200000
module.event.newKeyEvent(modifiers, character, true):post(targetApp)
timer.usleep(keyDelay)

Same tap, same flags; the gap is the only difference. I used asyncAfter(deadline: .now() + 0.2) so the run loop isn't stalled.

Worth saying that this was not what broke my first bring-up — an untrusted process was — but it is a real divergence from the known-good implementation.

AXIsProcessTrusted() needs to be re-checked at paste time, and recorded

Everything up to the paste succeeds and logs happily whether or not the keystroke is ever delivered, so an untrusted process is indistinguishable from a dropped event. Checking at paste time and, on failure, telling the user the transcript is on the clipboard turned a three-round-trip debug into a one-liner.

The bigger trap is on the diagnostic side: do not determine Accessibility by querying TCC.db. An ad-hoc signature's designated requirement is a bare hash:

designated => cdhash H"6836bec46e8c7d394cf1ba94421ff18a31674867"

so every rebuild is a new identity — but the old row survives with auth_value=2 and System Settings keeps drawing a switched-ON toggle for a binary nothing trusts. My doctor printed PASS Accessibility is granted while the agent was alerting on screen that it couldn't paste. Three sources agreeing with each other and disagreeing with reality.

The fix is the rule your mic-status file already follows for the same underlying reason: only the process can answer for the process. The agent writes its own AXIsProcessTrusted() result to a status file and the shell reads that.

Ad-hoc signing costs a re-grant on every rebuild

Follows from the same cdhash point, and it bites during development rather than in CI. Worth having the installer record the installed cdhash and, when it changes, tccutil reset Accessibility <bundle-id> — otherwise the user goes to grant the permission, finds it already granted, and goes looking elsewhere.

Goes away entirely with a Developer ID signature, since the requirement becomes the certificate. That's in progress on our side.

Confirming your entitlement is load-bearing

Packaging/entitlements.plist has com.apple.security.device.audio-input — correct, and worth stating why so nobody "cleans it up". Under the hardened runtime, 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
Policy disallows prompt for ...; access to kTCCServiceMicrophone denied

The app then never appears in the Microphone pane at all, because that pane lists only apps that have successfully requested, and the code sees an instant .denied. Nothing but the unified log names the cause. I lost an hour to exactly this.

One for the migration path

tccutil reset resolves the bundle id through Launch Services, so once you delete the .app you can no longer revoke its grants — it fails -10814 and the grants stay live while the uninstall reports success. Revoking Hammerspoon's Accessibility and Microphone is arguably the whole point of #2, so the order has to be quit → revoke → uninstall.


Happy to open a PR against this branch with the agent-side fixes plus the installer/doctor wiring and the Lua deletion, if that's useful — or leave it to you if you'd rather keep the tree yours. Either way the details are in #13/#14's commits and comments.

@drycode

drycode commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Ran this on hardware — Studio, real dictation, against the existing Python server. It works now, and the integration commits are on dy/swift-rewrite-integration (branched off this PR's head) if you want to cherry-pick rather than take them wholesale.

Four defects, none visible to the 48 tests. Sharing the evidence rather than just the diffs.

The hotkey misses almost every release — the Phase 0 spike, answered

Hotkey.swift reconstructs the chord from keycode + modifier flags, and applies that test to both edges. The flags describe the instant the event was generated, and releasing Ctrl+Alt+Space almost always lifts a modifier at or before the space bar — so the space key-UP arrives with the modifier bits already clear and isChord is false.

Measured with a bare session tap over 45 s of ordinary use:

DOWN events: 303      (auto-repeat while held)
UP events:     1
tap disabled:  0      (so it is not the timeout path)

One release out of dozens. In the agent that leaves a capture that never ends, and since beginCapture() guards on state == .idle, every later press is ignored — presenting as "the hotkey stopped working" rather than as a missed key-up.

Loosening the key-up test would mean tracking "a capture is open, end it on any space key-up", which re-implements something the OS already does. Switched to Carbon RegisterEventHotKey: pressed and released arrive as distinct events, the release is not conditional on modifier state, it consumes the chord so it does not also reach the focused app, and it needs no permission of its own — which answers the Phase 0 question directly: no Input Monitoring grant is needed. Accessibility is still required for the ⌘V, so it is one grant either way.

Recorder accumulates across captures

samples and peak are instance properties on a long-lived Recorder, and neither start() nor stop() clears them. Capture 2 transcribes 1+2, capture 3 transcribes 1+2+3, and the WAV grows without bound. Presents as the transcript repeating what you said last time. Reset at the top of start().

There is no presentation layer

private func alert(_ message: String) {
    log.info(message)
    NSWorkspace.shared.notificationCenter.post(name: .init("HarkAlert"), object: message)
}                                    // nothing anywhere observes "HarkAlert"

private func brief(_ message: String) { _ = message }

So the whole diagnostic surface — 401, 415, 400, 503, transport failure, "heard nothing", "paste withheld (focus moved)" — is discarded, and the menu bar title is the only feedback of any kind. Added an Overlay (non-activating floating panel, so it never pulls focus from what you are dictating into) and wired all three paths to it, keeping the menu bar item.

status.json reports a constant

writeHeartbeat passes a hardcoded hotkey: "registered" regardless of whether anything is bound — which is why the dead tap above looked healthy from the outside. Now reflects Hotkey.isRegistered.

Plus, from the previous comment

The client addressing gap turned out to be worse than a two-machine issue: my single-machine Studio binds 100.64.66.46, so 127.0.0.1:8911 refuses there too. The hardcoded loopback could not reach any deployment I have. Added ClientConfig (~/.config/hark/client.json) enforcing the transport policy your design already specifies — plain HTTP to loopback always, to a numeric IP only with an explicit allowPlaintext, to a hostname never. Absent config falls back to the current behaviour.

Also the 200 ms ⌘V hold, AXIsProcessTrusted re-checked at paste time, requestAccess waiting for its answer instead of discarding it, and a trailing space so consecutive dictations do not run together.


The thing I would take from all of this: every one of these passed both test suites. Yours (48) and mine (138) are green against code with all of them present, because they test what the code says and these are failures of what it reaches. The hardware spike your design asks for is worth more than the hotkey question it was scoped to.

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.

2 participants