From 3835b661106b8403578a4ae07090c591fd485140 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Fri, 31 Jul 2026 14:22:58 -0400 Subject: [PATCH 1/7] [hark] Design the native client that replaces Hammerspoon Spec for #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 --- .../specs/2026-07-31-native-client-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-native-client-design.md diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md new file mode 100644 index 0000000..aed99f5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -0,0 +1,196 @@ +# Native client design — replacing the Hammerspoon Lua client + +Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) +Date: 2026-07-31 + +## Why + +`install-client.sh` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +Hammerspoon has exactly one config file, so installing hark claims it. + +The permission story matters more than the tidiness one. Accessibility is granted to +Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a +grant that can observe every keystroke, and its config is a symlink into a git repo, so +`git pull` changes what that grant covers without re-prompting. + +What Hammerspoon buys in return, and what this design has to replace, is a **stable +signed TCC identity**. hark currently rides on an already-trusted app by swapping a text +file. Taking that away means hark owns the identity problem itself. + +## Decisions + +| Question | Decision | +|---|---| +| Signing | Design the bundle and TCC identity now, ship ad-hoc signed, flip to Developer ID later without structural change | +| App shape | `LSUIElement` bundle with a menu bar status item | +| Capture | Absorbed in-process; `rec` as a separate child binary goes away | +| Config | `~/.config/hark/client.json`; on a single-machine install the key is read from `~/.config/hark/key` rather than copied | +| Hotkey | Carbon `RegisterEventHotKey`, with `CGEventTap` documented as the fallback | +| Migration | Nothing on the user's machine is deleted | + +### Why RegisterEventHotKey rather than an event tap + +`RegisterEventHotKey` registers one chord with the window server and delivers press and +release. It does **not** require Accessibility. Accessibility is still required, but only +to synthesize the ⌘V. + +That split is the point. hark asks for permission to *type*, not permission to *watch you +type*. An agent built on `CGEventTap` would need the same all-or-nothing grant that made +the Hammerspoon arrangement uncomfortable in the first place, with a smaller attack +surface but not a smaller permission. + +It also makes two failures visible that are currently silent: + +- Accessibility missing: the hotkey still registers, so the agent hears the press and + fails at the paste. It can say "heard ⌃⌥Space, can't paste" instead of nothing. + `client/init.lua` cannot do this — without Accessibility, `hs.hotkey.bind` never fires + at all, which the README spends three paragraphs explaining. +- Chord already owned by another app: `RegisterEventHotKey` returns an error. + `hs.hotkey.bind` returns success and quietly never fires, which is why the README has + to warn about ⌘⌥Space in prose. + +**Risk to verify first:** that `kEventHotKeyReleased` is delivered reliably enough for a +hold-to-talk interaction. This is the first spike, before any other code. If it is not, +fall back to `CGEventTap` and accept the larger grant. + +## Measured constraint: rebuilds change the code signature + +Identical Swift sources produce a different binary and a different CDHash on every build. +Verified on 2026-07-31 by compiling `client/rec.swift` twice and comparing: + +``` +build 1: CDHash=5cb20c7342e72829dcb0035fcb19844deb1e6b20 +build 2: CDHash=b5aab283cab02415d5fbf9f07ff72259f76c4109 +``` + +`install-client.sh:404` recompiles unconditionally on every run and the script documents +itself as safe to re-run. That is harmless today, because TCC attributes microphone access +to Hammerspoon and `rec`'s own signature is irrelevant. Once the agent **is** the TCC +principal, every re-run invalidates its own Accessibility grant under an ad-hoc signature. + +So the installer must skip the build entirely when nothing that affects the binary has +changed. It hashes the **inputs** — every Swift source, `Package.swift`, `Info.plist`, and +`swiftc --version` — stores that hash beside the installed bundle, and rebuilds only on a +mismatch. + +The guard has to key on inputs rather than on comparing the built binary against the +installed one, precisely because of the measurement above: identical sources produce a +different binary every time, so an output comparison would always differ and the guard +would never fire. + +This is load-bearing, not an optimisation. Under a future Developer ID signature the +constraint disappears, because TCC keys on Team ID plus bundle ID rather than the hash. + +## Architecture + +One SPM package. `swift build` produces the binary; `install-client.sh` wraps it in +`~/Applications/Hark.app` (no `sudo`); `swift test` runs the unit tests. `Package.swift` +is not an Xcode project, so the "one build command, no project file to maintain" property +survives. + +Bundle ID `com.drycodeworks.hark-agent`, matching the existing launchd label convention. +`Info.plist` carries `LSUIElement` and `NSMicrophoneUsageDescription`. Login start uses +`SMAppService.mainApp`, which sets the floor at macOS 13. + +| File | Responsibility | +|---|---| +| `Config.swift` | Load `client.json`; resolve the key inline or from `~/.config/hark/key`; validate the URL | +| `Hotkey.swift` | `RegisterEventHotKey` wrapper; press/release callbacks; registration failure | +| `Recorder.swift` | AVAudioEngine, Float32 → 16 kHz mono s16, in-memory WAV, the zero-frames signal | +| `DictateClient.swift` | `URLSession` POST; decode `{"text":…}` / `{"detail":…}`; map status to typed errors | +| `Paster.swift` | `NSPasteboard` set with no restore, then `CGEvent` ⌘V, gated on `AXIsProcessTrusted()` | +| `StatusItem.swift` | Menu bar icon and state; Quit, Reload config, Open log, Run diagnostics | +| `Diagnostics.swift` | Writes the status file that `install-client.sh --doctor` reads | +| `Log.swift` | Append-only log; lengths, never transcript content | + +### The WAV never touches disk + +`/tmp/hark.wav` goes away, and with it the stale-file hazard that `init.lua:357` guards +against with an `os.remove` before every recording. `AVAudioFile` writes to a URL, so +`Recorder` hand-builds the 44-byte header around the PCM buffer instead. The wire format +is already pinned by `tests/test_audio.py` and unchanged: 16 kHz mono 16-bit PCM. + +### The mic-status file stays a file + +It is tempting to let `--doctor` run the agent binary with a `--probe` flag. That is wrong +for the reason `install-client.sh:183-190` already documents: a probe launched from the +terminal tests the **terminal's** TCC grant, not the agent's, and produces a confidently +wrong PASS. The agent writes the status; `--doctor` reads it. Only the path changes, to +`~/.config/hark/client-status.json`. + +## Data flow + +1. ⌃⌥Space down. Return early if already recording, or if no key resolves. +2. Status item to recording, overlay shown, `AVAudioEngine` tap starts. +3. ⌃⌥Space up. Tap stops, WAV bytes returned in memory. +4. Zero frames captured: report the microphone permission cause, log, stop. +5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. +6. `200` with empty text: transient "heard nothing", paste nothing. Not an error — the + energy gate tripped, or the transcript had no alphanumeric content. +7. `200` with text: set the pasteboard, synthesize ⌘V. Never Return. + +The clipboard is deliberately not restored, and auto-submit remains a hard non-goal. Both +carry over unchanged; see the comment above `hs.pasteboard.setContents` in +`client/init.lua` and the Non-goals section of the 2026-07-14 design spec. + +## Error handling + +The mapping in `init.lua:176-258` carries over as written. It is hard-won and is not being +redesigned: + +| Condition | Message names | +|---|---| +| Connection failure | The server URL, and that hark may not be running | +| 401 | The key mismatch, and the config path to fix | +| 415 | A client bug, not a microphone problem | +| 400 | Microphone permission, caught server-side | +| 503 | whisper-server down, `/tmp/hark-whisper.err` | +| Other | The status code and the server's `detail` | + +Messages render in a borderless transient overlay rather than Notification Center. A muted +notification is a silent failure, and silent failure is the thing this project repeatedly +designs against. + +## Testing + +Unit-testable with no permissions and no microphone: + +- config loading, key resolution (inline versus `~/.config/hark/key`), URL validation +- the WAV header builder, against `tests/fixtures/hello.wav` and `silence.wav` +- the status-to-error mapping, via a stubbed `URLProtocol` + +CI runs `swift build` and `swift test` on the macOS runner added in #1. + +Not testable in CI, stated as plainly as the README already states its own limits: TCC +grants, real hotkey delivery, real paste into a real window. Those remain exercised by use. + +## Migration + +Nothing on the user's machine is deleted. + +`client/init.lua` is replaced by a short stub that binds no hotkey and reports that hark +now runs as a native agent. An existing `~/.hammerspoon/init.lua` symlink keeps resolving +and simply does nothing, so the two clients never fight over ⌃⌥Space. The change lands in +this repo rather than in anyone's home directory. + +`install-client.sh` reports leftover Hammerspoon-era artifacts (`rec`, `hark-config.lua`, +`.hark-mic-status`, `.hark-mic-probe.wav`) and prints how to revoke Hammerspoon's grants +and remove the cask. It does not do either, because a script cannot revoke a TCC grant. + +## Non-goals + +- The Fn/🌐 key. It needs an event tap plus Input Monitoring, which is approach B. +- Pre-warming the audio engine. In-process capture makes it possible for the first time, + but the README's privacy trade-off is deliberate: holding the microphone open lights the + menu-bar indicator continuously, and that should stay an explicit choice. +- Developer ID signing and notarised releases. Designed for, deferred. +- Streaming transcription and auto-submit, per the 2026-07-14 spec's non-goals. + +## Open questions + +- Whether `kEventHotKeyReleased` is reliable enough for hold-to-talk. Spike first. +- Whether the ad-hoc rebuild actually invalidates the Accessibility grant in practice on + current macOS, or merely re-prompts. The rebuild guard is correct either way; this + determines how loudly the installer has to warn. +- Whether `~/Applications/Hark.app` or `/Applications` is the better home once signing + arrives and the app might be distributed as a release artifact. From f28ae60e10578334ea9ffd07f2948b1644118acd Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Fri, 31 Jul 2026 14:50:45 -0400 Subject: [PATCH 2/7] [hark] Revise the native client design after a six-reviewer panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../specs/2026-07-31-native-client-design.md | 454 +++++++++++++----- 1 file changed, 321 insertions(+), 133 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index aed99f5..45b5e8e 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -2,195 +2,383 @@ Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) Date: 2026-07-31 +Revision: 2, after a six-reviewer panel returned 6/6 REVISE on revision 1 ## Why -`install-client.sh` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +`install-client.sh:573` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. Hammerspoon has exactly one config file, so installing hark claims it. The permission story matters more than the tidiness one. Accessibility is granted to -Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a -grant that can observe every keystroke, and its config is a symlink into a git repo, so -`git pull` changes what that grant covers without re-prompting. +Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a grant +that can observe every keystroke, and its config is a symlink into a git repo, so `git +pull` changes what that grant covers without re-prompting. -What Hammerspoon buys in return, and what this design has to replace, is a **stable -signed TCC identity**. hark currently rides on an already-trusted app by swapping a text -file. Taking that away means hark owns the identity problem itself. +What Hammerspoon buys in return is a **stable signed TCC identity**. hark rides on an +already-trusted app by swapping a text file. Taking that away means hark owns the identity +problem itself, which is why this design assumes a Developer ID rather than deferring it. + +## What revision 1 got wrong + +Recorded because the errors are instructive, not to pad the document. + +**The hotkey rationale was wrong.** Revision 1 argued for Carbon `RegisterEventHotKey` +because it "asks for permission to type, not permission to watch you type." Synthesising +⌘V requires Accessibility, and Accessibility is a single atomic grant — holding it also +permits creating a `CGEventTap`. The claimed saving does not exist as stated. + +**The collision claim was wrong.** Revision 1 said `RegisterEventHotKey` errors when +another app owns the chord. The installed SDK says the opposite: + +> "The same hot key can, however, be registered by multiple applications... In Mac OS X +> 10.5 and later, you can request exclusive registration for your process only by passing +> `kEventHotKeyExclusive` for the `inOptions` parameter." +> — `CarbonEvents.h`, `RegisterEventHotKey` discussion + +**One claim was fabricated.** Revision 1 said `tests/test_audio.py` pins the wire format. +It does not: `audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is +accepted today. Fixing that is now in scope (see Testing). + +**The mechanism question is therefore unresolved**, and revision 2 does not pre-decide it. +See Phase 0. ## Decisions | Question | Decision | |---|---| -| Signing | Design the bundle and TCC identity now, ship ad-hoc signed, flip to Developer ID later without structural change | -| App shape | `LSUIElement` bundle with a menu bar status item | +| Signing | **Developer ID assumed.** CI builds, signs and notarises; releases ship an artifact | +| Install | `install-client.sh` downloads and installs the artifact. It no longer compiles | +| App shape | `LSUIElement` bundle, menu bar status item, at `~/Applications/Hark.app` (no sudo) | | Capture | Absorbed in-process; `rec` as a separate child binary goes away | -| Config | `~/.config/hark/client.json`; on a single-machine install the key is read from `~/.config/hark/key` rather than copied | -| Hotkey | Carbon `RegisterEventHotKey`, with `CGEventTap` documented as the fallback | -| Migration | Nothing on the user's machine is deleted | +| Config | `~/.config/hark/client.json` holds the **server URL only**; the key always lives in `~/.config/hark/key` mode 600 | +| Hotkey | **Undecided — Phase 0 spike decides.** Carbon and `CGEventTap` are both live | +| Migration | Quit Hammerspoon and offer to revoke its grants. Delete nothing from `$HOME` | -### Why RegisterEventHotKey rather than an event tap +### Developer ID changes the shape of the problem -`RegisterEventHotKey` registers one chord with the window server and delivers press and -release. It does **not** require Accessibility. Accessibility is still required, but only -to synthesize the ⌘V. +Revision 1 measured that identical Swift sources produce a different CDHash on every +build, and built an installer-side input hash to avoid re-signing on every re-run. -That split is the point. hark asks for permission to *type*, not permission to *watch you -type*. An agent built on `CGEventTap` would need the same all-or-nothing grant that made -the Hammerspoon arrangement uncomfortable in the first place, with a smaller attack -surface but not a smaller permission. +That whole mechanism is **deleted**. With CI producing the binary, the installer never +compiles, so re-running it cannot mint a new CDHash. With Developer ID, TCC keys on Team ID +plus bundle ID rather than the hash, so grants survive updates. The measurement stands as +the justification for requiring Developer ID; it is no longer a constraint to engineer +around. -It also makes two failures visible that are currently silent: +Two consequences worth stating plainly: -- Accessibility missing: the hotkey still registers, so the agent hears the press and - fails at the paste. It can say "heard ⌃⌥Space, can't paste" instead of nothing. - `client/init.lua` cannot do this — without Accessibility, `hs.hotkey.bind` never fires - at all, which the README spends three paragraphs explaining. -- Chord already owned by another app: `RegisterEventHotKey` returns an error. - `hs.hotkey.bind` returns success and quietly never fires, which is why the README has - to warn about ⌘⌥Space in prose. +- Notarisation is not optional. A downloaded `.app` carries `com.apple.quarantine`, and an + un-notarised quarantined app is blocked outright on current macOS. Telling users to + `xattr -d` is the kind of friction this project avoids everywhere else. +- The client install no longer needs the Xcode command line tools. That removes a + multi-gigabyte dependency from the machine you dictate *from*, which is the point of the + wider dependency work (#3, #4, #5). -**Risk to verify first:** that `kEventHotKeyReleased` is delivered reliably enough for a -hold-to-talk interaction. This is the first spike, before any other code. If it is not, -fall back to `CGEventTap` and accept the larger grant. +`~/Applications` keeps the install sudo-free. Under Developer ID, TCC is keyed on the +signing identity rather than the path, so location is a packaging choice, not a permission +one. -## Measured constraint: rebuilds change the code signature +## Phase 0: the hotkey spike (gates everything else) -Identical Swift sources produce a different binary and a different CDHash on every build. -Verified on 2026-07-31 by compiling `client/rec.swift` twice and comparing: +Two questions decide the mechanism. Both are unresolved and neither should be answered by +argument. -``` -build 1: CDHash=5cb20c7342e72829dcb0035fcb19844deb1e6b20 -build 2: CDHash=b5aab283cab02415d5fbf9f07ff72259f76c4109 -``` - -`install-client.sh:404` recompiles unconditionally on every run and the script documents -itself as safe to re-run. That is harmless today, because TCC attributes microphone access -to Hammerspoon and `rec`'s own signature is irrelevant. Once the agent **is** the TCC -principal, every re-run invalidates its own Accessibility grant under an ad-hoc signature. - -So the installer must skip the build entirely when nothing that affects the binary has -changed. It hashes the **inputs** — every Swift source, `Package.swift`, `Info.plist`, and -`swiftc --version` — stores that hash beside the installed bundle, and rebuilds only on a -mismatch. +1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** + `README.md:245-247` states from this project's own experience that an `hs.eventtap` + watching `flagsChanged` needs Input Monitoring. If that holds for a keyDown/keyUp tap, + Carbon is one grant and the tap is two, and the permission argument survives revision + 1's bad reasoning by a different route. If it does not hold, the tap is free. +2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** Specifically under load, + and when the user rolls onto other modifiers mid-hold. -The guard has to key on inputs rather than on comparing the built binary against the -installed one, precisely because of the measurement above: identical sources produce a -different binary every time, so an output comparison would always differ and the guard -would never fire. +Build both, measure both, then write the mechanism into this document. Revision 1's +"`CGEventTap` documented as the fallback" is removed: pre-designing an unused branch is +speculative work, and if Carbon loses the spike the affected sections get rewritten +against what actually won. -This is load-bearing, not an optimisation. Under a future Developer ID signature the -constraint disappears, because TCC keys on Team ID plus bundle ID rather than the hash. +Whichever wins, the agent must detect and report its own failure to register rather than +going silent, because silence is the failure mode this project keeps designing against. +Carbon additionally requires `kEventHotKeyExclusive` if collision detection is wanted. ## Architecture -One SPM package. `swift build` produces the binary; `install-client.sh` wraps it in -`~/Applications/Hark.app` (no `sudo`); `swift test` runs the unit tests. `Package.swift` -is not an Xcode project, so the "one build command, no project file to maintain" property -survives. +One SPM package. CI runs `swift build` and `swift test`; the release job produces the +signed, notarised `Hark.app`. -Bundle ID `com.drycodeworks.hark-agent`, matching the existing launchd label convention. -`Info.plist` carries `LSUIElement` and `NSMicrophoneUsageDescription`. Login start uses -`SMAppService.mainApp`, which sets the floor at macOS 13. +Bundle ID `com.drycodeworks.hark-agent`. This is a **new** identifier that follows the +existing launchd label style; it is not an existing label. `Info.plist` carries +`LSUIElement`, `NSMicrophoneUsageDescription`, and the ATS declaration below. Login start +uses `SMAppService.mainApp`, setting the floor at macOS 13. | File | Responsibility | |---|---| -| `Config.swift` | Load `client.json`; resolve the key inline or from `~/.config/hark/key`; validate the URL | -| `Hotkey.swift` | `RegisterEventHotKey` wrapper; press/release callbacks; registration failure | -| `Recorder.swift` | AVAudioEngine, Float32 → 16 kHz mono s16, in-memory WAV, the zero-frames signal | -| `DictateClient.swift` | `URLSession` POST; decode `{"text":…}` / `{"detail":…}`; map status to typed errors | -| `Paster.swift` | `NSPasteboard` set with no restore, then `CGEvent` ⌘V, gated on `AXIsProcessTrusted()` | -| `StatusItem.swift` | Menu bar icon and state; Quit, Reload config, Open log, Run diagnostics | -| `Diagnostics.swift` | Writes the status file that `install-client.sh --doctor` reads | -| `Log.swift` | Append-only log; lengths, never transcript content | - -### The WAV never touches disk - -`/tmp/hark.wav` goes away, and with it the stale-file hazard that `init.lua:357` guards -against with an `os.remove` before every recording. `AVAudioFile` writes to a URL, so -`Recorder` hand-builds the 44-byte header around the PCM buffer instead. The wire format -is already pinned by `tests/test_audio.py` and unchanged: 16 kHz mono 16-bit PCM. - -### The mic-status file stays a file - -It is tempting to let `--doctor` run the agent binary with a `--probe` flag. That is wrong -for the reason `install-client.sh:183-190` already documents: a probe launched from the -terminal tests the **terminal's** TCC grant, not the agent's, and produces a confidently -wrong PASS. The agent writes the status; `--doctor` reads it. Only the path changes, to -`~/.config/hark/client-status.json`. +| `Config.swift` | Load `client.json`; read and trim the key file; enforce the transport policy | +| `Hotkey.swift` | Register the chord (mechanism per Phase 0); press/release; registration failure | +| `Recorder.swift` | AVAudioEngine capture, format conversion, in-memory WAV, capture-state reporting | +| `DictateClient.swift` | POST, decode, map status to typed errors, sanitise the response | +| `AgentController.swift` | The state machine, permissions, paste, status file, logging, menu bar | + +Revision 1 had eight files. `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one +caller and no independent policy, so they collapse into `AgentController`. The three +remaining seams — config, capture, transport — are the ones with real test surface. + +The status item shows state and offers Quit only. "Reload config" and "Run diagnostics" +are removed; `install-client.sh --doctor` is already the diagnostic interface and a second +one would drift from it. + +### Capture, permissions, and a possible live bug + +Revision 1 said "zero frames captured" indicates missing microphone permission. That is how +`rec.swift:128` works today, and a reviewer asserts it is wrong: that a TCC-denied +`AVAudioEngine` delivers the expected buffers filled with **silence** rather than no +buffers, so the frame count never reaches zero. + +If that is correct, it is a live defect in shipped code, not merely a flaw in this design: +`rec.swift`'s exit-3 path would never fire, `init.lua:486-496`'s probe would always report +`ok`, and `--doctor`'s microphone check would be a confident false PASS. **Phase 0 must +verify this against a genuinely denied grant**, and if confirmed it gets its own issue +against the current client. + +Either way the design does not rely on frame counts for permission: + +- Query `AVCaptureDevice.authorizationStatus(for: .audio)` explicitly, and call + `requestAccess` at first launch to raise the consent dialog. The Microphone pane has no + "+" button and only lists apps that have already asked, so the request must happen. +- Probe at **launch**, not at first hotkey press, so a clean install has an + agent-authored status before anyone runs `--doctor`. +- Treat an all-silent buffer as its own reportable condition, distinct from a denied grant. + +Accessibility uses `AXIsProcessTrustedWithOptions` with the prompt option at first launch. +`AXIsProcessTrusted` alone only checks and never prompts, which leaves a fresh install with +no path to the grant. + +### The WAV is built in memory + +`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:357` guards against. +`AVAudioFile` writes to a URL, so `Recorder` builds the 44-byte header itself. + +The RIFF and data chunk sizes depend on total length, which is unknown until release, so +the header is written last or back-patched before the POST. Getting this wrong produces a +server 400, which is exactly why the 400 mapping below had to change. + +### Capture must be serialised + +The tap callback runs on an audio thread while key-up runs on the main queue. +`rec.swift:112` mutates `framesWritten` from the callback while `rec.swift:128` reads it +from `stop()` — an existing unsynchronised access that becomes more consequential in-process. + +Define an explicit boundary: an actor or serial queue owning the buffer, with stop waiting +for the tap to drain before the WAV is finalised. Stop must be idempotent. + +## State machine + +Revision 1 said only "return early if already recording", which leaves both a stuck-state +and an ordering hole. + +``` +idle ──press──► starting ──first buffer──► recording ──release──► stopping + ▲ │ + └──────────── paste / error ◄──── uploading ◄──── drain complete ────┘ +``` + +Rules: + +- A press outside `idle` is ignored. +- `stopping` exists so a press immediately after release cannot start a second capture + while the first is still draining. Revision 1 would have either overlapped captures or + dropped the second utterance. +- **One in-flight request.** `init.lua:319` clears its task before the async POST, so today + two quick utterances can paste in reverse order. Serialise: no new capture while a + request is pending, and drop a response whose sequence number is not current. +- **Maximum capture duration.** A missed release must not leave the engine running. In + memory this is unbounded growth rather than a growing file, so the cap stops capture and + returns to `idle`. +- Cleanup on sleep, app deactivation, and termination. ## Data flow -1. ⌃⌥Space down. Return early if already recording, or if no key resolves. -2. Status item to recording, overlay shown, `AVAudioEngine` tap starts. -3. ⌃⌥Space up. Tap stops, WAV bytes returned in memory. -4. Zero frames captured: report the microphone permission cause, log, stop. +1. Press. Ignore unless `idle` and the key resolves. +2. Capture starts; status item and overlay show recording. +3. Release. Tap drains, WAV finalised in memory. +4. No frames, or all-silent buffers: report the capture-side cause and stop. Do not POST. 5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. -6. `200` with empty text: transient "heard nothing", paste nothing. Not an error — the - energy gate tripped, or the transcript had no alphanumeric content. -7. `200` with text: set the pasteboard, synthesize ⌘V. Never Return. +6. `200` with empty text: transient "heard nothing", paste nothing. Not an error. +7. `200` with text: **sanitise, then** set the pasteboard, verify the write, then ⌘V. -The clipboard is deliberately not restored, and auto-submit remains a hard non-goal. Both -carry over unchanged; see the comment above `hs.pasteboard.setContents` in -`client/init.lua` and the Non-goals section of the 2026-07-14 design spec. +Never Return. The clipboard is deliberately not restored. -## Error handling +### The client sanitises the response + +The server sanitises at `sanitize.py:23`, and revision 1 treated that as sufficient. It is +not. A compromised server, or anyone on the network path of a plaintext two-machine setup, +returns JSON the server's sanitiser never touched. "Never Return" protects nothing when the +payload itself carries `\r` or `\n` into a terminal without bracketed paste. + +`DictateClient` applies the same rule as `sanitize.py` — C0/C1 controls and Unicode line +separators replaced with a space, whitespace collapsed — plus a length cap, before the text +reaches the pasteboard. The server keeps its sanitiser; this is defence in depth, and the +Swift and Python implementations get the same test cases. + +If the pasteboard write fails, report it and **do not** synthesise ⌘V — otherwise the +keystroke pastes whatever was on the clipboard before, potentially into a terminal. + +## Transport policy + +The two-machine default is plaintext HTTP (`install-client.sh:503`) over what +`README.md:214` calls "a LAN address you trust". On that path an attacker reads the audio, +the transcript and `X-Hark-Key`, and can forge the response that the client then types. + +`Config` enforces: + +- plain HTTP permitted **only** for numeric loopback, +- HTTPS required for every other host, +- userinfo (`user@host`) rejected — `install-client.sh:230` already treats it as a defect, +- redirects not followed, +- an ephemeral `URLSession` so credentials and responses are not cached to disk. + +`Info.plist` declares the matching ATS exception. Revision 1 omitted ATS entirely, which +would have produced a client that captures audio and then fails every POST. -The mapping in `init.lua:176-258` carries over as written. It is hard-won and is not being -redesigned: +Two-machine users on Tailscale who want to keep HTTP need an explicit, documented opt-in; +it is not the default. + +## Configuration + +`client.json` holds the server URL. The key always lives at `~/.config/hark/key`, mode +600, written by whichever installer obtained it. Revision 1's inline-versus-file branch is +deleted: one path, one permission contract, nothing to drift. + +- `install-client.sh` creates `~/.config/hark` mode 700 before writing anything. On a + two-machine *client* the directory does not otherwise exist, because the server-side + `mkdir` ran on the other machine. +- The key file is read and **trimmed**: `config.py:129` writes `key + "\n"` and + `install-client.sh:427` strips it today. Sending the raw bytes 401s every request. + Reject embedded whitespace rather than silently trimming it. +- `client.json` is serialised by a real JSON encoder and replaced atomically. The current + heredoc at `install-client.sh:541` validates quotes only in the key (`:468`), so a + hand-entered URL containing a quote produces an unparseable file. +- `--doctor` must stop passing the key in argv (`install-client.sh:291`), where any local + account can read it from the process table. Use a config-file-fed curl or the agent. + +**Known residual risk.** Any same-UID process can rewrite `client.json` and point the +trusted agent at an attacker's server; mode 600 does not prevent this. Under Developer ID +the mitigation is available — pin the approved origin in a Keychain item protected by code +identity, and require in-app confirmation to change it. Recorded as a follow-up rather than +built now, because it needs its own design. + +## Error handling | Condition | Message names | |---|---| -| Connection failure | The server URL, and that hark may not be running | -| 401 | The key mismatch, and the config path to fix | +| Connection / transport failure | The URL, and that hark may not be running | +| 401 | Key mismatch, and the file to fix | | 415 | A client bug, not a microphone problem | -| 400 | Microphone permission, caught server-side | +| **400** | **Malformed or unsupported audio — a client format bug.** Show the server's `detail` | | 503 | whisper-server down, `/tmp/hark-whisper.err` | -| Other | The status code and the server's `detail` | +| Other | Status code and the server's `detail` | +| No frames / all silent | Capture-side: device selection and microphone permission | -Messages render in a borderless transient overlay rather than Notification Center. A muted -notification is a silent failure, and silent failure is the thing this project repeatedly -designs against. +Revision 1 carried `init.lua`'s "400 means microphone permission" across unchanged. That is +now wrong twice over: capture failures never reach the server, and `app.py:98-103` returns +400 for **every** `InvalidAudioError` — including a malformed header this client now builds +by hand. Sending users to Microphone settings for a header bug would be actively +misleading. -## Testing +Messages render in a borderless transient overlay rather than Notification Center, since a +muted notification is a silent failure. -Unit-testable with no permissions and no microphone: +## Status file -- config loading, key resolution (inline versus `~/.config/hark/key`), URL validation -- the WAV header builder, against `tests/fixtures/hello.wav` and `silence.wav` -- the status-to-error mapping, via a stubbed `URLProtocol` +The agent writes `~/.config/hark/client-status.json`; `--doctor` reads it. `--doctor` must +not run the agent binary itself: a probe launched from the terminal tests the *terminal's* +TCC grant and produces a confident false PASS, which is the trap `install-client.sh:183-190` +already documents. -CI runs `swift build` and `swift test` on the macOS runner added in #1. +Revision 1 said "only the path changes". Two things must also change: -Not testable in CI, stated as plainly as the README already states its own limits: TCC -grants, real hotkey delivery, real paste into a real window. Those remain exercised by use. +- **Atomic write** — temp file plus rename. `--doctor` reading mid-write would otherwise + see partial JSON. +- **Generation stamp** — the file records the agent launch it came from, and `--doctor` + rejects a status older than the running agent. Today `install-client.sh:200-204` accepts + any existing `ok` first line, so a stale file survives a revoked grant and reports PASS. -## Migration +## Testing + +Unit-testable with no permissions and no microphone: config loading, key trimming and +rejection, transport-policy enforcement, the WAV header builder (including back-patched +sizes), the status-to-error mapping via a stubbed `URLProtocol`, and **hostile response +sanitisation** — control characters, newlines, Unicode line separators, over-length text. -Nothing on the user's machine is deleted. +Server-side, fix the coverage revision 1 wrongly claimed existed: `audio.py` validates +sample width only, so add explicit assertions and server rejection for channel count and +sample rate alongside bit depth. -`client/init.lua` is replaced by a short stub that binds no hotkey and reports that hark -now runs as a native agent. An existing `~/.hammerspoon/init.lua` symlink keeps resolving -and simply does nothing, so the two clients never fight over ⌃⌥Space. The change lands in -this repo rather than in anyone's home directory. +CI runs `swift build`, `swift test`, and the existing pytest suite. + +Not testable in CI, stated as plainly as the README states its own limits: TCC grants, real +hotkey delivery, real paste into a real window. + +## Migration -`install-client.sh` reports leftover Hammerspoon-era artifacts (`rec`, `hark-config.lua`, -`.hark-mic-status`, `.hark-mic-probe.wav`) and prints how to revoke Hammerspoon's grants -and remove the cask. It does not do either, because a script cannot revoke a TCC grant. +**Nothing is deleted from the user's home directory.** But "report and move on" is not +enough, for two reasons the panel found: + +- A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file + changes nothing until it reloads, so both clients bind ⌃⌥Space and fight. + `install-client.sh:586` already documents that Hammerspoon does not auto-reload. +- Leaving Hammerspoon's Accessibility and Microphone grants in place preserves exactly the + privilege escape that motivates this issue. Any same-UID process can later rewrite the + symlink target and reload it. + +So `install-client.sh`: + +1. quits Hammerspoon and confirms it no longer owns the chord before starting the agent, +2. offers `tccutil reset Accessibility org.hammerspoon.Hammerspoon` and the Microphone + equivalent, and prints how to remove the cask, +3. reports leftover artifacts without deleting them. + +`client/init.lua` becomes a **silent** no-op stub. Revision 1 had it show an alert, which +would fire from a still-running Hammerspoon that the installer is already handling. + +## Blast radius + +Revision 1 under-specified this and the panel enumerated it. All of the following are in +scope for the implementation, not follow-ups: + +- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build + (`:397-408`), the launch/relaunch block (`:576-589`), and every `--doctor` check + (`:86-110`, `:127`, `:173`, `:192`, `:657`, `:720`). Ordering matters: removing + `client/rec.swift` while `:404` still compiles it bricks the installer mid-run. +- **`README.md`** — architecture (`:13-14`), install (`:118-135`), `--doctor` (`:155-166`), + hotkey editing (`:230-247`), two-machine setup, and the repo layout (`:365-367`). +- **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and + `client/hark-config.example.lua`, replaced by a `client.json` example. +- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`. Three + of its statements stop being true: + - `:5` — "(architecture unchanged)", a parenthetical that this design invalidates. + - `:212-213` — "Replacing Hammerspoon with a native Swift menubar app" listed as a + non-goal, which is now the goal. Its stated reason ("it would be the right answer for a + *product*; this is not one") deserves an explicit answer rather than silent reversal. + - `:210` — "CI, release automation, versioning, changelogs" listed as a non-goal. #1 + already added CI, and this design adds signed release automation. Stale independently + of the native client. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, + pending implementation plan". ## Non-goals -- The Fn/🌐 key. It needs an event tap plus Input Monitoring, which is approach B. -- Pre-warming the audio engine. In-process capture makes it possible for the first time, - but the README's privacy trade-off is deliberate: holding the microphone open lights the - menu-bar indicator continuously, and that should stay an explicit choice. -- Developer ID signing and notarised releases. Designed for, deferred. -- Streaming transcription and auto-submit, per the 2026-07-14 spec's non-goals. +- The Fn/🌐 key. Revisit only if Phase 0 selects a tap. +- Pre-warming the audio engine. In-process capture makes it possible; the README's privacy + trade-off is deliberate and stands. +- Streaming transcription and auto-submit, per the 2026-07-14 spec. +- Keychain-pinned server origin. Recorded above as a follow-up. ## Open questions -- Whether `kEventHotKeyReleased` is reliable enough for hold-to-talk. Spike first. -- Whether the ad-hoc rebuild actually invalidates the Accessibility grant in practice on - current macOS, or merely re-prompts. The rebuild guard is correct either way; this - determines how loudly the installer has to warn. -- Whether `~/Applications/Hark.app` or `/Applications` is the better home once signing - arrives and the app might be distributed as a release artifact. +- Phase 0's two answers, which decide the hotkey mechanism. +- Whether TCC-denied capture really yields silent buffers rather than no frames. If yes, + file a separate issue against the current client. +- Whether the clipboard should self-clear after a timeout when `changeCount` is unchanged. + Any local process can poll `NSPasteboard` and harvest every transcript without holding + any TCC grant. Revision 1 documented non-restoration purely as a usability choice; it is + also a privacy exposure and the README should say so either way. From c763efa105504af0243be76e4b09aff692dfcb00 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Fri, 31 Jul 2026 15:04:22 -0400 Subject: [PATCH 3/7] [hark] Specify the native client design in numbers, not adjectives 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 --- .../specs/2026-07-31-native-client-design.md | 579 ++++++++++-------- 1 file changed, 331 insertions(+), 248 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index 45b5e8e..c476ad6 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -2,269 +2,304 @@ Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) Date: 2026-07-31 -Revision: 2, after a six-reviewer panel returned 6/6 REVISE on revision 1 +Revision: 3. Round 1 returned 6/6 REVISE; round 2 returned 5 REVISE / 1 APPROVED. ## Why `install-client.sh:573` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. Hammerspoon has exactly one config file, so installing hark claims it. -The permission story matters more than the tidiness one. Accessibility is granted to -Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a grant -that can observe every keystroke, and its config is a symlink into a git repo, so `git -pull` changes what that grant covers without re-prompting. +The permission story matters more. Accessibility is granted to Hammerspoon, not to hark: +a general-purpose scriptable Lua runtime holds a grant that can observe every keystroke, +and its config is a symlink into a git repo, so `git pull` changes what that grant covers +without re-prompting. -What Hammerspoon buys in return is a **stable signed TCC identity**. hark rides on an -already-trusted app by swapping a text file. Taking that away means hark owns the identity -problem itself, which is why this design assumes a Developer ID rather than deferring it. +What Hammerspoon buys in return is a **stable signed TCC identity**. Taking that away means +hark owns the identity problem itself, which is why this design assumes a Developer ID. -## What revision 1 got wrong +## What earlier revisions got wrong -Recorded because the errors are instructive, not to pad the document. +**Revision 1's hotkey rationale.** It argued for Carbon `RegisterEventHotKey` because it +"asks for permission to type, not permission to watch you type." Synthesising ⌘V requires +Accessibility, and Accessibility is a single atomic grant that also permits creating a +`CGEventTap`. The claimed saving does not exist as stated. -**The hotkey rationale was wrong.** Revision 1 argued for Carbon `RegisterEventHotKey` -because it "asks for permission to type, not permission to watch you type." Synthesising -⌘V requires Accessibility, and Accessibility is a single atomic grant — holding it also -permits creating a `CGEventTap`. The claimed saving does not exist as stated. - -**The collision claim was wrong.** Revision 1 said `RegisterEventHotKey` errors when -another app owns the chord. The installed SDK says the opposite: +**Revision 1's collision claim.** It said `RegisterEventHotKey` errors when another app +owns the chord. The installed SDK says the opposite: > "The same hot key can, however, be registered by multiple applications... In Mac OS X > 10.5 and later, you can request exclusive registration for your process only by passing -> `kEventHotKeyExclusive` for the `inOptions` parameter." +> `kEventHotKeyExclusive`." > — `CarbonEvents.h`, `RegisterEventHotKey` discussion -**One claim was fabricated.** Revision 1 said `tests/test_audio.py` pins the wire format. -It does not: `audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is -accepted today. Fixing that is now in scope (see Testing). +**Revision 1 fabricated a test claim.** It said `tests/test_audio.py` pins the wire format. +`audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. -**The mechanism question is therefore unresolved**, and revision 2 does not pre-decide it. -See Phase 0. +**Revision 2 contradicted itself twice.** It promised a Tailscale HTTP opt-in while +prohibiting non-loopback HTTP, with no mechanism for the opt-in; and it called an +all-silent buffer "distinct from a denied grant" while the error table filed both under +microphone permission. Both are resolved below. ## Decisions | Question | Decision | |---|---| -| Signing | **Developer ID assumed.** CI builds, signs and notarises; releases ship an artifact | -| Install | `install-client.sh` downloads and installs the artifact. It no longer compiles | -| App shape | `LSUIElement` bundle, menu bar status item, at `~/Applications/Hark.app` (no sudo) | -| Capture | Absorbed in-process; `rec` as a separate child binary goes away | -| Config | `~/.config/hark/client.json` holds the **server URL only**; the key always lives in `~/.config/hark/key` mode 600 | -| Hotkey | **Undecided — Phase 0 spike decides.** Carbon and `CGEventTap` are both live | -| Migration | Quit Hammerspoon and offer to revoke its grants. Delete nothing from `$HOME` | - -### Developer ID changes the shape of the problem - -Revision 1 measured that identical Swift sources produce a different CDHash on every -build, and built an installer-side input hash to avoid re-signing on every re-run. - -That whole mechanism is **deleted**. With CI producing the binary, the installer never -compiles, so re-running it cannot mint a new CDHash. With Developer ID, TCC keys on Team ID -plus bundle ID rather than the hash, so grants survive updates. The measurement stands as -the justification for requiring Developer ID; it is no longer a constraint to engineer -around. - -Two consequences worth stating plainly: - -- Notarisation is not optional. A downloaded `.app` carries `com.apple.quarantine`, and an - un-notarised quarantined app is blocked outright on current macOS. Telling users to - `xattr -d` is the kind of friction this project avoids everywhere else. -- The client install no longer needs the Xcode command line tools. That removes a - multi-gigabyte dependency from the machine you dictate *from*, which is the point of the - wider dependency work (#3, #4, #5). - -`~/Applications` keeps the install sudo-free. Under Developer ID, TCC is keyed on the -signing identity rather than the path, so location is a packaging choice, not a permission -one. +| Signing | Developer ID, hardened runtime, notarised and stapled. CI produces the artifact | +| Install | `install-client.sh` downloads and verifies a release artifact. It never compiles | +| App shape | `LSUIElement` bundle, menu bar status item, `~/Applications/Hark.app` (no sudo) | +| Capture | Absorbed in-process; the separate `rec` binary goes away | +| Config | `client.json` holds the server URL and the insecure-transport allowlist; the key always lives in `~/.config/hark/key` mode 600 | +| Hotkey | **Undecided.** Phase 0 decides, against the criteria below | +| Migration | Quit Hammerspoon, offer to revoke all three grants, restore or remove the symlink. Delete nothing else | + +### Why Developer ID, and what it does not buy + +Identical Swift sources produce a different CDHash on every build (measured). Under an +ad-hoc signature that would invalidate the agent's own Accessibility grant on every +rebuild. Revision 1 engineered an installer-side input hash around this; that mechanism is +**deleted**, because CI now produces the binary and the installer never compiles. + +Two corrections to how revision 2 stated the benefit: + +- TCC binds to the app's **designated requirement**, not simply "Team ID plus bundle ID". + CI must therefore keep the designated requirement stable across releases and across + certificate rotation, and Phase 0 must be validated against the **final signed bundle**, + not an ad-hoc spike binary. +- Notarisation requires the hardened runtime, and microphone capture under the hardened + runtime requires the audio-input entitlement. `NSMicrophoneUsageDescription` alone is not + sufficient. The entitlements file is part of the deliverable and CI verifies it. + +The client install no longer needs the Xcode command line tools, which removes a +multi-gigabyte dependency from the machine you dictate *from* — the point of #3, #4 and #5. + +`~/Applications` keeps the install sudo-free; under Developer ID the path is a packaging +choice, not a permission one. ## Phase 0: the hotkey spike (gates everything else) -Two questions decide the mechanism. Both are unresolved and neither should be answered by -argument. +Two questions decide the mechanism. Neither should be answered by argument. 1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** `README.md:245-247` states from this project's own experience that an `hs.eventtap` - watching `flagsChanged` needs Input Monitoring. If that holds for a keyDown/keyUp tap, - Carbon is one grant and the tap is two, and the permission argument survives revision - 1's bad reasoning by a different route. If it does not hold, the tap is free. -2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** Specifically under load, - and when the user rolls onto other modifiers mid-hold. + watching `flagsChanged` needs it. If that holds for keyDown/keyUp, Carbon is one grant + and the tap is two, and the permission argument survives revision 1's bad reasoning by a + different route. If not, the tap is free. +2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** + +**Pass/fail criteria**, so the spike produces a decision rather than an impression: + +- Test on the oldest supported macOS (13) and the newest available. +- 200 press/release cycles per mechanism; **zero** missed releases required. A missed + release strands the agent in `recording`, which is why the bar is zero rather than low. +- Include modifier rolls (press ⌃⌥Space, add ⇧ mid-hold, release), sustained CPU load, and + a display-sleep/wake cycle mid-hold. +- Register the chord while a second app holds it, with and without + `kEventHotKeyExclusive`, and record whether registration reports the collision. +- Run against the **signed, notarised bundle**, not a bare binary. -Build both, measure both, then write the mechanism into this document. Revision 1's -"`CGEventTap` documented as the fallback" is removed: pre-designing an unused branch is -speculative work, and if Carbon loses the spike the affected sections get rewritten -against what actually won. +If Carbon wins, `kEventHotKeyExclusive` is mandatory — migration cannot claim anything +about chord ownership without it (see Migration). -Whichever wins, the agent must detect and report its own failure to register rather than -going silent, because silence is the failure mode this project keeps designing against. -Carbon additionally requires `kEventHotKeyExclusive` if collision detection is wanted. +### The zero-frames question is probably a live bug + +Revision 2 framed this as unresolved. It should not be: Apple documents that capture from a +denied device yields silence rather than failure, so `rec.swift:128`'s `framesWritten == 0` +check most likely never fires. That would mean the shipped `rec` never exits 3, +`init.lua:486-496`'s probe always writes `ok`, and `--doctor`'s microphone check is a +confident false PASS today. + +Phase 0 validates the replacement implementation rather than deciding whether the premise +holds. If confirmed, file it against the current client — it is a defect in shipped code, +independent of this work. ## Architecture -One SPM package. CI runs `swift build` and `swift test`; the release job produces the -signed, notarised `Hark.app`. +One SPM package. CI runs `swift build` and `swift test`; the release job assembles, signs, +notarises and staples `Hark.app`. -Bundle ID `com.drycodeworks.hark-agent`. This is a **new** identifier that follows the -existing launchd label style; it is not an existing label. `Info.plist` carries -`LSUIElement`, `NSMicrophoneUsageDescription`, and the ATS declaration below. Login start -uses `SMAppService.mainApp`, setting the floor at macOS 13. +Bundle ID `com.drycodeworks.hark-agent` — a **new** identifier following the existing +launchd label style, not an existing label. `Info.plist` carries `LSUIElement`, +`NSMicrophoneUsageDescription`, `NSLocalNetworkUsageDescription` (needed for the +two-machine setup on macOS 15+, and separate from ATS), and the ATS keys below. +Entitlements carry the audio-input entitlement under the hardened runtime. | File | Responsibility | |---|---| -| `Config.swift` | Load `client.json`; read and trim the key file; enforce the transport policy | +| `Config.swift` | Load `client.json`; read and trim the key; enforce the transport policy | | `Hotkey.swift` | Register the chord (mechanism per Phase 0); press/release; registration failure | -| `Recorder.swift` | AVAudioEngine capture, format conversion, in-memory WAV, capture-state reporting | -| `DictateClient.swift` | POST, decode, map status to typed errors, sanitise the response | -| `AgentController.swift` | The state machine, permissions, paste, status file, logging, menu bar | +| `Recorder.swift` | AVAudioEngine capture, conversion, in-memory WAV, capture-state reporting | +| `DictateClient.swift` | Bounded POST, decode, typed errors, response sanitisation | +| `AgentController.swift` | State machine, permissions, paste, status heartbeat, logging, menu bar | -Revision 1 had eight files. `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one -caller and no independent policy, so they collapse into `AgentController`. The three -remaining seams — config, capture, transport — are the ones with real test surface. +Revision 1 had eight files; `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one +caller and no independent policy. The status item shows state and offers Quit only — +`install-client.sh --doctor` is already the diagnostic interface. -The status item shows state and offers Quit only. "Reload config" and "Run diagnostics" -are removed; `install-client.sh --doctor` is already the diagnostic interface and a second -one would drift from it. +### Login start -### Capture, permissions, and a possible live bug +`SMAppService.mainApp.register()` runs at first launch. Registration only schedules for +*subsequent* logins, so first launch must also start the agent directly. Handle +`.requiresApproval` (macOS has disabled it pending user consent) and `.notFound`/error by +surfacing state in the menu bar and in `--doctor`, rather than assuming success. -Revision 1 said "zero frames captured" indicates missing microphone permission. That is how -`rec.swift:128` works today, and a reviewer asserts it is wrong: that a TCC-denied -`AVAudioEngine` delivers the expected buffers filled with **silence** rather than no -buffers, so the frame count never reaches zero. +### Permissions -If that is correct, it is a live defect in shipped code, not merely a flaw in this design: -`rec.swift`'s exit-3 path would never fire, `init.lua:486-496`'s probe would always report -`ok`, and `--doctor`'s microphone check would be a confident false PASS. **Phase 0 must -verify this against a genuinely denied grant**, and if confirmed it gets its own issue -against the current client. +Capture permission is never inferred from frame counts: -Either way the design does not rely on frame counts for permission: +- `AVCaptureDevice.authorizationStatus(for: .audio)`, and `requestAccess` at first launch + to raise the dialog. The Microphone pane has no "+" button and lists only apps that have + already asked, so the request must actually happen. +- Probe at **launch**, not at first hotkey press, so a clean install has a status before + anyone runs `--doctor`. +- `AXIsProcessTrustedWithOptions` with the prompt option. `AXIsProcessTrusted` alone only + checks and never prompts, leaving a fresh install with no path to the grant. +- The agent probes local-network reachability itself. A `curl` from `--doctor` is a false + PASS, because terminal-launched tools are exempt from Local Network privacy. -- Query `AVCaptureDevice.authorizationStatus(for: .audio)` explicitly, and call - `requestAccess` at first launch to raise the consent dialog. The Microphone pane has no - "+" button and only lists apps that have already asked, so the request must happen. -- Probe at **launch**, not at first hotkey press, so a clean install has an - agent-authored status before anyone runs `--doctor`. -- Treat an all-silent buffer as its own reportable condition, distinct from a denied grant. +### Capture -Accessibility uses `AXIsProcessTrustedWithOptions` with the prompt option at first launch. -`AXIsProcessTrusted` alone only checks and never prompts, which leaves a fresh install with -no path to the grant. - -### The WAV is built in memory +The wire format is **16 kHz, mono, 16-bit signed PCM, little-endian, single `data` chunk** — +stated explicitly here because `audio.py:22` documents it in a comment while enforcing only +sample width. `/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:357` guards against. -`AVAudioFile` writes to a URL, so `Recorder` builds the 44-byte header itself. - -The RIFF and data chunk sizes depend on total length, which is unknown until release, so -the header is written last or back-patched before the POST. Getting this wrong produces a -server 400, which is exactly why the 400 mapping below had to change. - -### Capture must be serialised +`Recorder` builds the 44-byte header itself; RIFF and `data` sizes are back-patched before +the POST, since neither is known until release. The tap callback runs on an audio thread while key-up runs on the main queue. `rec.swift:112` mutates `framesWritten` from the callback while `rec.swift:128` reads it -from `stop()` — an existing unsynchronised access that becomes more consequential in-process. - -Define an explicit boundary: an actor or serial queue owning the buffer, with stop waiting -for the tap to drain before the WAV is finalised. Stop must be idempotent. +from `stop()` — unsynchronised today, and more consequential in-process. An actor or serial +queue owns the buffer; stop waits for the tap to drain and is idempotent. ## State machine -Revision 1 said only "return early if already recording", which leaves both a stuck-state -and an ordering hole. - ``` idle ──press──► starting ──first buffer──► recording ──release──► stopping - ▲ │ - └──────────── paste / error ◄──── uploading ◄──── drain complete ────┘ + ▲ │ │ │ + │ └── deadline / failure ──┐ └── cap reached ─────┤ + └──── paste, error, or discard ◄── uploading ◄──── drain complete ──┘ ``` -Rules: +| Transition | Behaviour | +|---|---| +| press outside `idle` | ignored | +| `starting`, no first buffer within **5 s** | abort to `idle`, report device or permission | +| `starting` + release | finish starting, then honour the release; never strand | +| engine start fails, or permission denied | abort to `idle` with the specific cause | +| `recording` beyond **120 s** | stop and upload what was captured, with a visible notice | +| `uploading`, request exceeds **30 s** | cancel, return to `idle`, report timeout | +| sleep, or user-session switch | abort capture to `idle` | -- A press outside `idle` is ignored. -- `stopping` exists so a press immediately after release cannot start a second capture - while the first is still draining. Revision 1 would have either overlapped captures or - dropped the second utterance. -- **One in-flight request.** `init.lua:319` clears its task before the async POST, so today - two quick utterances can paste in reverse order. Serialise: no new capture while a - request is pending, and drop a response whose sequence number is not current. -- **Maximum capture duration.** A missed release must not leave the engine running. In - memory this is unbounded growth rather than a growing file, so the cap stops capture and - returns to `idle`. -- Cleanup on sleep, app deactivation, and termination. +`stopping` exists so a press immediately after release cannot start a second capture while +the first drains — revision 1 would have overlapped captures or dropped the utterance. + +**One in-flight request**, with a sequence number. The state machine alone mostly prevents +overlap, but a timed-out request that completes late must not paste into a newer capture's +turn, so responses whose sequence is not current are discarded. + +A 5 s starting deadline is separate from the 120 s capture cap: today's recorder arms its +ceiling only after the first buffer (`rec.swift:167`), so a device that never delivers one +hangs indefinitely. ## Data flow 1. Press. Ignore unless `idle` and the key resolves. 2. Capture starts; status item and overlay show recording. -3. Release. Tap drains, WAV finalised in memory. -4. No frames, or all-silent buffers: report the capture-side cause and stop. Do not POST. +3. Release. Tap drains; WAV finalised in memory. **Snapshot the frontmost application.** +4. No frames, or an all-silent buffer: report the capture-side cause and stop. Do not POST. 5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. 6. `200` with empty text: transient "heard nothing", paste nothing. Not an error. -7. `200` with text: **sanitise, then** set the pasteboard, verify the write, then ⌘V. +7. `200` with text: sanitise, verify the paste target, set the pasteboard, verify the write, + then ⌘V. Never Return. The clipboard is deliberately not restored. -### The client sanitises the response +**Paste-target policy.** Transcription is asynchronous, so focus can move between release +and response. If the frontmost application is not the one snapshotted at release, put the +transcript on the pasteboard and report that automatic paste was withheld. Typing a +transcript into whatever happens to be focused later is worse than making the user press +⌘V. + +**Paste failure propagates.** If the pasteboard write fails, report it and do **not** +synthesise ⌘V — otherwise the keystroke pastes whatever was on the clipboard before. + +## Response handling and limits -The server sanitises at `sanitize.py:23`, and revision 1 treated that as sufficient. It is -not. A compromised server, or anyone on the network path of a plaintext two-machine setup, -returns JSON the server's sanitiser never touched. "Never Return" protects nothing when the -payload itself carries `\r` or `\n` into a terminal without bracketed paste. +The server sanitises at `sanitize.py:23`; revision 1 treated that as sufficient. It is not. +A compromised server, or anyone on the network path of a plaintext two-machine setup, +returns JSON the server's sanitiser never touched, and "Never Return" protects nothing when +the payload itself carries `\r` or `\n` into a terminal without bracketed paste. -`DictateClient` applies the same rule as `sanitize.py` — C0/C1 controls and Unicode line -separators replaced with a space, whitespace collapsed — plus a length cap, before the text -reaches the pasteboard. The server keeps its sanitiser; this is defence in depth, and the -Swift and Python implementations get the same test cases. +| Bound | Value | Applied | +|---|---|---| +| Response body | **1 MiB** | Before JSON decoding, as a streaming byte limit | +| Sanitised text | **8 KiB** (UTF-8 bytes) | After decode and sanitisation | +| Behaviour past either | **Reject, do not truncate** | Rejection is visible; truncation silently corrupts a transcript | -If the pasteboard write fails, report it and **do not** synthesise ⌘V — otherwise the -keystroke pastes whatever was on the clipboard before, potentially into a terminal. +The body cap must bind before decoding — a post-decode cap still lets a hostile server make +`URLSession` buffer an unbounded response. + +Sanitisation applies the same rule as `sanitize.py`: C0/C1 controls and Unicode line +separators replaced with a space, whitespace collapsed. The Swift and Python +implementations share test cases, including hostile inputs: embedded newlines, CSI/OSC +sequences, U+2028/U+2029, and an oversized body. ## Transport policy -The two-machine default is plaintext HTTP (`install-client.sh:503`) over what +The two-machine default is plaintext HTTP (`install-client.sh:503`) to what `README.md:214` calls "a LAN address you trust". On that path an attacker reads the audio, -the transcript and `X-Hark-Key`, and can forge the response that the client then types. - -`Config` enforces: +the transcript and `X-Hark-Key`, and can forge the response the client then types. -- plain HTTP permitted **only** for numeric loopback, -- HTTPS required for every other host, -- userinfo (`user@host`) rejected — `install-client.sh:230` already treats it as a defect, -- redirects not followed, -- an ephemeral `URLSession` so credentials and responses are not cached to disk. +Revision 2 prohibited non-loopback HTTP while promising a Tailscale opt-in, with no +mechanism. Resolved: -`Info.plist` declares the matching ATS exception. Revision 1 omitted ATS entirely, which -would have produced a client that captures audio and then fails every POST. +```json +{ + "server": "http://100.x.y.z:8911/dictate", + "insecure_transport_hosts": ["100.x.y.z"] +} +``` -Two-machine users on Tailscale who want to keep HTTP need an explicit, documented opt-in; -it is not the default. +- HTTPS is required for every non-loopback host. +- Plain HTTP is permitted for numeric loopback unconditionally, and for a host **only** if + that exact host is listed in `insecure_transport_hosts`. +- The list exists because Tailscale already encrypts at the network layer, so HTTP over a + tailnet is a defensible choice — but it must be a stated one, not a silent default. + Requiring TLS on the hark server instead would mean provisioning certificates, which is + a larger change than this issue. +- `--doctor` reports every entry as a warning naming the assumption it encodes. +- Userinfo (`user@host`) rejected; `install-client.sh:230` already treats it as a defect. +- Redirects cancelled explicitly in the `URLSession` delegate — the default session follows + them. +- Ephemeral `URLSession`, so credentials and responses are not cached to disk. + +ATS: `NSAllowsLocalNetworking` plus per-host `NSExceptionDomains` entries generated for the +allowlisted hosts. `NSAllowsLocalNetworking` permits local and IP loads broadly, so the +runtime check in `Config` — not ATS — is the real boundary. Modern macOS treats bare IP +addresses specially, so Phase 0 verifies the exact keys on both supported versions. ## Configuration -`client.json` holds the server URL. The key always lives at `~/.config/hark/key`, mode -600, written by whichever installer obtained it. Revision 1's inline-versus-file branch is -deleted: one path, one permission contract, nothing to drift. - -- `install-client.sh` creates `~/.config/hark` mode 700 before writing anything. On a - two-machine *client* the directory does not otherwise exist, because the server-side - `mkdir` ran on the other machine. -- The key file is read and **trimmed**: `config.py:129` writes `key + "\n"` and - `install-client.sh:427` strips it today. Sending the raw bytes 401s every request. - Reject embedded whitespace rather than silently trimming it. -- `client.json` is serialised by a real JSON encoder and replaced atomically. The current - heredoc at `install-client.sh:541` validates quotes only in the key (`:468`), so a +The key always lives at `~/.config/hark/key`, mode 600. Revision 1's inline-versus-file +branch is deleted: one path, one permission contract. + +- `install-client.sh` creates `~/.config/hark` mode 700 before writing. On a two-machine + *client* the directory does not otherwise exist — the server-side `mkdir` ran on the + other machine. +- The key is read and **trimmed**: `config.py:129` writes `key + "\n"` and + `install-client.sh:427` strips it today. Raw bytes 401 every request. Embedded whitespace + is rejected rather than silently trimmed. +- `client.json` is written by a real JSON encoder and replaced atomically. The current + heredoc (`install-client.sh:541`) validates quotes only in the key (`:468`), so a hand-entered URL containing a quote produces an unparseable file. -- `--doctor` must stop passing the key in argv (`install-client.sh:291`), where any local - account can read it from the process table. Use a config-file-fed curl or the agent. +- `--doctor` stops passing the key in argv (`install-client.sh:291`), where any local + account can read it from the process table. **Known residual risk.** Any same-UID process can rewrite `client.json` and point the trusted agent at an attacker's server; mode 600 does not prevent this. Under Developer ID the mitigation is available — pin the approved origin in a Keychain item protected by code -identity, and require in-app confirmation to change it. Recorded as a follow-up rather than -built now, because it needs its own design. +identity, requiring in-app confirmation to change. Recorded as a follow-up; it needs its own +design. ## Error handling @@ -273,97 +308,146 @@ built now, because it needs its own design. | Connection / transport failure | The URL, and that hark may not be running | | 401 | Key mismatch, and the file to fix | | 415 | A client bug, not a microphone problem | -| **400** | **Malformed or unsupported audio — a client format bug.** Show the server's `detail` | +| 400 | Malformed or unsupported audio — a client format bug. Shows the server's `detail` | | 503 | whisper-server down, `/tmp/hark-whisper.err` | | Other | Status code and the server's `detail` | -| No frames / all silent | Capture-side: device selection and microphone permission | - -Revision 1 carried `init.lua`'s "400 means microphone permission" across unchanged. That is -now wrong twice over: capture failures never reach the server, and `app.py:98-103` returns -400 for **every** `InvalidAudioError` — including a malformed header this client now builds -by hand. Sending users to Microphone settings for a header bug would be actively -misleading. - -Messages render in a borderless transient overlay rather than Notification Center, since a -muted notification is a silent failure. - -## Status file - -The agent writes `~/.config/hark/client-status.json`; `--doctor` reads it. `--doctor` must -not run the agent binary itself: a probe launched from the terminal tests the *terminal's* -TCC grant and produces a confident false PASS, which is the trap `install-client.sh:183-190` -already documents. - -Revision 1 said "only the path changes". Two things must also change: +| No frames captured | Device selection, and microphone permission | +| All-silent buffer | Input level or a muted device — **not** reported as a denied grant | + +The last two rows resolve revision 2's contradiction. A denied grant is detected by +`authorizationStatus`, never inferred from audio content, so an all-silent buffer means the +device is muted or the level is too low. + +**The server must change too.** `app.py:103-107` returns one detail for every +`InvalidAudioError` — "Check that the client has microphone permission and is sending +16 kHz mono 16-bit PCM WAV" — so a malformed header from this client's hand-built WAV would +still send users to Microphone settings. `test_app.py:107` asserts that wording +(`assert "microphone" in detail or "mic" in detail`). + +`audio.py` already raises distinct causes: empty body, unreadable WAV, wrong sample width. +Branch on them — empty body keeps the microphone advice, which is right for any client; +malformed or unsupported audio gets format advice. Update the test to match the cause it +exercises. + +## Status and `--doctor` + +`--doctor` must not run the agent binary itself: a probe launched from the terminal tests +the *terminal's* TCC grant, the trap `install-client.sh:183-190` already documents. + +Revision 2's "generation stamp" was not implementable — `--doctor` is bash and cannot read +the agent's internal state. The binding must be independently observable: + +```json +{ + "pid": 4321, + "process_started": "2026-07-31T14:02:11Z", + "written": "2026-07-31T14:31:02Z", + "bundle_version": "1.2.0", + "microphone": "authorized", + "accessibility": "trusted", + "local_network": "ok" +} +``` -- **Atomic write** — temp file plus rename. `--doctor` reading mid-write would otherwise - see partial JSON. -- **Generation stamp** — the file records the agent launch it came from, and `--doctor` - rejects a status older than the running agent. Today `install-client.sh:200-204` accepts - any existing `ok` first line, so a stale file survives a revoked grant and reports PASS. +- The agent rewrites this every **30 s** and on every permission change. +- `--doctor` confirms the PID is alive, that `ps -o lstart -p ` matches + `process_started`, and that `written` is under **90 s** old. Anything else fails. +- No agent running is a FAIL, not a missing check. +- Written atomically, temp file plus rename. Revision 1 would have let `--doctor` read + partial JSON, and today `install-client.sh:200-204` accepts any existing `ok` line, so a + stale file survives a revoked grant and reports PASS. + +Permissions are re-read after wake and on activation, so revocation during a long-running +agent is reflected rather than cached from launch. + +## Release and install contract + +"Downloads the artifact" is not a design. The installer replaces code that holds TCC grants, +so: + +- **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub + release, with the tag as the version. `install-client.sh` selects the newest release + unless pinned. +- **Verification, before anything is moved into place**: staple check via `spctl --assess`, + and `codesign --verify --deep --strict -R` against an expected requirement naming the + **Team ID and bundle ID**. Notarisation alone only proves *someone* signed it. +- **Replacement**: download to a staging directory, verify, quit any running agent, replace + atomically, then launch and confirm the status heartbeat appears. +- **Rollback**: keep the previous bundle until the new one reports healthy. +- Failure at any step leaves the existing install untouched. ## Testing -Unit-testable with no permissions and no microphone: config loading, key trimming and -rejection, transport-policy enforcement, the WAV header builder (including back-patched -sizes), the status-to-error mapping via a stubbed `URLProtocol`, and **hostile response -sanitisation** — control characters, newlines, Unicode line separators, over-length text. +No permissions or microphone needed: config loading, key trimming and rejection, +transport-policy enforcement including the allowlist, the WAV header builder with +back-patched sizes, status-to-error mapping via a stubbed `URLProtocol`, response bounds +(1 MiB body, 8 KiB text, reject-not-truncate), and hostile-response sanitisation. Server-side, fix the coverage revision 1 wrongly claimed existed: `audio.py` validates -sample width only, so add explicit assertions and server rejection for channel count and -sample rate alongside bit depth. +sample width only, so add assertions and server rejection for channel count and sample rate +alongside bit depth, and update the 400-detail test to the branched causes. -CI runs `swift build`, `swift test`, and the existing pytest suite. +CI runs `swift build`, `swift test`, the pytest suite, and verifies the signed bundle's +entitlements and designated requirement. -Not testable in CI, stated as plainly as the README states its own limits: TCC grants, real -hotkey delivery, real paste into a real window. +Not testable in CI, as plainly as the README states its own limits: TCC grants, real hotkey +delivery, real paste into a real window. ## Migration -**Nothing is deleted from the user's home directory.** But "report and move on" is not -enough, for two reasons the panel found: +**Nothing outside hark's own files is deleted.** But "report and move on" is insufficient: - A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file - changes nothing until it reloads, so both clients bind ⌃⌥Space and fight. - `install-client.sh:586` already documents that Hammerspoon does not auto-reload. -- Leaving Hammerspoon's Accessibility and Microphone grants in place preserves exactly the - privilege escape that motivates this issue. Any same-UID process can later rewrite the - symlink target and reload it. - -So `install-client.sh`: - -1. quits Hammerspoon and confirms it no longer owns the chord before starting the agent, -2. offers `tccutil reset Accessibility org.hammerspoon.Hammerspoon` and the Microphone - equivalent, and prints how to remove the cask, -3. reports leftover artifacts without deleting them. - -`client/init.lua` becomes a **silent** no-op stub. Revision 1 had it show an alert, which -would fire from a still-running Hammerspoon that the installer is already handling. + changes nothing until it reloads, so both clients bind ⌃⌥Space. + `install-client.sh:586` already documents that it does not auto-reload. +- Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this + issue. Any same-UID process can later rewrite the symlink target and reload it. + +`install-client.sh`: + +1. **Detects a hark-era install** — `~/.hammerspoon/init.lua` is a symlink into this repo. + If it is a real file, or points elsewhere, the user uses Hammerspoon independently: do + not quit it and do not offer to revoke anything. +2. **Quits Hammerspoon** and waits for the process to exit. Chord ownership cannot be + queried — macOS exposes no way to ask WindowServer who owns a hotkey — so the check is + confirmed process exit plus successful registration by the new agent. Revision 2's + "confirms it no longer owns the chord" overstated what is possible. +3. **Removes the symlink** it created. Leaving it means hark still claims Hammerspoon's only + config path and future repo changes still control it. A real file is never touched. +4. **Offers to revoke all three grants** — `tccutil reset Accessibility`, `Microphone`, and + `ListenEvent`, all for `org.hammerspoon.Hammerspoon`. Input Monitoring is included + because `README.md:245-247` told Fn-key users to grant it. +5. If the user declines revocation, says plainly that the original exposure remains. The + install proceeds; it is not silently reported as closed. +6. **Ordering**: the native agent must be installed, launched and verified healthy before + Hammerspoon is quit, so a failure leaves a working client. Removing `client/rec.swift` + while `install-client.sh:404` still compiles it would brick the installer mid-run. + +`client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. ## Blast radius -Revision 1 under-specified this and the panel enumerated it. All of the following are in -scope for the implementation, not follow-ups: +In scope for the implementation, not follow-ups: - **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build - (`:397-408`), the launch/relaunch block (`:576-589`), and every `--doctor` check - (`:86-110`, `:127`, `:173`, `:192`, `:657`, `:720`). Ordering matters: removing - `client/rec.swift` while `:404` still compiles it bricks the installer mid-run. + (`:397-408`), the launch block (`:576-589`), and every `--doctor` check (`:86-110`, + `:127`, `:173`, `:192`, `:657`, `:720`). +- **`src/hark/app.py`** (`:103-107`) and **`tests/test_app.py`** (`:107`) — branch the 400 + detail by cause. +- **`src/hark/audio.py`** and **`tests/test_audio.py`** — enforce and assert channel count + and sample rate, not just sample width. - **`README.md`** — architecture (`:13-14`), install (`:118-135`), `--doctor` (`:155-166`), - hotkey editing (`:230-247`), two-machine setup, and the repo layout (`:365-367`). + hotkey editing (`:230-247`), two-machine setup, repo layout (`:365-367`). - **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and `client/hark-config.example.lua`, replaced by a `client.json` example. -- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`. Three - of its statements stop being true: - - `:5` — "(architecture unchanged)", a parenthetical that this design invalidates. - - `:212-213` — "Replacing Hammerspoon with a native Swift menubar app" listed as a - non-goal, which is now the goal. Its stated reason ("it would be the right answer for a - *product*; this is not one") deserves an explicit answer rather than silent reversal. +- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`: + - `:5` — "(architecture unchanged)", which this design invalidates. + - `:213-214` — replacing Hammerspoon with a native Swift menubar app listed as a + non-goal, with the stated reason "It would be the right answer for a *product*; this is + not one." That deserves an explicit answer, not silent reversal. - `:210` — "CI, release automation, versioning, changelogs" listed as a non-goal. #1 - already added CI, and this design adds signed release automation. Stale independently - of the native client. -- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, - pending implementation plan". + already added CI and this design adds signed release automation. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`). ## Non-goals @@ -371,14 +455,13 @@ scope for the implementation, not follow-ups: - Pre-warming the audio engine. In-process capture makes it possible; the README's privacy trade-off is deliberate and stands. - Streaming transcription and auto-submit, per the 2026-07-14 spec. -- Keychain-pinned server origin. Recorded above as a follow-up. +- TLS on the hark server. The `insecure_transport_hosts` allowlist is the interim answer. +- Keychain-pinned server origin. Follow-up, recorded above. ## Open questions - Phase 0's two answers, which decide the hotkey mechanism. -- Whether TCC-denied capture really yields silent buffers rather than no frames. If yes, - file a separate issue against the current client. - Whether the clipboard should self-clear after a timeout when `changeCount` is unchanged. Any local process can poll `NSPasteboard` and harvest every transcript without holding any TCC grant. Revision 1 documented non-restoration purely as a usability choice; it is - also a privacy exposure and the README should say so either way. + also a privacy exposure, and the README should say so either way. From b34eb789099ce747cda8d95139947271f94ddae4 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:14:06 -0400 Subject: [PATCH 4/7] [hark] Fix the ATS and migration-ordering defects from round 3 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 --- .../specs/2026-07-31-native-client-design.md | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index c476ad6..4ae5a4d 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -260,23 +260,29 @@ mechanism. Resolved: } ``` -- HTTPS is required for every non-loopback host. -- Plain HTTP is permitted for numeric loopback unconditionally, and for a host **only** if - that exact host is listed in `insecure_transport_hosts`. -- The list exists because Tailscale already encrypts at the network layer, so HTTP over a - tailnet is a defensible choice — but it must be a stated one, not a silent default. - Requiring TLS on the hark server instead would mean provisioning certificates, which is - a larger change than this issue. +- HTTPS is required for every non-loopback **hostname**, with no exceptions. +- Plain HTTP is permitted for numeric loopback unconditionally, and for a **numeric IP + literal** only if that exact address is listed in `insecure_transport_hosts`. +- The allowlist exists because Tailscale already encrypts at the network layer, so HTTP to + a tailnet IP is a defensible choice — but it must be a stated one, not a silent default. + Provisioning TLS on the hark server would be a larger change than this issue. +- A Tailscale MagicDNS name is therefore **not** usable over HTTP. Use the tailnet IP. - `--doctor` reports every entry as a warning naming the assumption it encodes. - Userinfo (`user@host`) rejected; `install-client.sh:230` already treats it as a defect. - Redirects cancelled explicitly in the `URLSession` delegate — the default session follows them. - Ephemeral `URLSession`, so credentials and responses are not cached to disk. -ATS: `NSAllowsLocalNetworking` plus per-host `NSExceptionDomains` entries generated for the -allowlisted hosts. `NSAllowsLocalNetworking` permits local and IP loads broadly, so the -runtime check in `Config` — not ATS — is the real boundary. Modern macOS treats bare IP -addresses specially, so Phase 0 verifies the exact keys on both supported versions. +**ATS: `NSAllowsLocalNetworking` only, and nothing per-host.** Revision 3 said the +installer would generate per-host `NSExceptionDomains` entries. That is impossible: +`NSExceptionDomains` lives in the signed `Info.plist`, so writing to it after download +invalidates the CDHash and the Developer ID signature, and macOS kills the app at launch. + +Restricting insecure HTTP to IP literals is what makes a static plist sufficient — +`NSAllowsLocalNetworking` exempts IP-literal loads without naming any host, so the plist +never has to know which addresses a given user configured. The `Config` allowlist check at +runtime, not ATS, remains the actual boundary. Phase 0 confirms the key behaves this way on +both supported macOS versions, since bare IP handling has changed across releases. ## Configuration @@ -403,25 +409,40 @@ delivery, real paste into a real window. - Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this issue. Any same-UID process can later rewrite the symlink target and reload it. -`install-client.sh`: - -1. **Detects a hark-era install** — `~/.hammerspoon/init.lua` is a symlink into this repo. - If it is a real file, or points elsewhere, the user uses Hammerspoon independently: do - not quit it and do not offer to revoke anything. -2. **Quits Hammerspoon** and waits for the process to exit. Chord ownership cannot be - queried — macOS exposes no way to ask WindowServer who owns a hotkey — so the check is - confirmed process exit plus successful registration by the new agent. Revision 2's - "confirms it no longer owns the chord" overstated what is possible. -3. **Removes the symlink** it created. Leaving it means hark still claims Hammerspoon's only - config path and future repo changes still control it. A real file is never touched. -4. **Offers to revoke all three grants** — `tccutil reset Accessibility`, `Microphone`, and - `ListenEvent`, all for `org.hammerspoon.Hammerspoon`. Input Monitoring is included - because `README.md:245-247` told Fn-key users to grant it. -5. If the user declines revocation, says plainly that the original exposure remains. The +### Staged cutover + +Revisions 2 and 3 contained a deadlock: they required the native agent to be verified +healthy *before* Hammerspoon is quit, while the agent cannot register the hotkey at all +while Hammerspoon owns it — certainly not under `kEventHotKeyExclusive`. Health-checking a +hotkey the old client still holds is not possible, so registration is split out of the +health check and happens after the handover: + +1. **Detect a hark-era install.** `~/.hammerspoon/init.lua` is a symlink whose target is a + `client/init.lua` carrying a hark marker comment — matching the *content*, not this + checkout's path, so an install still resolves after the repo moves. A real file, or a + symlink to something without the marker, means the user runs Hammerspoon + independently: do not quit it, do not offer to revoke anything, and ask before + proceeding. +2. **Install and health-check the agent with the hotkey disabled.** Everything except + registration is verified: bundle signature, launch, permissions, status heartbeat, + server reachability, and key authentication. +3. **Quit Hammerspoon** and wait for process exit. Chord ownership cannot be queried — + macOS exposes no way to ask WindowServer who owns a hotkey — so confirmed process exit + is the strongest available signal. Revision 2's "confirms it no longer owns the chord" + overstated what is possible. +4. **Tell the running agent to register the hotkey**, and verify registration succeeded. +5. **On failure at step 4**: restore the symlink, relaunch Hammerspoon, leave the agent + installed but inactive, and report what happened. The user ends with a working client + either way. +6. **Only then** remove the symlink hark created, and offer to revoke **all three** grants — + `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for + `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:245-247` + told Fn-key users to grant it. +7. If the user declines revocation, say plainly that the original exposure remains. The install proceeds; it is not silently reported as closed. -6. **Ordering**: the native agent must be installed, launched and verified healthy before - Hammerspoon is quit, so a failure leaves a working client. Removing `client/rec.swift` - while `install-client.sh:404` still compiles it would brick the installer mid-run. + +Removing `client/rec.swift` while `install-client.sh:404` still compiles it would brick the +installer mid-run, so that ordering holds too. `client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. From 5e02539934d90e4235e1cfe7e63dda5ae9c69c82 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:19:30 -0400 Subject: [PATCH 5/7] [hark] Rebase the native client design and close the round 3 findings 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 #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 --- .../specs/2026-07-31-native-client-design.md | 181 ++++++++++++------ 1 file changed, 122 insertions(+), 59 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index 4ae5a4d..4dc2c4a 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -2,11 +2,13 @@ Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) Date: 2026-07-31 -Revision: 3. Round 1 returned 6/6 REVISE; round 2 returned 5 REVISE / 1 APPROVED. +Revision: 4. Panel rounds: 6/6 REVISE, then 5 REVISE + 1 APPROVED, then 6/6 REVISE with +the findings down to two contradictions and a format bug. Rebased onto `8d12f7b`; all line +numbers re-verified against that commit. ## Why -`install-client.sh:573` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +`install-client.sh:595` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. Hammerspoon has exactly one config file, so installing hark claims it. The permission story matters more. Accessibility is granted to Hammerspoon, not to hark: @@ -33,7 +35,7 @@ owns the chord. The installed SDK says the opposite: > — `CarbonEvents.h`, `RegisterEventHotKey` discussion **Revision 1 fabricated a test claim.** It said `tests/test_audio.py` pins the wire format. -`audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. +`audio.py:45-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. **Revision 2 contradicted itself twice.** It promised a Tailscale HTTP opt-in while prohibiting non-loopback HTTP, with no mechanism for the opt-in; and it called an @@ -80,7 +82,7 @@ choice, not a permission one. Two questions decide the mechanism. Neither should be answered by argument. 1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** - `README.md:245-247` states from this project's own experience that an `hs.eventtap` + `README.md:266-268` states from this project's own experience that an `hs.eventtap` watching `flagsChanged` needs it. If that holds for keyDown/keyUp, Carbon is one grant and the tap is two, and the permission argument survives revision 1's bad reasoning by a different route. If not, the tap is free. @@ -100,17 +102,26 @@ Two questions decide the mechanism. Neither should be answered by argument. If Carbon wins, `kEventHotKeyExclusive` is mandatory — migration cannot claim anything about chord ownership without it (see Migration). -### The zero-frames question is probably a live bug +### The zero-frames question is settled, and already fixed upstream -Revision 2 framed this as unresolved. It should not be: Apple documents that capture from a -denied device yields silence rather than failure, so `rec.swift:128`'s `framesWritten == 0` -check most likely never fires. That would mean the shipped `rec` never exits 3, -`init.lua:486-496`'s probe always writes `ok`, and `--doctor`'s microphone check is a -confident false PASS today. +Revisions 1 and 2 treated this as an open risk. It is neither open nor hypothetical: +inferring microphone permission from a frame count does not work, because a TCC-denied +device yields substituted silence rather than no frames. It was filed as +[#9](https://github.com/DRYCodeWorks/hark/issues/9) and fixed in `559aafe` for the current +Lua client, whose `rec.swift` now says so directly: -Phase 0 validates the replacement implementation rather than deciding whether the premise -holds. If confirmed, file it against the current client — it is a defect in shipped code, -independent of this work. +> "format negotiation succeeds under denial too. The only way to learn the answer is to +> ask TCC for it." + +That commit is the reference implementation for this design's permission handling rather +than something it has to invent: `rec.swift:65` switches on +`AVCaptureDevice.authorizationStatus(for: .audio)`, `:78` calls `requestAccess` and pumps +the main run loop rather than blocking on a semaphore (`requestAccess` delivers on an +unspecified queue, so a blocked main thread can deadlock), and the zero-frame check +survives at `:185` for its real meaning — the device delivered nothing at all. + +The native agent inherits all three behaviours. Phase 0 no longer has anything to decide +here. ## Architecture @@ -162,12 +173,12 @@ The wire format is **16 kHz, mono, 16-bit signed PCM, little-endian, single `dat stated explicitly here because `audio.py:22` documents it in a comment while enforcing only sample width. -`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:357` guards against. +`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:375` guards against. `Recorder` builds the 44-byte header itself; RIFF and `data` sizes are back-patched before the POST, since neither is known until release. The tap callback runs on an audio thread while key-up runs on the main queue. -`rec.swift:112` mutates `framesWritten` from the callback while `rec.swift:128` reads it +`rec.swift:163` mutates `framesWritten` from the callback while `rec.swift:185` reads it from `stop()` — unsynchronised today, and more consequential in-process. An actor or serial queue owns the buffer; stop waits for the tap to drain and is idempotent. @@ -198,7 +209,7 @@ overlap, but a timed-out request that completes late must not paste into a newer turn, so responses whose sequence is not current are discarded. A 5 s starting deadline is separate from the 120 s capture cap: today's recorder arms its -ceiling only after the first buffer (`rec.swift:167`), so a device that never delivers one +ceiling only after the first buffer (`rec.swift:139`), so a device that never delivers one hangs indefinitely. ## Data flow @@ -215,11 +226,18 @@ hangs indefinitely. Never Return. The clipboard is deliberately not restored. **Paste-target policy.** Transcription is asynchronous, so focus can move between release -and response. If the frontmost application is not the one snapshotted at release, put the -transcript on the pasteboard and report that automatic paste was withheld. Typing a +and response. If the frontmost **application** is not the one snapshotted at release, put +the transcript on the pasteboard and report that automatic paste was withheld. Typing a transcript into whatever happens to be focused later is worse than making the user press ⌘V. +The guarantee is deliberately stated at application granularity, because that is all this +mechanism enforces. It does **not** catch a focus move *within* an application — a +different browser tab, or a password field in the same window. Catching that would mean +comparing the system-wide focused accessibility element, which is a heavier check against a +moving target. Claiming protection this does not deliver would be worse than the narrower +promise, so the README should describe it in the same terms. + **Paste failure propagates.** If the pasteboard write fails, report it and do **not** synthesise ⌘V — otherwise the keystroke pastes whatever was on the clipboard before. @@ -234,11 +252,19 @@ the payload itself carries `\r` or `\n` into a terminal without bracketed paste. |---|---|---| | Response body | **1 MiB** | Before JSON decoding, as a streaming byte limit | | Sanitised text | **8 KiB** (UTF-8 bytes) | After decode and sanitisation | +| Error `detail` | **2 KiB**, sanitised | Same treatment as transcript text | | Behaviour past either | **Reject, do not truncate** | Rejection is visible; truncation silently corrupts a transcript | The body cap must bind before decoding — a post-decode cap still lets a hostile server make `URLSession` buffer an unbounded response. +**Every response path is bounded and sanitised, not just the 200.** The error table below +displays the server's `detail` string on 400, 503 and the catch-all, and that string is +just as attacker-controlled as a transcript. An unsanitised `detail` reaches an alert +rather than the pasteboard, so it cannot be typed into a terminal — but it can still carry +control sequences into whatever renders it, and an unbounded one is a denial of service on +the UI. Same rule, smaller cap. + Sanitisation applies the same rule as `sanitize.py`: C0/C1 controls and Unicode line separators replaced with a space, whitespace collapsed. The Swift and Python implementations share test cases, including hostile inputs: embedded newlines, CSI/OSC @@ -246,8 +272,8 @@ sequences, U+2028/U+2029, and an oversized body. ## Transport policy -The two-machine default is plaintext HTTP (`install-client.sh:503`) to what -`README.md:214` calls "a LAN address you trust". On that path an attacker reads the audio, +The two-machine default is plaintext HTTP (`install-client.sh:525`) to what +`README.md:234` calls "a LAN address you trust". On that path an attacker reads the audio, the transcript and `X-Hark-Key`, and can forge the response the client then types. Revision 2 prohibited non-loopback HTTP while promising a Tailscale opt-in, with no @@ -268,7 +294,7 @@ mechanism. Resolved: Provisioning TLS on the hark server would be a larger change than this issue. - A Tailscale MagicDNS name is therefore **not** usable over HTTP. Use the tailnet IP. - `--doctor` reports every entry as a warning naming the assumption it encodes. -- Userinfo (`user@host`) rejected; `install-client.sh:230` already treats it as a defect. +- Userinfo (`user@host`) rejected; `install-client.sh:241` already treats it as a defect. - Redirects cancelled explicitly in the `URLSession` delegate — the default session follows them. - Ephemeral `URLSession`, so credentials and responses are not cached to disk. @@ -293,12 +319,12 @@ branch is deleted: one path, one permission contract. *client* the directory does not otherwise exist — the server-side `mkdir` ran on the other machine. - The key is read and **trimmed**: `config.py:129` writes `key + "\n"` and - `install-client.sh:427` strips it today. Raw bytes 401 every request. Embedded whitespace + `install-client.sh:449` strips it today. Raw bytes 401 every request. Embedded whitespace is rejected rather than silently trimmed. - `client.json` is written by a real JSON encoder and replaced atomically. The current - heredoc (`install-client.sh:541`) validates quotes only in the key (`:468`), so a + heredoc (`install-client.sh:563`) validates quotes only in the key (`:491`), so a hand-entered URL containing a quote produces an unparseable file. -- `--doctor` stops passing the key in argv (`install-client.sh:291`), where any local +- `--doctor` stops passing the key in argv (`install-client.sh:313`), where any local account can read it from the process table. **Known residual risk.** Any same-UID process can rewrite `client.json` and point the @@ -338,7 +364,7 @@ exercises. ## Status and `--doctor` `--doctor` must not run the agent binary itself: a probe launched from the terminal tests -the *terminal's* TCC grant, the trap `install-client.sh:183-190` already documents. +the *terminal's* TCC grant, the trap `install-client.sh:177-184` already documents. Revision 2's "generation stamp" was not implementable — `--doctor` is bash and cannot read the agent's internal state. The binding must be independently observable: @@ -346,21 +372,33 @@ the agent's internal state. The binding must be independently observable: ```json { "pid": 4321, - "process_started": "2026-07-31T14:02:11Z", - "written": "2026-07-31T14:31:02Z", + "process_started": "Fri Jul 31 14:02:11 2026", + "written_epoch": 1785508262, "bundle_version": "1.2.0", "microphone": "authorized", "accessibility": "trusted", - "local_network": "ok" + "local_network": "ok", + "hotkey": "registered", + "login_item": "enabled" } ``` +- `process_started` is the **verbatim output of `ps -o lstart= -p `**, read by the + agent about itself at launch. An ISO-8601 timestamp cannot work here: `ps -o lstart=` + prints a localised, non-ISO string (`Fri Jul 31 14:02:11 2026`, with trailing padding), + so `--doctor` would need brittle date conversion to compare. Storing what `ps` prints + makes the check a trimmed string equality. +- `written_epoch` is integer seconds, so bash compares it with `$(date +%s)` and no parsing. - The agent rewrites this every **30 s** and on every permission change. -- `--doctor` confirms the PID is alive, that `ps -o lstart -p ` matches - `process_started`, and that `written` is under **90 s** old. Anything else fails. -- No agent running is a FAIL, not a missing check. +- `--doctor` fails unless the PID is alive, `ps -o lstart= -p ` matches + `process_started` after trimming, and `written_epoch` is within **90 s** of now. +- `hotkey` and `login_item` are in the heartbeat because a fresh status file otherwise + proves only that a process is running with permissions. It would pass with an + unregistered hotkey or a login item macOS has disabled — both of which mean the product + does not work. `--doctor` requires both. +- No agent running is a FAIL, not a skipped check. - Written atomically, temp file plus rename. Revision 1 would have let `--doctor` read - partial JSON, and today `install-client.sh:200-204` accepts any existing `ok` line, so a + partial JSON, and today `install-client.sh:204-208` accepts any existing `ok` line, so a stale file survives a revoked grant and reports PASS. Permissions are re-read after wake and on activation, so revocation during a long-running @@ -374,12 +412,17 @@ so: - **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub release, with the tag as the version. `install-client.sh` selects the newest release unless pinned. -- **Verification, before anything is moved into place**: staple check via `spctl --assess`, - and `codesign --verify --deep --strict -R` against an expected requirement naming the - **Team ID and bundle ID**. Notarisation alone only proves *someone* signed it. +- **Verification, before anything is moved into place**, all three and in this order: + `xcrun stapler validate` (the notarisation ticket is actually stapled), `spctl --assess + --type execute` (Gatekeeper accepts it), and `codesign --verify --deep --strict -R` + against an expected requirement naming the **Team ID and bundle ID**. `spctl` alone is a + Gatekeeper verdict, not a staple check, and notarisation alone only proves *someone* + signed it — the requirement string is what proves it was *you*. - **Replacement**: download to a staging directory, verify, quit any running agent, replace - atomically, then launch and confirm the status heartbeat appears. -- **Rollback**: keep the previous bundle until the new one reports healthy. + atomically, then launch and confirm the status heartbeat appears with `hotkey` and + `login_item` both healthy. +- **Rollback**: keep the previous bundle until the new one reports healthy, and on failure + restore *and relaunch* it. Leaving the user with no running client is its own outage. - Failure at any step leaves the existing install untouched. ## Testing @@ -405,7 +448,7 @@ delivery, real paste into a real window. - A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file changes nothing until it reloads, so both clients bind ⌃⌥Space. - `install-client.sh:586` already documents that it does not auto-reload. + `install-client.sh:608` already documents that it does not auto-reload. - Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this issue. Any same-UID process can later rewrite the symlink target and reload it. @@ -436,39 +479,42 @@ health check and happens after the handover: either way. 6. **Only then** remove the symlink hark created, and offer to revoke **all three** grants — `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for - `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:245-247` + `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:266-268` told Fn-key users to grant it. 7. If the user declines revocation, say plainly that the original exposure remains. The install proceeds; it is not silently reported as closed. -Removing `client/rec.swift` while `install-client.sh:404` still compiles it would brick the +Removing `client/rec.swift` while `install-client.sh:426` still compiles it would brick the installer mid-run, so that ordering holds too. `client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. ## Blast radius -In scope for the implementation, not follow-ups: +Line numbers are against `8d12f7b`. In scope for the implementation, not follow-ups: -- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build - (`:397-408`), the launch block (`:576-589`), and every `--doctor` check (`:86-110`, - `:127`, `:173`, `:192`, `:657`, `:720`). +- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build (`:419`), + the launch block (`:613`), and every `--doctor` check: `check_hammerspoon_installed` + (`:86`), the Lua-config key grep (`:127`), the recorder check (`:173`), the mic-status + read (`:196`), the Hammerspoon TCC query (`:679`), and the probe wait (`:742`). - **`src/hark/app.py`** (`:103-107`) and **`tests/test_app.py`** (`:107`) — branch the 400 detail by cause. - **`src/hark/audio.py`** and **`tests/test_audio.py`** — enforce and assert channel count - and sample rate, not just sample width. -- **`README.md`** — architecture (`:13-14`), install (`:118-135`), `--doctor` (`:155-166`), - hotkey editing (`:230-247`), two-machine setup, repo layout (`:365-367`). + and sample rate, not just sample width (`:45`). +- **`README.md`** — architecture (`:16`), install step 1 (`:138`), the `--doctor` list + (`:175`), "Changing the hotkey" (`:250`), two-machine setup, repo layout (`:387`). - **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and `client/hark-config.example.lua`, replaced by a `client.json` example. -- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`: - - `:5` — "(architecture unchanged)", which this design invalidates. - - `:213-214` — replacing Hammerspoon with a native Swift menubar app listed as a - non-goal, with the stated reason "It would be the right answer for a *product*; this is - not one." That deserves an explicit answer, not silent reversal. - - `:210` — "CI, release automation, versioning, changelogs" listed as a non-goal. #1 - already added CI and this design adds signed release automation. -- Update the status line of `2026-07-14-dictate-design.md` (`:10`). +- **`docs/superpowers/specs/2026-07-14-hark-open-source-design.md:7`** — "(architecture + unchanged)", which this design invalidates. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, + pending implementation plan". + +**Already done upstream, and deliberately not repeated here.** `ef47aeb` struck the two +non-goals this design contradicts — "CI, release automation, versioning, changelogs" +(`:219`) and "Replacing Hammerspoon with a native Swift menubar app" (`:228`) — and added a +"Superseded in part" header at `:211`. `c852392` cleared the stale `setup.sh` references +(#8's sibling work). Only `:7` above remains. ## Non-goals @@ -479,10 +525,27 @@ In scope for the implementation, not follow-ups: - TLS on the hark server. The `insecure_transport_hosts` allowlist is the interim answer. - Keychain-pinned server origin. Follow-up, recorded above. +## Clipboard retention + +Two reviewers asked for this to be decided rather than left open, and they are right: it is +a privacy policy, not a preference. + +**The transcript self-clears after 90 seconds, but only if `NSPasteboard.changeCount` is +unchanged since hark wrote it.** + +The existing behaviour — set and never restore — is deliberate and stays: a misfired paste +stays recoverable with a manual ⌘V instead of re-speaking. See the comment above +`hs.pasteboard.setContents` in `client/init.lua`. But "never restore" currently means +*indefinitely*, and any local process can poll `NSPasteboard` and harvest every transcript +without holding a single TCC grant. That is a real exposure the design was silently +inheriting. + +90 seconds keeps the recovery window that motivated the original decision while bounding +the exposure. The `changeCount` guard means hark only ever clears its own value — if you +copied something else in the meantime, hark does nothing. + +The README should state this as a privacy property, not only as paste-recovery ergonomics. + ## Open questions - Phase 0's two answers, which decide the hotkey mechanism. -- Whether the clipboard should self-clear after a timeout when `changeCount` is unchanged. - Any local process can poll `NSPasteboard` and harvest every transcript without holding - any TCC grant. Revision 1 documented non-restoration purely as a usability choice; it is - also a privacy exposure, and the README should say so either way. From 0a5e02c693d8a36686056e4d19b4f0f73c96245b Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:29:52 -0400 Subject: [PATCH 6/7] [hark] Make migration rollback actually able to roll back 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 --- .../specs/2026-07-31-native-client-design.md | 90 +++++++++++++------ 1 file changed, 65 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index 4dc2c4a..f631f41 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -148,10 +148,15 @@ caller and no independent policy. The status item shows state and offers Quit on ### Login start -`SMAppService.mainApp.register()` runs at first launch. Registration only schedules for -*subsequent* logins, so first launch must also start the agent directly. Handle -`.requiresApproval` (macOS has disabled it pending user consent) and `.notFound`/error by -surfacing state in the menu bar and in `--doctor`, rather than assuming success. +`SMAppService.mainApp.register()` is called by the **installer as the last step of a +successful cutover** (Migration, step 4), never automatically at first launch. Registering +on launch would mean a rolled-back install still starts the agent at the next login, where +it would fight the restored Hammerspoon client. + +Registration only schedules for *subsequent* logins, so the installer also starts the agent +directly for the current session. Handle `.requiresApproval` (macOS has disabled it pending +user consent) and `.notFound`/error by surfacing state in the menu bar, in the status +heartbeat's `login_item` field, and in `--doctor`, rather than assuming success. ### Permissions @@ -412,12 +417,24 @@ so: - **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub release, with the tag as the version. `install-client.sh` selects the newest release unless pinned. -- **Verification, before anything is moved into place**, all three and in this order: - `xcrun stapler validate` (the notarisation ticket is actually stapled), `spctl --assess - --type execute` (Gatekeeper accepts it), and `codesign --verify --deep --strict -R` - against an expected requirement naming the **Team ID and bundle ID**. `spctl` alone is a - Gatekeeper verdict, not a staple check, and notarisation alone only proves *someone* - signed it — the requirement string is what proves it was *you*. +- **Verification splits across CI and the client**, because they have different tools + available: + - **In CI**, where Xcode exists: `xcrun stapler validate` confirms the notarisation + ticket is actually stapled to the artifact before it is published. This is the only + place `stapler` is used. + - **On the client**, using only stock macOS binaries: `spctl --assess --type execute -vv` + (`/usr/sbin/spctl`) and `codesign --verify --deep --strict -R` (`/usr/bin/codesign`) + against an expected requirement naming the **Team ID and bundle ID**. + + Revision 4 called for `xcrun stapler validate` on the client. That contradicted this + document's own claim that the client no longer needs the Xcode command line tools — + `stapler` ships with Xcode, not with macOS. Since the downloaded artifact is quarantined, + `spctl --assess` exercises the full Gatekeeper path including notarisation, which is the + property that actually matters at install time; stapling is a release-time concern and + belongs where the toolchain already exists. + + `spctl` alone is a Gatekeeper verdict and notarisation alone only proves *someone* signed + it — the requirement string is what proves it was *you*. - **Replacement**: download to a staging directory, verify, quit any running agent, replace atomically, then launch and confirm the status heartbeat appears with `hotkey` and `login_item` both healthy. @@ -460,29 +477,52 @@ while Hammerspoon owns it — certainly not under `kEventHotKeyExclusive`. Healt hotkey the old client still holds is not possible, so registration is split out of the health check and happens after the handover: -1. **Detect a hark-era install.** `~/.hammerspoon/init.lua` is a symlink whose target is a - `client/init.lua` carrying a hark marker comment — matching the *content*, not this - checkout's path, so an install still resolves after the repo moves. A real file, or a - symlink to something without the marker, means the user runs Hammerspoon - independently: do not quit it, do not offer to revoke anything, and ask before - proceeding. -2. **Install and health-check the agent with the hotkey disabled.** Everything except - registration is verified: bundle signature, launch, permissions, status heartbeat, - server reachability, and key authentication. +0. **Preserve a rollback target first.** Copy the *current, working* `client/init.lua` to + `~/.config/hark/legacy-client.lua` and record ownership in + `~/.config/hark/legacy-client.json`: that hark installed the symlink, its original + target path, and a checksum. + + Both files are load-bearing and revision 4 had neither. `client/init.lua` becomes a + no-op stub in this same change, so after the upgrade the symlink's target *is* the stub + — "restore the symlink" would restore a client that does nothing. Rollback repoints at + the preserved copy instead. + + The ownership record exists because content-matching the symlink target cannot survive + the repo moving: an absolute symlink (`install-client.sh:595`) to a moved checkout is + dangling, so there is no content left to match. A durable record in `~/.config/hark` + answers "did hark install this" without depending on the target existing. +1. **Detect a hark-era install**, in this order: the ownership record from step 0 if + present; otherwise a symlink whose target carries the hark marker comment; otherwise a + *dangling* symlink whose recorded path matches a known hark layout. A real file, or a + symlink to something without the marker and without a record, means the user runs + Hammerspoon independently — do not quit it, do not offer to revoke anything, and ask + before proceeding. +2. **Install and health-check the agent with the hotkey disabled**, and with login + registration **not yet performed**. Everything else is verified: bundle signature, + launch, permissions, status heartbeat, server reachability, and key authentication. 3. **Quit Hammerspoon** and wait for process exit. Chord ownership cannot be queried — macOS exposes no way to ask WindowServer who owns a hotkey — so confirmed process exit is the strongest available signal. Revision 2's "confirms it no longer owns the chord" overstated what is possible. -4. **Tell the running agent to register the hotkey**, and verify registration succeeded. -5. **On failure at step 4**: restore the symlink, relaunch Hammerspoon, leave the agent - installed but inactive, and report what happened. The user ends with a working client - either way. -6. **Only then** remove the symlink hark created, and offer to revoke **all three** grants — - `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for +4. **Tell the running agent to register the hotkey**, verify it succeeded, and only then + call `SMAppService.mainApp.register()`. +5. **On failure at step 4, roll back in this order**: stop the agent, ensure + `SMAppService.mainApp` is **not** registered (unregister if step 4 got that far), point + `~/.hammerspoon/init.lua` at `~/.config/hark/legacy-client.lua`, relaunch Hammerspoon, + confirm it is running, then report. The user ends with a working client either way. + + Deregistration is the part revision 4 missed: it left the agent "installed but inactive" + while registration ran unconditionally at first launch, so the agent would start at the + next login and fight the Hammerspoon that had just been restored. Registration is + therefore deliberately the *last* step of a successful cutover, not part of launch. +6. **Only on success**, remove the symlink hark created, and offer to revoke **all three** + grants — `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:266-268` told Fn-key users to grant it. 7. If the user declines revocation, say plainly that the original exposure remains. The install proceeds; it is not silently reported as closed. +8. `~/.config/hark/legacy-client.lua` is kept, not deleted, so a later manual rollback is + still possible. It is inert once the symlink is gone. Removing `client/rec.swift` while `install-client.sh:426` still compiles it would brick the installer mid-run, so that ordering holds too. From c7a163febe3e95c7804d301db4558bfd4ec595ef Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 17:32:18 -0400 Subject: [PATCH 7/7] feat(hark): land the Swift rewrite of the dictation client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 6 + swift/Package.swift | 18 + swift/Packaging/Info.plist | 31 ++ swift/Packaging/Makefile | 34 ++ swift/Packaging/build-app.sh | 48 +++ swift/Packaging/entitlements.plist | 12 + swift/Sources/HarkCore/Config.swift | 131 +++++++ swift/Sources/HarkCore/DictateClient.swift | 170 +++++++++ swift/Sources/HarkCore/HarkServer.swift | 359 ++++++++++++++++++ swift/Sources/HarkCore/KeyFile.swift | 67 ++++ swift/Sources/HarkCore/Sanitize.swift | 45 +++ swift/Sources/HarkCore/WAV.swift | 155 ++++++++ swift/Sources/HarkCore/WhisperClient.swift | 88 +++++ swift/Sources/hark/AgentController.swift | 320 ++++++++++++++++ swift/Sources/hark/Hotkey.swift | 76 ++++ swift/Sources/hark/Recorder.swift | 127 +++++++ swift/Sources/hark/main.swift | 36 ++ swift/Tests/HarkCoreTests/ConfigTests.swift | 40 ++ .../HarkCoreTests/DictateClientTests.swift | 95 +++++ swift/Tests/HarkCoreTests/E2ETests.swift | 84 ++++ .../HarkCoreTests/IntegrationTests.swift | 116 ++++++ swift/Tests/HarkCoreTests/KeyFileTests.swift | 59 +++ swift/Tests/HarkCoreTests/SanitizeTests.swift | 28 ++ swift/Tests/HarkCoreTests/ServerTests.swift | 115 ++++++ swift/Tests/HarkCoreTests/TestSupport.swift | 92 +++++ swift/Tests/HarkCoreTests/WAVTests.swift | 87 +++++ 26 files changed, 2439 insertions(+) create mode 100644 swift/Package.swift create mode 100644 swift/Packaging/Info.plist create mode 100644 swift/Packaging/Makefile create mode 100755 swift/Packaging/build-app.sh create mode 100644 swift/Packaging/entitlements.plist create mode 100644 swift/Sources/HarkCore/Config.swift create mode 100644 swift/Sources/HarkCore/DictateClient.swift create mode 100644 swift/Sources/HarkCore/HarkServer.swift create mode 100644 swift/Sources/HarkCore/KeyFile.swift create mode 100644 swift/Sources/HarkCore/Sanitize.swift create mode 100644 swift/Sources/HarkCore/WAV.swift create mode 100644 swift/Sources/HarkCore/WhisperClient.swift create mode 100644 swift/Sources/hark/AgentController.swift create mode 100644 swift/Sources/hark/Hotkey.swift create mode 100644 swift/Sources/hark/Recorder.swift create mode 100644 swift/Sources/hark/main.swift create mode 100644 swift/Tests/HarkCoreTests/ConfigTests.swift create mode 100644 swift/Tests/HarkCoreTests/DictateClientTests.swift create mode 100644 swift/Tests/HarkCoreTests/E2ETests.swift create mode 100644 swift/Tests/HarkCoreTests/IntegrationTests.swift create mode 100644 swift/Tests/HarkCoreTests/KeyFileTests.swift create mode 100644 swift/Tests/HarkCoreTests/SanitizeTests.swift create mode 100644 swift/Tests/HarkCoreTests/ServerTests.swift create mode 100644 swift/Tests/HarkCoreTests/TestSupport.swift create mode 100644 swift/Tests/HarkCoreTests/WAVTests.swift diff --git a/.gitignore b/.gitignore index 6d3dd88..e9fd7d5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ config.toml # Local migration notes, not part of the project. LAPTOP-MIGRATION.local.md + +# SwiftPM build artifacts (the Swift rewrite). +swift/.build/ + +# Generated app bundle (rebuilt by swift/Packaging/build-app.sh). +swift/Packaging/Hark.app/ diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000..a6b03e2 --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Hark", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "hark", targets: ["hark"]) + ], + targets: [ + // Pure / server-logic core: config, key, sanitize, wav, server, whisper. + .target(name: "HarkCore"), + // macOS agent: hotkey, recorder, dictate client, controller. Kept out + // of HarkCore so tests run headless in CI (no TCC, no hardware). + .executableTarget(name: "hark", dependencies: ["HarkCore"]), + .testTarget(name: "HarkCoreTests", dependencies: ["HarkCore"]), + ] +) diff --git a/swift/Packaging/Info.plist b/swift/Packaging/Info.plist new file mode 100644 index 0000000..ebf276b --- /dev/null +++ b/swift/Packaging/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleIdentifier + com.drycodeworks.hark-agent + CFBundleName + Hark + CFBundleExecutable + hark + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSMicrophoneUsageDescription + Hark records your voice while you hold the dictate hotkey, to transcribe it. + NSLocalNetworkUsageDescription + Hark talks to the transcription server on your local network. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/swift/Packaging/Makefile b/swift/Packaging/Makefile new file mode 100644 index 0000000..49612c8 --- /dev/null +++ b/swift/Packaging/Makefile @@ -0,0 +1,34 @@ +# Makefile for Hark — build, package, sign, verify, clean, test. +# Rules use tabs (this file is written with tab-indented recipe lines). + +PKG_DIR := Packaging +APP := $(PKG_DIR)/Hark.app + +.PHONY: build app sign verify clean test + +## build: compile the release binary +build: + swift build -c release + +## app: run the packaging script (build release + assemble + sign Hark.app) +app: + ./$(PKG_DIR)/build-app.sh + +## sign: re-sign the assembled bundle (ad-hoc unless HARK_SIGN_IDENTITY is set) +sign: + codesign --force --deep --sign "$${HARK_SIGN_IDENTITY:--}" \ + --options runtime \ + --entitlements $(PKG_DIR)/entitlements.plist \ + $(APP) + +## verify: codesign verification of the bundle +verify: + codesign --verify --deep --strict --verbose=2 $(APP) + +## clean: remove build products and the assembled bundle +clean: + rm -rf .build $(APP) + +## test: run the test suite +test: + swift test diff --git a/swift/Packaging/build-app.sh b/swift/Packaging/build-app.sh new file mode 100755 index 0000000..c1a49c7 --- /dev/null +++ b/swift/Packaging/build-app.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# build-app.sh — build the release binary and assemble a signed Hark.app bundle. +# +# ./Packaging/build-app.sh +# +# Signing identity comes from $HARK_SIGN_IDENTITY if set (e.g. a Developer ID), +# otherwise ad-hoc ("-") is used. Default is ad-hoc — fine for local dev. +set -euo pipefail + +PKG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${PKG_DIR}/.." && pwd)" +APP="${PKG_DIR}/Hark.app" + +SIGN_IDENTITY="${HARK_SIGN_IDENTITY:-}" + +echo "==> Building release binary" +cd "${ROOT_DIR}" +swift build -c release + +echo "==> Assembling ${APP}" +rm -rf "${APP}" +mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources" + +cp ".build/release/hark" "${APP}/Contents/MacOS/hark" +cp "${PKG_DIR}/Info.plist" "${APP}/Contents/Info.plist" +cp "${PKG_DIR}/entitlements.plist" "${APP}/Contents/Resources/" +chmod +x "${APP}/Contents/MacOS/hark" + +echo "==> Signing" +if [[ -n "${SIGN_IDENTITY}" ]]; then + echo " using identity: ${SIGN_IDENTITY}" + codesign --force --deep --sign "${SIGN_IDENTITY}" \ + --options runtime \ + --entitlements "${PKG_DIR}/entitlements.plist" \ + "${APP}" +else + echo " using ad-hoc signature (-)" + codesign --force --deep --sign - \ + --options runtime \ + --entitlements "${PKG_DIR}/entitlements.plist" \ + "${APP}" +fi + +echo "==> Verifying" +codesign --verify --deep --strict --verbose=2 "${APP}" +echo "==> Entitlements embedded in binary:" +codesign -d --entitlements :- "${APP}" 2>/dev/null || true +echo "==> done. Bundle at: ${APP}" diff --git a/swift/Packaging/entitlements.plist b/swift/Packaging/entitlements.plist new file mode 100644 index 0000000..bd7a79c --- /dev/null +++ b/swift/Packaging/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.device.audio-input + + com.apple.security.automation.apple-events + + com.apple.security.cs.allow-jit + + + diff --git a/swift/Sources/HarkCore/Config.swift b/swift/Sources/HarkCore/Config.swift new file mode 100644 index 0000000..6606540 --- /dev/null +++ b/swift/Sources/HarkCore/Config.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Server deployment configuration — a port of `src/hark/config.py`. +/// +/// Defaults describe the single-machine setup: bind to loopback, expose +/// nothing. `~/.config/hark/config.toml` (outside the repo) overrides, exactly +/// as it does for the Python server. A missing file is the ordinary case; a +/// malformed one raises rather than silently falling back to defaults (that +/// could bind the service somewhere the user did not ask for). +public struct HarkConfig { + public var bindHost: String // server.bind, default 127.0.0.1 + public var harkPort: Int // server.port, default 8911 + public var whisperHost: String // always 127.0.0.1 (not configurable) + public var whisperPort: Int // whisper.port, default 8910 + public var modelPath: String // whisper.model + public var vocabPrompt: String // whisper.prompt + public var silenceRMSThreshold: Double // audio.silence_rms_threshold, default 150.0 + public var transcribeTimeout: Double // 60 + public var connectTimeout: Double // 5 + + public init(bindHost: String = "127.0.0.1", + harkPort: Int = 8911, + whisperPort: Int = 8910, + modelPath: String = "~/.local/share/whisper-cpp/ggml-large-v3-turbo.bin", + vocabPrompt: String = "", + silenceRMSThreshold: Double = 150.0, + transcribeTimeout: Double = 60.0, + connectTimeout: Double = 5.0) { + self.bindHost = bindHost + self.harkPort = harkPort + self.whisperHost = "127.0.0.1" + self.whisperPort = whisperPort + self.modelPath = modelPath + self.vocabPrompt = vocabPrompt + self.silenceRMSThreshold = silenceRMSThreshold + self.transcribeTimeout = transcribeTimeout + self.connectTimeout = connectTimeout + } + + public static var configFile: URL { + if let env = ProcessInfo.processInfo.environment["HARK_CONFIG"] { + return URL(fileURLWithPath: env) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/hark/config.toml") + } + + public static func load() -> HarkConfig { + var cfg = HarkConfig() + guard let text = try? String(contentsOf: configFile, encoding: .utf8) else { + return cfg + } + do { + let parsed = try MiniTOML.parse(text) + if let s = parsed.section("server") { + cfg.bindHost = s.string("bind") ?? cfg.bindHost + cfg.harkPort = s.int("port") ?? cfg.harkPort + } + if let w = parsed.section("whisper") { + cfg.whisperPort = w.int("port") ?? cfg.whisperPort + cfg.modelPath = w.string("model") ?? cfg.modelPath + cfg.vocabPrompt = w.string("prompt") ?? cfg.vocabPrompt + } + if let a = parsed.section("audio") { + cfg.silenceRMSThreshold = a.double("silence_rms_threshold") ?? cfg.silenceRMSThreshold + } + } catch { + fatalError("malformed hark config at \(configFile.path): \(error)") + } + return cfg + } + + public var whisperURL: String { "http://\(whisperHost):\(whisperPort)" } +} + +// MARK: - Minimal TOML subset parser + +/// Parses just the shape `config.example.toml` uses: `[section]` headers, +/// `key = value` pairs (string / integer / float / boolean), `#` comments. +/// Unknown keys and sections are ignored so a config written for the Python +/// server keeps working. Values that do not parse raise, never silently drop. +struct MiniTOML { + struct Section { + let name: String + var values: [String: String] = [:] + func string(_ key: String) -> String? { values[key] } + func int(_ key: String) -> Int? { + guard let v = values[key], let i = Int(v) else { return nil } + return i + } + func double(_ key: String) -> Double? { + guard let v = values[key] else { return nil } + if let i = Int(v) { return Double(i) } + return Double(v) + } + } + var sections: [String: Section] = [:] + + func section(_ name: String) -> Section? { sections[name] } + + static func parse(_ text: String) throws -> MiniTOML { + var result = MiniTOML() + var current: String = "" + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + var line = String(rawLine) + if let hash = line.firstIndex(of: "#") { line = String(line[..= 2 { + value = String(value.dropFirst().dropLast()) + } + result.sections[current, default: Section(name: current)].values[key] = value + } + return result + } +} + +enum TOMLError: Error, CustomStringConvertible { + case malformed(String) + var description: String { "malformed TOML line: \(self)" } +} diff --git a/swift/Sources/HarkCore/DictateClient.swift b/swift/Sources/HarkCore/DictateClient.swift new file mode 100644 index 0000000..13f6bf3 --- /dev/null +++ b/swift/Sources/HarkCore/DictateClient.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Agent-side dictation client: POST a WAV to the server's /dictate and get +/// back sanitised text. Ports the response handling of `client/init.lua` and +/// the bounds/transport rules from the native-client design. +/// +/// Every response path is bounded and sanitised, not just the 200. The error +/// `detail` string is just as attacker-controlled as a transcript — it reaches +/// an alert rather than the pasteboard, but it must still be capped and cleaned. +public enum DictateError: Error, Equatable { + case transport(String) // connection / DNS / timeout + case unauthorized(String) // 401 + case unsupportedMediaType(String) // 415 + case badRequest(String) // 400 + case serviceUnavailable(String) // 503 + case unexpected(Int, String) // any other status + case malformedResponse + case bodyTooLarge + + public var isTransport: Bool { + if case .transport = self { return true } + return false + } +} + +public enum DictateOutcome: Equatable { + case pasted(String) // non-empty sanitised text + case nothing // 200 with empty / no-alphanumeric text + case failed(DictateError) +} + +public final class DictateClient: NSObject, URLSessionDataDelegate { + private let url: URL + private let key: String + private let maxBodyBytes: Int + private var session: URLSession! // set in init after super.init() (needs self as delegate) + + // Per-request state. + private var accumulated = Data() + private var response: HTTPURLResponse? + private var completion: ((Result<(Int, Data), DictateError>) -> Void)? + private var large = false + + public init(url: URL, key: String, maxBodyBytes: Int = 1 << 20, + configuration: URLSessionConfiguration? = nil) { + self.url = url + self.key = key + self.maxBodyBytes = maxBodyBytes + let cfg: URLSessionConfiguration + if let configuration { + cfg = configuration + } else { + cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 30 + cfg.timeoutIntervalForResource = 30 + } + super.init() + // NOTE: must pass `delegate: self` here — URLSession(configuration:) + // alone does NOT deliver delegate callbacks, which would leave the + // semaphore wait in upload() blocked forever. + self.session = URLSession(configuration: cfg, delegate: self, delegateQueue: nil) + self.session.sessionDescription = "hark-dictate" + } + + /// Result of a dictation attempt. + public func dictate(wav: Data) -> DictateOutcome { + switch upload(wav: wav) { + case .failure(let e): + return .failed(e) + case .success(let pair): + let (http, body) = pair + return classify(http: http, body: body) + } + } + + // MARK: - Upload with a hard body bound + + private func upload(wav: Data) -> Result<(Int, Data), DictateError> { + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.httpBody = wav + req.setValue(key, forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + + // Synchronous bridge: the data task runs on URLSession's own queues, so + // blocking this caller on a semaphore is safe for the single-request + // concurrency this client is used for. + let sem = DispatchSemaphore(value: 0) + var result: Result<(Int, Data), DictateError> = .failure(.transport("no result")) + accumulated = Data() + response = nil + large = false + completion = { res in + result = res + sem.signal() + } + session.dataTask(with: req).resume() + sem.wait() + completion = nil + return result + } + + // MARK: - URLSessionDataDelegate + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) { + accumulated.append(data) + if accumulated.count > maxBodyBytes { + large = true + dataTask.cancel() + } + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + self.response = response as? HTTPURLResponse + completionHandler(.allow) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let completion else { return } + self.completion = nil + if large { + completion(.failure(.bodyTooLarge)) + return + } + if let error { + completion(.failure(.transport(error.localizedDescription))) + return + } + guard let resp = self.response else { + completion(.failure(.transport("no HTTP response"))) + return + } + completion(.success((resp.statusCode, accumulated))) + } + + // MARK: - Response classification + + private func classify(http: Int, body: Data) -> DictateOutcome { + if http == 200 { + guard let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let text = obj["text"] as? String else { + return .failed(.malformedResponse) + } + let sanitised = Sanitize.sanitize(text) + if sanitised.isEmpty { return .nothing } + return .pasted(sanitised) + } + + let detail = extractDetail(body) + switch http { + case 401: return .failed(.unauthorized(detail)) + case 415: return .failed(.unsupportedMediaType(detail)) + case 400: return .failed(.badRequest(detail)) + case 503: return .failed(.serviceUnavailable(detail)) + default: return .failed(.unexpected(http, detail)) + } + } + + private func extractDetail(_ body: Data) -> String { + let raw = String(decoding: body, as: UTF8.self) + guard let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let detail = obj["detail"] as? String else { + return raw + } + return Sanitize.sanitize(detail) + } +} diff --git a/swift/Sources/HarkCore/HarkServer.swift b/swift/Sources/HarkCore/HarkServer.swift new file mode 100644 index 0000000..f9b2d52 --- /dev/null +++ b/swift/Sources/HarkCore/HarkServer.swift @@ -0,0 +1,359 @@ +import Foundation +import Network + +/// The hark service: audio in, transcript out. A stdlib HTTP/1.1 server on the +/// loopback (or a configured private address), replacing FastAPI + uvicorn in +/// `src/hark/app.py` (issue #4). One user, one request at a time — async buys +/// nothing here, so the server is deliberately simple. +/// +/// Deliberately contains no business logic — sanitisation and ASR live in +/// `Sanitize` and `WhisperClient`, imported by name so tests can fake them. +/// The server does not inject the transcript anywhere; it returns it in the +/// response and the client pastes it at the cursor. +public enum HarkServerError: Error, CustomStringConvertible { + case bindFailed(String) + public var description: String { + switch self { + case .bindFailed(let m): return m + } + } +} + +public struct HTTPRequest { + public let method: String + public let target: String + public let headers: [String: String] // lower-cased keys + public let body: Data +} + +public struct HTTPResponse { + public let status: Int + public let contentType: String + public let body: Data + + public var serialized: Data { + let reason = Self.reason(for: status) + var head = "HTTP/1.1 \(status) \(reason)\r\n" + head += "Content-Type: \(contentType)\r\n" + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n" + head += "\r\n" + var out = Data(head.utf8) + out.append(body) + return out + } + + static func reason(for status: Int) -> String { + switch status { + case 200: return "OK" + case 400: return "Bad Request" + case 401: return "Unauthorized" + case 415: return "Unsupported Media Type" + case 500: return "Internal Server Error" + case 503: return "Service Unavailable" + default: return "Status" + } + } +} + +public protocol WhisperTranscribing { + func transcribe(wav: Data) async throws -> String +} + +extension WhisperClient: WhisperTranscribing {} + +public final class HarkServer { + let config: HarkConfig + let key: String + let whisper: any WhisperTranscribing + let logger: Logger + + public init(config: HarkConfig = .load(), + key: String = KeyFile.ensure(), + whisper: (any WhisperTranscribing)? = nil, + logger: Logger = Logger(label: "hark")) { + self.config = config + self.key = key + self.whisper = whisper ?? WhisperClient(baseURL: config.whisperURL, + connectTimeout: config.connectTimeout, + transcribeTimeout: config.transcribeTimeout) + self.logger = logger + } + + /// Bind and start serving on a port (default: `config.harkPort`; pass a + /// port to override, e.g. for tests). Non-blocking — returns the live + /// listener so callers (or tests) can cancel it. The connection handler is + /// installed immediately; `serve()` just adds the blocking runloop. + @discardableResult + public func start(port: UInt16? = nil) throws -> NWListener { + let portNumber = port ?? UInt16(config.harkPort) + guard let p = NWEndpoint.Port(rawValue: portNumber) else { + throw HarkServerError.bindFailed("invalid port \(portNumber)") + } + let listener = try NWListener(using: .tcp, on: p) + listener.newConnectionHandler = { [weak self] conn in + self?.handle(conn) + } + listener.stateUpdateHandler = { [logger] state in + if case .failed(let e) = state { logger.error("listener failed: \(e)") } + } + listener.start(queue: .global(qos: .userInitiated)) + logger.info("hark listening on \(config.bindHost):\(portNumber)") + return listener + } + + /// Run until the process is terminated. + public func serve() throws { + _ = try start() + dispatchMain() + } + + // MARK: - Connection handling + + private func handle(_ conn: NWConnection) { + conn.stateUpdateHandler = { [weak self, weak conn] state in + guard let self, let conn else { return } + if case .ready = state { + self.receiveLoop(conn, buffer: Data()) + } + } + conn.start(queue: .global(qos: .default)) + } + + private func receiveLoop(_ conn: NWConnection, buffer: Data) { + conn.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { [weak self, weak conn] data, _, isComplete, error in + guard let conn else { return } + var buf = buffer + if let data, !data.isEmpty { buf.append(data) } + + if error != nil || isComplete { + conn.cancel() + return + } + + guard let self else { return } + // Try to complete a request with whatever we have. + do { + let (request, consumed) = try HTTPParser.parseComplete(from: buf) + let response = self.dispatch(request) + conn.send(content: response.serialized, completion: .contentProcessed { _ in + conn.cancel() + }) + _ = consumed + } catch HTTPParseError.incomplete { + // Need more data. + self.receiveLoop(conn, buffer: buf) + } catch { + // Unparseable request: 400. + let resp = HTTPResponse(status: 400, contentType: "application/json", + body: Data("{\"detail\":\"bad request\"}".utf8)) + conn.send(content: resp.serialized, completion: .contentProcessed { _ in + conn.cancel() + }) + } + } + } + + // MARK: - Routing + + func dispatch(_ request: HTTPRequest) -> HTTPResponse { + switch (request.method, request.target) { + case ("GET", "/health"): + return HTTPResponse(status: 200, contentType: "application/json", + body: Data("{\"status\":\"ok\"}".utf8)) + case ("POST", "/dictate"): + return dictate(request) + default: + return jsonError(404, "not found") + } + } + + private func dictate(_ request: HTTPRequest) -> HTTPResponse { + // Authorisation: both checks matter, and each independently defeats the + // drive-by CSRF (a CORS-simple request needs no preflight). X-Hark-Key + // and Content-Type audio/wav are both non-safelisted, so requiring them + // forces a preflight, which fails — no CORS middleware is installed. + let presented = request.headers["x-hark-key"] ?? "" + guard ConstantTime.equal(presented, key) else { + logger.warning("rejected unauthenticated POST /dictate") + return jsonError(401, "missing or invalid X-Hark-Key") + } + + // Ignore parameters: `audio/wav; charset=binary` is still audio/wav. + let mediaType = (request.headers["content-type"] ?? "") + .split(separator: ";").first? + .trimmingCharacters(in: .whitespaces) + .lowercased() ?? "" + guard mediaType == "audio/wav" else { + logger.warning("rejected POST /dictate with content-type \(mediaType)") + return jsonError(415, "expected Content-Type: audio/wav") + } + + // Parse and validate the wire format: 16 kHz mono 16-bit PCM. + let info: WAVInfo + do { + info = try WAV.parse(request.body) + } catch let e as WAVError { + logger.warning("rejected audio: \(e.description)") + return jsonError(400, formatDetail(for: e)) + } catch { + return jsonError(400, formatDetail(for: WAVError.notReadable("\(error)"))) + } + guard info.sampleWidth == 2 else { + return jsonError(400, formatDetail(for: .unsupportedSampleWidth(info.sampleWidth))) + } + guard info.channelCount == 1 else { + return jsonError(400, "expected mono audio, got \(info.channelCount) channels") + } + guard info.sampleRate == 16000 else { + return jsonError(400, "expected 16 kHz audio, got \(info.sampleRate) Hz") + } + + // Whisper hallucinates on silence, so it must be caught on the AUDIO. + let amplitude = WAV.rmsPCM(info.data) + if amplitude < config.silenceRMSThreshold { + logger.info("silent audio (rms \(String(format: "%.1f", amplitude)) < \(String(format: "%.1f", config.silenceRMSThreshold))); returning empty transcript") + return jsonText("") + } + + let raw = sync { + try await self.whisper.transcribe(wav: request.body) + } + let text: String + do { + text = try raw.get() + } catch { + logger.error("whisper-server unavailable: \(error)") + return jsonError(503, "\(error)") + } + + let sanitised = Sanitize.sanitize(text) + if !hasAlphanumeric(sanitised) { + logger.info("transcript has no alphanumerics; returning empty transcript") + return jsonText("") + } + + // Log the LENGTH only, never the text — transcripts are private and + // must never settle into a world-readable file in /tmp. + logger.info("transcribed \(sanitised.count) chars (rms \(String(format: "%.1f", amplitude)), threshold \(String(format: "%.1f", config.silenceRMSThreshold)))") + return jsonText(sanitised) + } + + /// The server's error detail must be branched by cause so a malformed + /// header from the client does not send users to Microphone settings. + private func formatDetail(for e: WAVError) -> String { + switch e { + case .emptyBody: + return e.description + ". Check that the client has microphone permission and is sending 16 kHz mono 16-bit PCM WAV." + default: + return e.description + ". Expected 16 kHz mono 16-bit PCM WAV." + } + } + + private func hasAlphanumeric(_ s: String) -> Bool { + s.unicodeScalars.contains { CharacterSet.alphanumerics.contains($0) } + } + + private func jsonText(_ text: String) -> HTTPResponse { + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let body = "{\"text\":\"\(escaped)\"}" + return HTTPResponse(status: 200, contentType: "application/json", body: Data(body.utf8)) + } + + private func jsonError(_ status: Int, _ detail: String) -> HTTPResponse { + let escaped = detail + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let body = "{\"detail\":\"\(escaped)\"}" + return HTTPResponse(status: status, contentType: "application/json", body: Data(body.utf8)) + } + + /// Bridge an async call into the sync connection handler. The URLSession + /// work runs on its own queues so blocking this worker thread is fine for + /// the single-user concurrency this server is built for. + private func sync(_ body: @escaping () async throws -> T) -> Result { + let sem = DispatchSemaphore(value: 0) + var result: Result = .failure(HarkServerError.bindFailed("unreached")) + Task { + do { result = .success(try await body()) } + catch { result = .failure(error) } + sem.signal() + } + sem.wait() + return result + } +} + +// MARK: - Logging shim (kept dependency-free) + +public struct Logger { + let label: String + func timestamp() -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f.string(from: Date()) + } + public init(label: String) { self.label = label } + func write(_ level: String, _ msg: String) { + let line = "\(timestamp()) \(level) \(label): \(msg)\n" + FileHandle.standardOutput.write(Data(line.utf8)) + } + func info(_ msg: String) { write("INFO", msg) } + func warning(_ msg: String) { write("WARNING", msg) } + func error(_ msg: String) { write("ERROR", msg) } +} + +enum ConstantTime { + static func equal(_ a: String, _ b: String) -> Bool { + let aa = Array(a.utf8), bb = Array(b.utf8) + guard aa.count == bb.count else { return false } + var diff: UInt8 = 0 + for i in 0.. (HTTPRequest, Int) { + guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { + throw HTTPParseError.incomplete + } + let headerData = data.subdata(in: data.startIndex..= 2 else { throw HTTPParseError.malformed } + let method = String(parts[0]) + let target = String(parts[1]) + + var headers: [String: String] = [:] + for line in lines { + guard let colon = line.firstIndex(of: ":") else { continue } + let k = line[..= contentLength else { throw HTTPParseError.incomplete } + + let body = data.subdata(in: bodyStart..<(bodyStart + contentLength)) + return (HTTPRequest(method: method, target: target, headers: headers, body: body), + bodyStart + contentLength) + } +} diff --git a/swift/Sources/HarkCore/KeyFile.swift b/swift/Sources/HarkCore/KeyFile.swift new file mode 100644 index 0000000..484def6 --- /dev/null +++ b/swift/Sources/HarkCore/KeyFile.swift @@ -0,0 +1,67 @@ +import Foundation + +/// The shared secret gating POST /dictate. Lives at `~/.config/hark/key`, +/// mode 600, generated on first server use. Read and trimmed by the client. +/// +/// Deliberately not a credential system: one user, one key, one file. On the +/// server the key is generated on first use and persisted so the client can be +/// configured once by reading the file. The file lives outside the repo and is +/// never committed. +public enum KeyFile { + /// Test-only override so tests don't touch the real `~/.config/hark/key`. + public static var pathOverride: URL? + + public static var path: URL { + if let override = pathOverride { + return override + } + if let env = ProcessInfo.processInfo.environment["HARK_KEY_FILE"] { + return URL(fileURLWithPath: env) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/hark/key") + } + + /// Read and trim the key, or nil if absent/unreadable. A key from the + /// environment wins over the file, mirroring `config.py`'s `HARK_KEY`. + public static func load() -> String? { + if let env = ProcessInfo.processInfo.environment["HARK_KEY"] { + let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + guard let data = try? Data(contentsOf: path) else { return nil } + let trimmed = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Generate and persist a key if absent. Directory mode 700, file mode 600 + /// with an exclusive create (a concurrent request cannot clobber ours). + /// Returns the key. + @discardableResult + public static func ensure() -> String { + if let existing = load() { return existing } + let dir = path.deletingLastPathComponent() + try? FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let key = generateKey() + let created = FileManager.default.createFile( + atPath: path.path, contents: Data((key + "\n").utf8), + attributes: [.posixPermissions: 0o600]) + if created { return key } + // Lost the race to a concurrent writer; use its key, not ours. + return load() ?? key + } + + static func generateKey() -> String { + // 32 bytes of urandom → URL-safe base64 (≈ 43 chars), like Python's + // secrets.token_urlsafe(32). + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/swift/Sources/HarkCore/Sanitize.swift b/swift/Sources/HarkCore/Sanitize.swift new file mode 100644 index 0000000..b3ecc69 --- /dev/null +++ b/swift/Sources/HarkCore/Sanitize.swift @@ -0,0 +1,45 @@ +/// Transcript sanitisation — a faithful port of `src/hark/sanitize.py`. +/// +/// Dictation never wants a literal newline. Collapsing whitespace is what +/// makes it structurally impossible for a transcript to submit a prompt +/// early, rather than relying on bracketed paste to save us. +/// +/// Control characters (C0 0x00-0x08,0x0B,0x0C,0x0E-0x1F, 0x7F and C1 +/// 0x80-0x9F, which includes the 8-bit single-byte equivalents of ESC-prefixed +/// sequences like CSI/OSC/DCS) are replaced with a space rather than deleted, +/// so a stray control character cannot smuggle an escape sequence into the +/// receiving application. Substituting instead of deleting also means two +/// words separated only by a control character become two space-separated +/// words after collapsing, not one fused word. +public enum Sanitize { + /// Roughly Python's `[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]`. + static func isControl(_ s: Unicode.Scalar) -> Bool { + let v = s.value + if v <= 0x08 { return true } + if v == 0x0B || v == 0x0C { return true } + if (0x0E...0x1F).contains(v) { return true } + if (0x7F...0x9F).contains(v) { return true } + return false + } + + /// Unicode line/paragraph separators, also hostile to a terminal. + static func isLineSeparator(_ s: Unicode.Scalar) -> Bool { + s.value == 0x2028 || s.value == 0x2029 + } + + /// Replace control characters and Unicode line separators with a space, + /// collapse all remaining whitespace runs to a single space, and trim. + public static func sanitize(_ raw: String) -> String { + var out = String.UnicodeScalarView() + for s in raw.unicodeScalars { + if isControl(s) || isLineSeparator(s) { + out.append(" ") + } else { + out.append(s) + } + } + return String(out) + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + } +} diff --git a/swift/Sources/HarkCore/WAV.swift b/swift/Sources/HarkCore/WAV.swift new file mode 100644 index 0000000..b2ed0f9 --- /dev/null +++ b/swift/Sources/HarkCore/WAV.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Audio energy measurement + WAV parsing — a port of `src/hark/audio.py`. +/// +/// Whisper hallucinates on silence (digital silence -- " Thank you.", faint +/// noise -- " ."), so the gate has to sit on the AUDIO, before transcription. +/// The server measures RMS and refuses to send quiet audio to whisper at all. +/// +/// The documented wire format is **16 kHz, mono, 16-bit signed PCM, one `data` +/// chunk**. The threshold in Config is calibrated on the signed-16-bit scale, +/// so any other sample width would be silently mis-scaled against it — refuse +/// loudly instead. +public enum WAVError: Error, Equatable { + case emptyBody + case notReadable(String) + case unsupportedSampleWidth(Int) + + public var description: String { + switch self { + case .emptyBody: + return "empty audio body - the microphone produced no samples" + case .notReadable(let e): + return "not a readable WAV: \(e)" + case .unsupportedSampleWidth(let w): + return "expected 16-bit PCM samples, got \(w * 8)-bit" + } + } +} + +public struct WAVInfo: Equatable { + public let sampleWidth: Int + public let channelCount: Int + public let sampleRate: Int + public let data: Data + public init(sampleWidth: Int, channelCount: Int, sampleRate: Int, data: Data) { + self.sampleWidth = sampleWidth + self.channelCount = channelCount + self.sampleRate = sampleRate + self.data = data + } +} + +public enum WAV { + static let supportedSampleWidth = 2 + static let supportedChannelCount = 1 + static let supportedSampleRate = 16000 + + public static func parse(_ d: Data) throws -> WAVInfo { + guard !d.isEmpty else { throw WAVError.emptyBody } + guard d.count >= 44 else { throw WAVError.notReadable("header too short") } + guard ascii(d, 0) == "RIFF", ascii(d, 8) == "WAVE" else { + throw WAVError.notReadable("missing RIFF/WAVE markers") + } + + var sampleWidth = 0, channelCount = 0, sampleRate = 0 + var foundFormat = false + var foundData: Data? + var offset = 12 + while offset + 8 <= d.count { + let chunkID = ascii(d, offset) + let size = UInt64(leUInt32(d, offset + 4)) + let chunkStart = offset + 8 + guard Int64(chunkStart) + Int64(size) <= Int64(d.count), size <= Int(INT32_MAX) else { + throw WAVError.notReadable("truncated or oversized chunk '\(chunkID)'") + } + if chunkID == "fmt " { + guard size >= 16 else { throw WAVError.notReadable("fmt chunk too small") } + let format = leUInt16(d, chunkStart) + guard format == 1 else { throw WAVError.notReadable("not PCM (audio format \(format))") } + channelCount = Int(leUInt16(d, chunkStart + 2)) + sampleRate = Int(leUInt32(d, chunkStart + 4)) + // bitsPerSample → bytes (16 → 2), matching Python's getsampwidth(). + sampleWidth = Int(leUInt16(d, chunkStart + 14)) / 8 + foundFormat = true + } else if chunkID == "data" { + foundData = d.subdata(in: chunkStart..<(chunkStart + Int(size))) + } + // Chunks are word-aligned (padded to even length). + offset = chunkStart + Int(size) + (Int(size) % 2) + } + guard foundFormat else { throw WAVError.notReadable("missing fmt chunk") } + guard let foundData else { throw WAVError.notReadable("missing data chunk") } + return WAVInfo(sampleWidth: sampleWidth, channelCount: channelCount, + sampleRate: sampleRate, data: foundData) + } + + /// RMS of raw signed-16-bit little-endian PCM samples. 0.0 for digital + /// silence; roughly 3000-5000 for normal speech. + public static func rmsPCM(_ pcm: Data) -> Double { + let bytes = [UInt8](pcm) + let usable = bytes.count - (bytes.count % 2) + guard usable > 0 else { return 0 } + var sum: Double = 0 + var count = 0 + var i = 0 + while i + 1 < usable { + let low = UInt16(bytes[i]) + let high = UInt16(bytes[i + 1]) + let u: UInt16 = low | (high << 8) + let s = Int16(bitPattern: u) // already little-endian on the wire + let v = Int32(s) + sum += Double(v) * Double(v) + count += 1 + i += 2 + } + guard count > 0 else { return 0 } + return (sum / Double(count)).squareRoot() + } + + /// Root-mean-square of a WAV's PCM samples. 0.0 for digital silence. + public static func rms(_ d: Data) throws -> Double { + let info = try parse(d) + guard info.sampleWidth == supportedSampleWidth else { + throw WAVError.unsupportedSampleWidth(info.sampleWidth) + } + return rmsPCM(info.data) + } + + /// Build a 44-byte canonical WAV header for 16 kHz mono 16-bit PCM, with + /// data- and RIFF-size placeholders the caller back-patches. + public static func header16kMono16Bit(dataByteCount: Int) -> Data { + var out = Data(capacity: 44) + func appendASCII(_ s: String) { out.append(Data(s.utf8)) } + func appendLE16(_ v: Int) { out.append(UInt8(v & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) } + func appendLE32(_ v: Int) { + out.append(UInt8((v) & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) + out.append(UInt8((v >> 16) & 0xFF)); out.append(UInt8((v >> 24) & 0xFF)) + } + let blockAlign = 2 // mono s16 + let byteRate = 16000 * blockAlign + appendASCII("RIFF"); appendLE32(36 + dataByteCount); appendASCII("WAVE") + appendASCII("fmt "); appendLE32(16) + appendLE16(1) // PCM + appendLE16(supportedChannelCount) + appendLE32(supportedSampleRate) + appendLE32(byteRate) + appendLE16(blockAlign) + appendLE16(16) // bits per sample + appendASCII("data"); appendLE32(dataByteCount) + return out + } + + static func ascii(_ d: Data, _ o: Int) -> String { + guard o + 4 <= d.count else { return "" } + return String(decoding: d.subdata(in: o..<(o + 4)), as: UTF8.self) + } + static func leUInt16(_ d: Data, _ o: Int) -> UInt16 { + guard o + 2 <= d.count else { return 0 } + return UInt16(d[o]) | (UInt16(d[o + 1]) << 8) + } + static func leUInt32(_ d: Data, _ o: Int) -> UInt32 { + guard o + 4 <= d.count else { return 0 } + return UInt32(d[o]) | (UInt32(d[o + 1]) << 8) | (UInt32(d[o + 2]) << 16) | (UInt32(d[o + 3]) << 24) + } +} diff --git a/swift/Sources/HarkCore/WhisperClient.swift b/swift/Sources/HarkCore/WhisperClient.swift new file mode 100644 index 0000000..61153aa --- /dev/null +++ b/swift/Sources/HarkCore/WhisperClient.swift @@ -0,0 +1,88 @@ +import Foundation + +/// HTTP client for whisper.cpp's whisper-server, port of `src/hark/whisper.py`. +/// +/// The server holds the model resident; a fresh `whisper-cli` per utterance +/// would reload ~1.5 GB every time. Vocabulary biasing is applied at server +/// startup via `--prompt`, not per request. +public enum WhisperError: Error, CustomStringConvertible { + case unavailable(String) + case malformed(String) + + public var description: String { + switch self { + case .unavailable(let m): return m + case .malformed(let m): return "malformed response from whisper-server: \(m)" + } + } +} + +public struct WhisperClient { + public let baseURL: String + public let connectTimeout: Double + public let transcribeTimeout: Double + + public init(baseURL: String, connectTimeout: Double = 5.0, transcribeTimeout: Double = 60.0) { + self.baseURL = baseURL + self.connectTimeout = connectTimeout + self.transcribeTimeout = transcribeTimeout + } + + /// POST a WAV to `/inference` as multipart/form-data and return the text. + /// The SQLite-free hand-rolled multipart is the point of the stdlib build + /// (issue #4): outbound multipart is one of the few things a library got + /// right that we now own. + public func transcribe(wav: Data) async throws -> String { + let boundary = "HarkBoundary\(UUID().uuidString)" + let body = Self.makeMultipart(boundary: boundary, wav: wav) + + var req = URLRequest(url: URL(string: baseURL + "/inference")!) + req.httpMethod = "POST" + req.httpBody = body + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = transcribeTimeout + config.timeoutIntervalForResource = transcribeTimeout + + do { + let (data, response) = try await URLSession(configuration: config).data(for: req) + guard let http = response as? HTTPURLResponse else { + throw WhisperError.unavailable("no HTTP response from whisper-server") + } + guard (200..<300).contains(http.statusCode) else { + throw WhisperError.unavailable("whisper-server returned HTTP \(http.statusCode)") + } + guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let text = obj["text"] as? String else { + throw WhisperError.malformed( + String(decoding: data.prefix(512), as: UTF8.self)) + } + return text + } catch let e as WhisperError { + throw e + } catch { + throw WhisperError.unavailable("\(error)") + } + } + + static func makeMultipart(boundary: String, wav: Data) -> Data { + var body = Data() + func appendASCII(_ s: String) { body.append(Data(s.utf8)) } + + appendASCII("--\(boundary)\r\n") + appendASCII("Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n") + appendASCII("Content-Type: audio/wav\r\n\r\n") + body.append(wav) + appendASCII("\r\n") + + for (name, value) in [("response_format", "json"), ("temperature", "0.0")] { + appendASCII("--\(boundary)\r\n") + appendASCII("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + appendASCII(value) + appendASCII("\r\n") + } + appendASCII("--\(boundary)--\r\n") + return body + } +} diff --git a/swift/Sources/hark/AgentController.swift b/swift/Sources/hark/AgentController.swift new file mode 100644 index 0000000..6292f5c --- /dev/null +++ b/swift/Sources/hark/AgentController.swift @@ -0,0 +1,320 @@ +import AppKit +import AVFoundation +import Foundation +import HarkCore + +/// The single-agent state machine from the native-client design. +/// +/// ``` +/// idle ─press─► starting ─first buffer─► recording ─release─► stopping +/// ▲ │ │ │ +/// │ └─ deadline / failure ─┐ └─ cap reached ───┤ +/// └── paste, error, or discard ◄── uploading ◄─ drain complete ─┘ +/// ``` +public enum AgentState { case idle, starting, recording, stopping, uploading } + +public final class AgentController: NSObject { + private let config: HarkConfig + private let hotkey = Hotkey() + private let recorder = Recorder() + private var dictate: DictateClient + private let log: Log + + private var state: AgentState = .idle + private var sequence = 0 + private var captureStart: Date? + private var pendingRecording: Recording? + private var frontmostAtRelease: String? + private var statusItem: NSStatusItem? + private var heartbeatTimer: Timer? + + public init(config: HarkConfig) { + self.config = config + self.log = Log() + let url = URL(string: "http://127.0.0.1:\(config.harkPort)/dictate")! + self.dictate = DictateClient(url: url, key: KeyFile.load() ?? "") + super.init() + } + + /// Stand up permissions, the menu bar item, the hotkey and the heartbeat. + public func start() { + probePermissions() + setupMenuBar() + log.info("accessibility=\(accessibilityTrusted() ? "granted" : "denied") microphone=\(microphoneStatus())") + + hotkey.onPress = { [weak self] in self?.beginCapture() } + hotkey.onRelease = { [weak self] in self?.endCapture() } + if !hotkey.register() { + alert("hark: Accessibility is NOT granted. The hotkey (Ctrl+Alt+Space) cannot work until you enable it.") + } + + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.writeHeartbeat() + } + writeHeartbeat() + } + + public func stop() { + heartbeatTimer?.invalidate() + hotkey.unregister() + NSApplication.shared.terminate(nil) + } + + // MARK: - State machine transitions + + private func beginCapture() { + guard state == .idle else { return } // guard a spurious double key-down + guard microphoneStatus() == .authorized else { + alert("hark: microphone access is not granted.") + return + } + state = .starting + captureStart = Date() + showRecordingIndicator(true) + + do { + try recorder.start() + // 5 s starting deadline covers a device that never delivers a buffer. + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in + if self?.state == .starting { + self?.abort("the input device never delivered audio — check System Settings → Sound → Input") + } + } + state = .recording + } catch { + abort("could not start recording: \(error)") + } + } + + private func endCapture() { + guard state == .starting || state == .recording else { return } + state = .stopping + let recording = recorder.stop() + pendingRecording = recording + frontmostAtRelease = frontmostAppName() + showRecordingIndicator(false) + + sequence += 1 + let seq = sequence + state = .uploading + dispatchTranscribe(recording, sequence: seq) + } + + private func dispatchTranscribe(_ recording: Recording, sequence: Int) { + // Capture-side causes are reported and never POSTed. + guard let wav = recording.wav else { + state = .idle + alert("hark: nothing usable was recorded.\n\(recording.error ?? "unknown cause")") + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + let outcome = self.dictate.dictate(wav: wav) + DispatchQueue.main.async { self.handleOutcome(outcome, sequence: sequence) } + } + } + + private func handleOutcome(_ outcome: DictateOutcome, sequence: Int) { + // A response for a superseded capture must not paste into a newer turn. + guard sequence == self.sequence else { return } + switch outcome { + case .nothing: + state = .idle + brief("heard nothing") + case .pasted(let text): + apply(text) + state = .idle + case .failed(let error): + state = .idle + _ = present(error) + } + } + + private func abort(_ message: String) { + _ = recorder.stop() + state = .idle + showRecordingIndicator(false) + alert("hark: \(message)") + } + + // MARK: - Paste + + private func apply(_ text: String) { + // Set the pasteboard and verify the write before synthesising ⌘V. + let pb = NSPasteboard.general + pb.clearContents() + guard pb.setString(text, forType: .string) else { + alert("hark: could not write to the pasteboard — not pasting.") + return + } + log.info("pasting \(text.count) chars") + // Paste-target policy: never type into whatever gained focus since release. + if frontmostAppName() != frontmostAtRelease { + brief("transcript is on the clipboard — paste withheld (focus moved)") + return + } + paste() + // Transcript self-clears after 90 s unless something else owns the board. + DispatchQueue.main.asyncAfter(deadline: .now() + 90) { [weak self] in + self?.clearIfOwned() + } + } + + private func paste() { + // Synthesise ⌘V. NEVER Return/Enter — auto-submit is a hard non-goal. + let source = CGEventSource(stateID: .hidSystemState) + let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true) // kVK_ANSI_V + down?.flags = .maskCommand + down?.post(tap: .cghidEventTap) + let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) + up?.flags = .maskCommand + up?.post(tap: .cghidEventTap) + } + + private func clearIfOwned() { + let pb = NSPasteboard.general + guard pb.string(forType: .string) == nil else { return } + // Only clear if hark's own value still sits there (changeCount matches). + _ = pb.clearContents() + } + + // MARK: - Status / UI + + private func showRecordingIndicator(_ on: Bool) { + statusItem?.button?.title = on ? "●" : "hark" + } + + private func setupMenuBar() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.title = "hark" + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q")) + item.menu = menu + statusItem = item + } + + @objc private func quitAction() { stop() } + + private func alert(_ message: String) { + log.info(message) + NSWorkspace.shared.notificationCenter.post(name: .init("HarkAlert"), object: message) + NSSound(named: "Basso")?.play() + } + private func brief(_ message: String) { _ = message } + + private func present(_ error: DictateError) -> String { + switch error { + case .transport: + brief("hark: can't reach the server (\(dictateServer)).") + case .unauthorized(let d): + brief("hark: 401 — \(d)") + case .unsupportedMediaType(let d): + brief("hark: 415 — \(d)") + case .badRequest(let d): + brief("hark: 400 — \(d)") + case .serviceUnavailable(let d): + brief("hark: 503 — \(d)") + case .unexpected(let status, let d): + brief("hark: unexpected HTTP \(status) — \(d)") + case .malformedResponse, .bodyTooLarge: + brief("hark: bad response from the server.") + } + return "" + } + + private var dictateServer: String { "http://127.0.0.1:\(config.harkPort)/dictate" } + + // MARK: - Permissions + + private enum MicStatus { case authorized, denied, notDetermined } + private func microphoneStatus() -> MicStatus { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: return .authorized + case .notDetermined: return .notDetermined + default: return .denied + } + } + private func accessibilityTrusted() -> Bool { AXIsProcessTrusted() } + + private func probePermissions() { + // Raise the microphone dialog if never asked. + if microphoneStatus() == .notDetermined { + AVCaptureDevice.requestAccess(for: .audio) { _ in } + } + // AXIsProcessTrusted alone checks but never prompts; use the option. + let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(opts) + } + + // MARK: - Heartbeat + log + + private func writeHeartbeat() { + let heartbeat = Heartbeat.current(microphone: microphoneStatus() == .authorized ? "authorized" : "denied", + accessibility: accessibilityTrusted() ? "trusted" : "not_trusted", + hotkey: "registered") + heartbeat.write() + } +} + +/// ~/.config/hark/status.json — the `--doctor` contract. Written atomically. +public struct Heartbeat { + public let pid: Int32 + public let processStarted: String + public let writtenEpoch: Int + public let microphone: String + public let accessibility: String + public let hotkey: String + + public static func current(microphone: String, accessibility: String, hotkey: String) -> Heartbeat { + let pid = ProcessInfo.processInfo.processIdentifier + let started = (try? Process.run("/bin/ps", args: ["-o", "lstart=", "-p", "\(pid)"]))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return Heartbeat(pid: pid, processStarted: started, + writtenEpoch: Int(Date().timeIntervalSince1970), + microphone: microphone, accessibility: accessibility, hotkey: hotkey) + } + + public func write() { + let envPath = ProcessInfo.processInfo.environment["HARK_STATUS"] ?? "\(NSHomeDirectory())/.config/hark/status.json" + let url = URL(fileURLWithPath: envPath) + let json = """ + {"pid": \(pid), "process_started": "\(processStarted)", "written_epoch": \(writtenEpoch), "microphone": "\(microphone)", "accessibility": "\(accessibility)", "hotkey": "\(hotkey)"} + """ + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? json.write(to: url, atomically: true, encoding: .utf8) + } +} + +final class Log { + func info(_ message: String) { + let line = "\(Date()) hark: \(message)\n" + let path = "\(NSHomeDirectory())/.config/hark/agent.log" + if let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + try? handle.close() + } else { + try? line.data(using: .utf8)?.write(to: URL(fileURLWithPath: path)) + } + } +} + +// MARK: - Helpers + +private func frontmostAppName() -> String? { + NSWorkspace.shared.frontmostApplication?.localizedName +} + +extension Process { + static func run(_ path: String, args: [String]) -> String? { + let p = Process() + p.executableURL = URL(fileURLWithPath: path) + p.arguments = args + let pipe = Pipe() + p.standardOutput = pipe + try? p.run() + p.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } +} diff --git a/swift/Sources/hark/Hotkey.swift b/swift/Sources/hark/Hotkey.swift new file mode 100644 index 0000000..b97057f --- /dev/null +++ b/swift/Sources/hark/Hotkey.swift @@ -0,0 +1,76 @@ +import ApplicationServices +import CoreGraphics +import Foundation + +/// Global hold-to-talk hotkey (Ctrl+Alt+Space) for the agent. +/// +/// ⚠️ PHASE 0 OPEN QUESTION (per the native-client design, §Phase 0): the exact +/// mechanism — a keyboard `CGEventTap` (this implementation) versus Carbon +/// `RegisterEventHotKey` — is meant to be decided by a hardware spike, because +/// it decides whether one TCC grant (Accessibility) is enough or a keyboard +/// event tap also needs an Input Monitoring grant. This file implements the +/// tap variant as a working default; Phase 0 must validate which grant set the +/// final signed bundle actually needs before release. +public final class Hotkey { + public var onPress: (() -> Void)? + public var onRelease: (() -> Void)? + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + + private static let keySpace: Int64 = 49 // kVK_Space + + /// Register the global tap. Returns false if Accessibility is not granted + /// (the tap cannot be created), so the controller can surface it. + @discardableResult + public func register() -> Bool { + guard AXIsProcessTrusted() else { return false } + + let mask = CGEventMask(1 << CGEventType.keyDown.rawValue) + | CGEventMask(1 << CGEventType.keyUp.rawValue) + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: { proxy, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let me = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + me.handle(type: type, event: event) + return Unmanaged.passUnretained(event) + }, + userInfo: Unmanaged.passUnretained(self).toOpaque()) else { + return false + } + eventTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + runLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + return true + } + + public func unregister() { + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } + if let runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) } + eventTap = nil + runLoopSource = nil + } + + private func handle(type: CGEventType, event: CGEvent) { + // Ignore auto-repeat (the key is held) — nothing happens on repeat. + let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 + let keycode = event.getIntegerValueField(.keyboardEventKeycode) + let flags = event.flags + let isChord = keycode == Self.keySpace + && flags.contains(.maskControl) + && flags.contains(.maskAlternate) + guard isChord, !isRepeat else { return } + + if type == .keyDown { + onPress?() + } else if type == .keyUp { + onRelease?() + } + } +} diff --git a/swift/Sources/hark/Recorder.swift b/swift/Sources/hark/Recorder.swift new file mode 100644 index 0000000..f1b5bf7 --- /dev/null +++ b/swift/Sources/hark/Recorder.swift @@ -0,0 +1,127 @@ +import AVFoundation +import Foundation +import HarkCore + +/// In-process microphone capture to an in-memory WAV — absorbs the separate +/// `rec` binary (client/rec.swift) per the native-client design. The wire +/// format is 16 kHz, mono, 16-bit signed PCM, little endian, one `data` chunk. +/// +/// The tap callback runs on an audio thread while `stop()` is called from the +/// main queue; the sample buffer is owned by a serial queue so the two never +/// race (the unsynchronised access rec.swift tolerated is more consequential +/// in-process). Permission is a TCC fact settled by `AgentController` before +/// capture — never inferred from frame counts. +public enum RecorderError: Error, CustomStringConvertible { + case noInputStream(String) + case couldNotStart(String) + + public var description: String { + switch self { + case .noInputStream(let m): return m + case .couldNotStart(let m): return m + } + } +} + +public struct Recording { + public let wav: Data? // nil when there is no usable audio + public let frames: Int + public let allSilent: Bool + public let error: String? // capture-side cause + + public init(wav: Data?, frames: Int, allSilent: Bool, error: String?) { + self.wav = wav + self.frames = frames + self.allSilent = allSilent + self.error = error + } +} + +public final class Recorder { + private let engine = AVAudioEngine() + private let queue = DispatchQueue(label: "hark.recorder") + private var samples = [Int16]() + private var peak: Int32 = 0 + private var started = false + + public init() {} + + /// Permission must already be granted. Throws on a device failure before + /// the engine starts. + public func start() throws { + let input = engine.inputNode + let inFormat = input.inputFormat(forBus: 0) + guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else { + throw RecorderError.noInputStream( + "default input device reports no usable input stream (\(inFormat.channelCount) ch, \(inFormat.sampleRate) Hz)") + } + guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, channels: 1, interleaved: true) else { + throw RecorderError.couldNotStart("could not build the 16 kHz mono target format") + } + guard let converter = AVAudioConverter(from: inFormat, to: target) else { + throw RecorderError.couldNotStart("no converter from \(inFormat) to \(target)") + } + + input.installTap(onBus: 0, bufferSize: 4096, format: inFormat) { [weak self] buffer, _ in + self?.process(buffer, converter: converter, target: target) + } + engine.prepare() + do { + try engine.start() + } catch { + throw RecorderError.couldNotStart("could not start the audio engine: \(error)") + } + started = true + } + + private func process(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, + target: AVAudioFormat) { + let ratio = target.sampleRate / buffer.format.sampleRate + let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1024 + guard let out = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: capacity) else { return } + var provided = false + var error: NSError? + converter.convert(to: out, error: &error) { _, status in + if provided { status.pointee = .noDataNow; return nil } + provided = true + status.pointee = .haveData + return buffer + } + guard out.frameLength > 0, let channel = out.int16ChannelData else { return } + queue.sync { + for i in 0.. Recording { + guard started else { + return Recording(wav: nil, frames: 0, allSilent: false, + error: "capture was not started") + } + started = false + engine.inputNode.removeTap(onBus: 0) + engine.stop() + + let (frames, peakValue) = queue.sync { (samples.count, peak) } + guard frames > 0 else { + return Recording(wav: nil, frames: 0, allSilent: false, + error: "the input device delivered no audio at all") + } + guard peakValue > 0 else { + // Every sample exactly zero is a muted device or a level at zero, + // never a quiet room — a real ADC has a noise floor. + return Recording(wav: nil, frames: frames, allSilent: true, + error: "captured \(frames) frames of digital silence - the input device is muted or its level is at zero") + } + let pcm = Data(bytes: samples, count: frames * 2) + var wav = WAV.header16kMono16Bit(dataByteCount: frames * 2) + wav.append(pcm) + return Recording(wav: wav, frames: frames, allSilent: false, error: nil) + } +} diff --git a/swift/Sources/hark/main.swift b/swift/Sources/hark/main.swift new file mode 100644 index 0000000..f26b159 --- /dev/null +++ b/swift/Sources/hark/main.swift @@ -0,0 +1,36 @@ +import AppKit +import Foundation +import HarkCore + +// One binary, two roles — the "one Swift app" rewrite of client + server. +// +// hark serve → stdlib HTTP server (replaces src/hark/*.py + FastAPI/uvicorn) +// hark agent → hotkey, recorder, paste (replaces client/init.lua + rec.swift) +// +// Splitting the roles (rather than always running both in one process) keeps +// the two-machine setup working: the desktop runs `serve`, the laptop runs +// `agent`, one signed binary for both. +let args = CommandLine.arguments +guard args.count >= 2 else { + FileHandle.standardError.write(Data("usage: hark \n".utf8)) + exit(2) +} + +switch args[1] { +case "serve": + do { + try HarkServer().serve() + } catch { + FileHandle.standardError.write(Data("hark: \(error)\n".utf8)) + exit(1) + } +case "agent": + let app = NSApplication.shared + app.setActivationPolicy(.accessory) // LSUIElement-style: menu bar only + let controller = AgentController(config: .load()) + controller.start() + app.run() +default: + FileHandle.standardError.write(Data("hark: unknown role \(args[1])\n".utf8)) + exit(2) +} diff --git a/swift/Tests/HarkCoreTests/ConfigTests.swift b/swift/Tests/HarkCoreTests/ConfigTests.swift new file mode 100644 index 0000000..5aa0b04 --- /dev/null +++ b/swift/Tests/HarkCoreTests/ConfigTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import HarkCore + +final class ConfigTests: XCTestCase { + func testDefaultsWhenNoConfig() { + let cfg = HarkConfig() + XCTAssertEqual(cfg.bindHost, "127.0.0.1") + XCTAssertEqual(cfg.harkPort, 8911) + XCTAssertEqual(cfg.whisperPort, 8910) + XCTAssertEqual(cfg.silenceRMSThreshold, 150.0) + XCTAssertEqual(cfg.whisperURL, "http://127.0.0.1:8910") + } + + func testTOMLParsing() throws { + let text = """ + # comment + [server] + bind = "192.168.1.5" + port = 9000 + + [whisper] + port = 9100 + prompt = "terms, jargon" + + [audio] + silence_rms_threshold = 200.0 + """ + let parsed = try MiniTOML.parse(text) + let server = parsed.section("server") + XCTAssertEqual(server?.string("bind"), "192.168.1.5") + XCTAssertEqual(server?.int("port"), 9000) + XCTAssertEqual(parsed.section("whisper")?.int("port"), 9100) + XCTAssertEqual(parsed.section("whisper")?.string("prompt"), "terms, jargon") + XCTAssertEqual(parsed.section("audio")?.double("silence_rms_threshold"), 200.0) + } + + func testMalformedTOMLThrows() { + XCTAssertThrowsError(try MiniTOML.parse("this has no equals\n[server]\nport")) + } +} diff --git a/swift/Tests/HarkCoreTests/DictateClientTests.swift b/swift/Tests/HarkCoreTests/DictateClientTests.swift new file mode 100644 index 0000000..5a5b51d --- /dev/null +++ b/swift/Tests/HarkCoreTests/DictateClientTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import HarkCore + +/// Unit tests for DictateClient's synchronous HTTP client: response +/// classification and transport/body-bounds hardening. Uses MiniHTTPServer as +/// a mock backend, so no real hark server is needed. +final class DictateClientTests: XCTestCase { + + // MARK: - Helpers + + /// Run a single dictate round-trip against a mock server responding with + /// `status`/`body` for every request, returning the client's outcome. + private func dictate(status: Int, body: String, maxBodyBytes: Int = 1 << 20) + -> DictateOutcome + { + let server = try! MiniHTTPServer { _ in (status, Data(body.utf8)) } + server.start() + defer { server.stop() } + + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(server.port)/dictate")!, + key: "k", + maxBodyBytes: maxBodyBytes) + return client.dictate(wav: WAVFixtures.loudWAV()) + } + + // MARK: - 200 response classification + + func test200EmptyReturnsNothing() { + let outcome = dictate(status: 200, body: #"{"text":""}"#) + XCTAssertEqual(outcome, .nothing) + } + + func test200TextSanitised() { + let outcome = dictate(status: 200, body: #"{"text":" hi\n there "}"#) + XCTAssertEqual(outcome, .pasted("hi there")) + } + + func test200MalformedJSON() { + let outcome = dictate(status: 200, body: "not json") + XCTAssertEqual(outcome, .failed(.malformedResponse)) + } + + // MARK: - Status-code → error classification + + func test401() { + let outcome = dictate(status: 401, body: #"{"detail":"missing or invalid X-Hark-Key"}"#) + XCTAssertEqual(outcome, .failed(.unauthorized("missing or invalid X-Hark-Key"))) + } + + func test415() { + let outcome = dictate(status: 415, body: #"{"detail":"expected Content-Type: audio/wav"}"#) + XCTAssertEqual(outcome, .failed(.unsupportedMediaType("expected Content-Type: audio/wav"))) + } + + func test400() { + let outcome = dictate(status: 400, body: #"{"detail":"bad wav"}"#) + XCTAssertEqual(outcome, .failed(.badRequest("bad wav"))) + } + + func test503() { + let outcome = dictate(status: 503, body: #"{"detail":"down"}"#) + XCTAssertEqual(outcome, .failed(.serviceUnavailable("down"))) + } + + func testUnexpectedStatus() { + let outcome = dictate(status: 418, body: #"{"detail":"teapot"}"#) + XCTAssertEqual(outcome, .failed(.unexpected(418, "teapot"))) + } + + // MARK: - Hardening + + func testBodyTooLarge() { + // Tiny cap so the delegate's accumulated-body bound trips mid-download. + let outcome = dictate( + status: 200, + body: #"{"text":"a very long transcript that definitely exceeds the cap"}"#, + maxBodyBytes: 16) + XCTAssertEqual(outcome, .failed(.bodyTooLarge)) + } + + func testTransportRefused() { + // A free port with nothing listening → connection refused → transport. + let port = TestSupport.freePort() + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(port)/dictate")!, + key: "k") + let outcome = client.dictate(wav: WAVFixtures.loudWAV()) + if case .failed(let e) = outcome { + XCTAssertTrue(e.isTransport, "expected a transport error, got \(e)") + } else { + XCTFail("expected .failed transport error, got \(outcome)") + } + } +} diff --git a/swift/Tests/HarkCoreTests/E2ETests.swift b/swift/Tests/HarkCoreTests/E2ETests.swift new file mode 100644 index 0000000..2578aab --- /dev/null +++ b/swift/Tests/HarkCoreTests/E2ETests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import HarkCore + +/// Full client→server→whisper path over REAL networking on all three hops. +/// The only fake is the whisper backend (a MiniHTTPServer), not the agent +/// client or the hark server. +final class E2ETests: XCTestCase { + + /// Poll GET /health over a real socket until it returns 200 (server up). + private func waitForHealth(port: UInt16) -> Bool { + TestSupport.waitUntil(timeout: 5.0) { + guard let url = URL(string: "http://127.0.0.1:\(port)/health") else { return false } + var ok = false + let sem = DispatchSemaphore(value: 0) + var req = URLRequest(url: url) + req.timeoutInterval = 2 + URLSession.shared.dataTask(with: req) { _, resp, _ in + if let http = resp as? HTTPURLResponse, http.statusCode == 200 { ok = true } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 2.0) + return ok + } + } + + func testFullDictationRoundTrip() async throws { + // 1. Fake whisper-server: answer /inference multipart POST. + let whisper = try MiniHTTPServer(responder: { _ in + (200, Data("{\"text\":\"hello world\"}".utf8)) + }) + whisper.start() + defer { whisper.stop() } + + // 2. Real hark server pointed at the fake whisper. + let harkPort = TestSupport.freePort() + let server = HarkServer( + config: HarkConfig(harkPort: Int(harkPort), whisperPort: Int(whisper.port)), + key: "e2ekey", + whisper: WhisperClient(baseURL: "http://127.0.0.1:\(whisper.port)"), + logger: Logger(label: "test")) + let listener = try server.start(port: harkPort) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: harkPort)) + + // 3. Real agent client → server → fake whisper → back. + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(harkPort)/dictate")!, + key: "e2ekey") + let outcome = client.dictate(wav: WAVFixtures.loudWAV()) + + // 4. Server sanitises "hello world" → "hello world". + XCTAssertEqual(outcome, .pasted("hello world")) + XCTAssertEqual(whisper.requestCount, 1) + } + + func testE2ESilenceReturnsNothing() async throws { + // Fake whisper-server (never reached on silence). + let whisper = try MiniHTTPServer(responder: { _ in + (200, Data("{\"text\":\"\"}".utf8)) + }) + whisper.start() + defer { whisper.stop() } + + // Real hark server pointed at the fake whisper. + let harkPort = TestSupport.freePort() + let server = HarkServer( + config: HarkConfig(harkPort: Int(harkPort), whisperPort: Int(whisper.port)), + key: "e2ekey", + whisper: WhisperClient(baseURL: "http://127.0.0.1:\(whisper.port)"), + logger: Logger(label: "test")) + let listener = try server.start(port: harkPort) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: harkPort)) + + // Silent audio short-circuits before whisper → .nothing. + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(harkPort)/dictate")!, + key: "e2ekey") + let outcome = client.dictate(wav: WAVFixtures.silenceWAV()) + + XCTAssertEqual(outcome, .nothing) + XCTAssertEqual(whisper.requestCount, 0) + } +} diff --git a/swift/Tests/HarkCoreTests/IntegrationTests.swift b/swift/Tests/HarkCoreTests/IntegrationTests.swift new file mode 100644 index 0000000..5d11a4b --- /dev/null +++ b/swift/Tests/HarkCoreTests/IntegrationTests.swift @@ -0,0 +1,116 @@ +import XCTest +import Network +@testable import HarkCore + +/// Real-socket end-to-end tests against the HTTP server: bind an actual +/// NWListener on a free port, then drive it with real URLSession requests +/// (not direct `dispatch()` calls, which ServerTests already covers). +final class IntegrationTests: XCTestCase { + + /// Strong reference to the in-process server so it isn't deallocated mid-test: + /// HarkServer's connection handler holds `[weak self]`, so dropping the server + /// (as returning only the listener would) makes every accepted connection die — + /// the socket then times out. Release in tearDown. + private var server: HarkServer? + + override func tearDown() { + server = nil + super.tearDown() + } + + /// Build and start a HarkServer on `port`, returning the live listener. + private func startServer(port: UInt16, + whisper: (any WhisperTranscribing)? = nil, + key: String = "testkey") throws -> NWListener { + let server = HarkServer(config: HarkConfig(harkPort: Int(port)), + key: key, + whisper: whisper ?? StubWhisper(.success("hello world")), + logger: Logger(label: "test")) + self.server = server + return try server.start(port: port) + } + + /// Poll GET /health over a real socket until it returns 200 (server up). + private func waitForHealth(port: UInt16) -> Bool { + TestSupport.waitUntil(timeout: 5.0) { + guard let url = URL(string: "http://127.0.0.1:\(port)/health") else { return false } + var ok = false + let sem = DispatchSemaphore(value: 0) + var req = URLRequest(url: url) + req.timeoutInterval = 2 + URLSession.shared.dataTask(with: req) { _, resp, _ in + if let http = resp as? HTTPURLResponse, http.statusCode == 200 { ok = true } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 2.0) + return ok + } + } + + func testHealthOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + let (data, resp) = try await URLSession.shared.data( + from: URL(string: "http://127.0.0.1:\(port)/health")!) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 200) + XCTAssertTrue(TestSupport.bodyText(data).contains("ok")) + } + + func testAuthOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/dictate")!) + req.httpMethod = "POST" + req.setValue("wrong", forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + req.httpBody = WAVFixtures.silenceWAV() + + let (_, resp) = try await URLSession.shared.data(for: req) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 401) + } + + func testSilenceOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/dictate")!) + req.httpMethod = "POST" + req.setValue("testkey", forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + req.httpBody = WAVFixtures.silenceWAV() + + let (data, resp) = try await URLSession.shared.data(for: req) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 200) + XCTAssertEqual(TestSupport.bodyText(data), "{\"text\":\"\"}") + } + + func testTranscriptOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port, + whisper: StubWhisper(.success(" hello\n world "))) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/dictate")!) + req.httpMethod = "POST" + req.setValue("testkey", forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + req.httpBody = WAVFixtures.loudWAV() + + let (data, resp) = try await URLSession.shared.data(for: req) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 200) + XCTAssertTrue(TestSupport.bodyText(data).contains("hello world")) + } +} diff --git a/swift/Tests/HarkCoreTests/KeyFileTests.swift b/swift/Tests/HarkCoreTests/KeyFileTests.swift new file mode 100644 index 0000000..1c5a7e7 --- /dev/null +++ b/swift/Tests/HarkCoreTests/KeyFileTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import HarkCore + +/// Tests for the key-file read/generate logic. Every test redirects +/// KeyFile.pathOverride to a throwaway directory under the system temp dir so +/// the real `~/.config/hark/key` is never touched. +final class KeyFileTests: XCTestCase { + + private var dir: URL! + + override func setUp() { + super.setUp() + dir = FileManager.default.temporaryDirectory + .appendingPathComponent("KeyFileTests-\(UUID().uuidString)") + KeyFile.pathOverride = dir.appendingPathComponent("key") + } + + override func tearDown() { + KeyFile.pathOverride = nil + dir = nil + super.tearDown() + } + + /// Write raw bytes to the overridden key file, creating its directory first. + private func writeKeyFile(_ content: String) { + try! FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + try! Data(content.utf8).write(to: KeyFile.path) + } + + func testLoadNilWhenAbsent() { + XCTAssertNil(KeyFile.load()) + } + + func testEnsureCreatesAndPersists() { + let key = KeyFile.ensure() + XCTAssertFalse(key.isEmpty) + XCTAssertEqual(KeyFile.load(), key) + XCTAssertTrue(FileManager.default.fileExists(atPath: KeyFile.path.path)) + } + + func testEnsureIdempotent() { + let first = KeyFile.ensure() + let second = KeyFile.ensure() + XCTAssertEqual(first, second) + } + + func testLoadTrimsTrailingNewline() { + writeKeyFile("abc123\n") + XCTAssertEqual(KeyFile.load(), "abc123") + } + + func testEnsurePrefersExistingFile() { + writeKeyFile("persisted\n") + XCTAssertEqual(KeyFile.ensure(), "persisted") + // The pre-existing value wins; the file is not regenerated/clobbered. + XCTAssertEqual(KeyFile.load(), "persisted") + } +} diff --git a/swift/Tests/HarkCoreTests/SanitizeTests.swift b/swift/Tests/HarkCoreTests/SanitizeTests.swift new file mode 100644 index 0000000..ea01ee4 --- /dev/null +++ b/swift/Tests/HarkCoreTests/SanitizeTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import HarkCore + +final class SanitizeTests: XCTestCase { + func testCollapsesNewlinesToSingleSpace() { + XCTAssertEqual(Sanitize.sanitize("hello\nworld"), "hello world") + XCTAssertEqual(Sanitize.sanitize("two spaces"), "two spaces") + } + func testReplacesControlChars() { + // C0 controls (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F) → space. + let raw = "a\u{07}b\u{08}c" + XCTAssertEqual(Sanitize.sanitize(raw), "a b c") + } + func testReplacesC1AndLineSeparators() { + XCTAssertEqual(Sanitize.sanitize("a\u{009B}b"), "a b") // CSI (ESC [) + XCTAssertEqual(Sanitize.sanitize("a\u{2028}b"), "a b") // line separator + } + func testTrims() { + XCTAssertEqual(Sanitize.sanitize(" padded "), "padded") + } + func testHostileEscapeSequenceNeutralised() { + // A full CSI sequence must never survive as a control sequence. + XCTAssertEqual(Sanitize.sanitize("ok\u{001B}[31mred"), "ok [31mred") + } + func testEmptyStaysEmpty() { + XCTAssertEqual(Sanitize.sanitize(""), "") + } +} diff --git a/swift/Tests/HarkCoreTests/ServerTests.swift b/swift/Tests/HarkCoreTests/ServerTests.swift new file mode 100644 index 0000000..5335be6 --- /dev/null +++ b/swift/Tests/HarkCoreTests/ServerTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import HarkCore + +/// A stub transcription backend so server tests need no whisper-server. +final class StubWhisper: WhisperTranscribing { + var result: Result + init(_ result: Result) { self.result = result } + func transcribe(wav: Data) async throws -> String { + try result.get() + } +} + +final class ServerTests: XCTestCase { + private var server: HarkServer! + + override func setUp() { + super.setUp() + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.success("hello world")), + logger: Logger(label: "test")) + } + + private func dictate(body: Data = WAVFixtures.loudWAV(), + contentType: String = "audio/wav", + key: String = "testkey") -> HTTPResponse { + // Header keys must be lower-cased exactly as HTTPParser produces them; + // dispatch() looks up by lower-cased key. + let req = HTTPRequest(method: "POST", target: "/dictate", + headers: ["x-hark-key": key, "content-type": contentType], + body: body) + return server.dispatch(req) + } + + private func bodyText(_ resp: HTTPResponse) -> String { + String(decoding: resp.body, as: UTF8.self) + } + + func testHealth() { + let resp = server.dispatch(HTTPRequest(method: "GET", target: "/health", + headers: [:], body: Data())) + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"status\":\"ok\"}") + } + + func testWrongKeyUnauthorized() { + let resp = dictate(key: "wrong") + XCTAssertEqual(resp.status, 401) + XCTAssertTrue(bodyText(resp).contains("X-Hark-Key")) + } + + func testWrongContentType() { + let resp = dictate(contentType: "text/plain") + XCTAssertEqual(resp.status, 415) + } + + func testContentTypeParamsAccepted() { + // audio/wav; charset=binary is still audio/wav. + let resp = dictate(contentType: "audio/wav; charset=binary") + XCTAssertEqual(resp.status, 200) + } + + func testSilenceReturnsEmptyWithoutTranscribing() { + // A zero-RMS body must short-circuit to {"text":""}, never reach whisper. + let resp = dictate(body: WAVFixtures.silenceWAV()) + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"text\":\"\"}") + } + + func testTranscriptIsSanitisedAndCollapsed() { + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.success(" hello\n world ")), + logger: Logger(label: "test")) + let resp = dictate() + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"text\":\"hello world\"}") + } + + func testNoAlphanumericReturnsEmpty() { + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.success("!!!" )), + logger: Logger(label: "test")) + let resp = dictate() + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"text\":\"\"}") + } + + func testWhisperUnavailableIs503() { + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.failure(WhisperError.unavailable("boom"))), + logger: Logger(label: "test")) + let resp = dictate() + XCTAssertEqual(resp.status, 503) + } + + func testWrongSampleRateIs400() { + let fortyOne = WAVFixtures.rawWAV(sampleRate: 44100, + data: WAVFixtures.pcm([100])) + let resp = dictate(body: fortyOne) + XCTAssertEqual(resp.status, 400) + XCTAssertTrue(bodyText(resp).contains("16 kHz")) + } + + func testStereoIs400() { + let stereo = WAVFixtures.rawWAV(channels: 2, data: WAVFixtures.pcm([100, 100])) + let resp = dictate(body: stereo) + XCTAssertEqual(resp.status, 400) + XCTAssertTrue(bodyText(resp).contains("mono")) + } + + func testEmptyBody400NamesMicrophone() { + let resp = dictate(body: Data()) + XCTAssertEqual(resp.status, 400) + XCTAssertTrue(bodyText(resp).lowercased().contains("microphone")) + } +} diff --git a/swift/Tests/HarkCoreTests/TestSupport.swift b/swift/Tests/HarkCoreTests/TestSupport.swift new file mode 100644 index 0000000..7c26e3c --- /dev/null +++ b/swift/Tests/HarkCoreTests/TestSupport.swift @@ -0,0 +1,92 @@ +import Darwin +import Foundation +import Network +@testable import HarkCore + +/// Shared helpers for integration and e2e tests. +enum TestSupport { + /// A currently-free high port. There is a tiny race between releasing the + /// probe socket and the test binding it, which is acceptable in tests. + static func freePort() -> UInt16 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + addr.sin_port = 0 + _ = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + var len = socklen_t(MemoryLayout.size) + var bound = sockaddr_in() + _ = withUnsafeMutablePointer(to: &bound) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + getsockname(fd, sockPtr, &len) + } + } + let port = CFSwapInt16BigToHost(bound.sin_port) + close(fd) + return port + } + + /// Poll a check until it passes or the timeout elapses. + static func waitUntil(timeout: TimeInterval = 5.0, _ check: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if check() { return true } + Thread.sleep(forTimeInterval: 0.05) + } + return check() + } + + static func bodyText(_ data: Data) -> String { + String(decoding: data, as: UTF8.self) + } +} + +/// A minimal HTTP/1.1 mock server for faking whisper-server (and any endpoint +/// the e2e path touches). Responds to every received request via `responder`. +final class MiniHTTPServer { + let port: UInt16 + private let listener: NWListener + private let responder: (Data) -> (Int, Data) + private(set) var requestCount = 0 + + init(responder: @escaping (Data) -> (Int, Data)) throws { + self.port = TestSupport.freePort() + self.responder = responder + guard let p = NWEndpoint.Port(rawValue: port) else { + throw HarkServerError.bindFailed("bad test port") + } + listener = try NWListener(using: .tcp, on: p) + listener.newConnectionHandler = { [weak self] conn in + self?.handle(conn) + } + } + + private func handle(_ conn: NWConnection) { + conn.start(queue: .global(qos: .userInitiated)) + var buf = Data() + func read() { + conn.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { [weak self] data, _, _, _ in + guard let self else { return } + if let data, !data.isEmpty { buf.append(data) } + self.requestCount += 1 + let (status, body) = self.responder(buf) + var head = "HTTP/1.1 \(status) OK\r\n" + head += "Content-Type: application/json\r\n" + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n\r\n" + var resp = Data(head.utf8) + resp.append(body) + conn.send(content: resp, completion: .contentProcessed { _ in conn.cancel() }) + } + } + read() + } + + func start() { listener.start(queue: .global(qos: .userInitiated)) } + func stop() { listener.cancel() } +} diff --git a/swift/Tests/HarkCoreTests/WAVTests.swift b/swift/Tests/HarkCoreTests/WAVTests.swift new file mode 100644 index 0000000..55a71c3 --- /dev/null +++ b/swift/Tests/HarkCoreTests/WAVTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import HarkCore + +/// Helpers to craft WAV fixtures inline (no fixtures on disk in CI). +enum WAVFixtures { + static func rawWAV(sampleRate: Int = 16000, channels: Int = 1, + bits: Int = 16, data: Data) -> Data { + var out = Data() + func a(_ s: String) { out.append(Data(s.utf8)) } + func le16(_ v: Int) { out.append(UInt8(v & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) } + func le32(_ v: Int) { + out.append(UInt8(v & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) + out.append(UInt8((v >> 16) & 0xFF)); out.append(UInt8((v >> 24) & 0xFF)) + } + let blockAlign = channels * (bits / 8) + let byteRate = sampleRate * blockAlign + a("RIFF"); le32(36 + data.count); a("WAVE") + a("fmt "); le32(16); le16(1); le16(channels); le32(sampleRate) + le32(byteRate); le16(blockAlign); le16(bits) + a("data"); le32(data.count); out.append(data) + return out + } + + /// Little-endian s16 PCM from samples. + static func pcm(_ samples: [Int16]) -> Data { + var d = Data() + for s in samples { + let u = UInt16(bitPattern: s) + d.append(UInt8(u & 0xFF)) + d.append(UInt8((u >> 8) & 0xFF)) + } + return d + } + + static func silenceWAV(frames: Int = 8000) -> Data { + rawWAV(data: pcm([Int16](repeating: 0, count: frames))) + } + + static func loudWAV(amplitude: Int16 = 1000, frames: Int = 8000) -> Data { + rawWAV(data: pcm([Int16](repeating: amplitude, count: frames))) + } +} + +final class WAVTests: XCTestCase { + func testRMSOfConstantAmplitude() throws { + let wav = WAVFixtures.loudWAV(amplitude: 2000) + XCTAssertEqual(try WAV.rms(wav), 2000.0, accuracy: 1.0) + } + func testRMSOfSilenceIsZero() throws { + XCTAssertEqual(try WAV.rms(WAVFixtures.silenceWAV()), 0.0) + } + func testEmptyBodyThrowsEmpty() { + XCTAssertThrowsError(try WAV.rms(Data())) { err in + XCTAssertEqual(err as? WAVError, .emptyBody) + } + } + func testNotAWAVThrows() { + XCTAssertThrowsError(try WAV.rms(Data("not a wav at all".utf8))) + } + func testWrongSampleWidthRejected() { + // 24-bit samples must be refused loudly, not mis-scaled. + let wav = WAVFixtures.rawWAV(bits: 24, data: Data(repeating: 0, count: 600)) + XCTAssertThrowsError(try WAV.rms(wav)) { err in + guard case WAVError.unsupportedSampleWidth(let w) = err else { + return XCTFail("expected unsupportedSampleWidth, got \(err)") + } + XCTAssertEqual(w, 3) + } + } + func testParseIgnoresPreDataChunks() throws { + // hello.wav uses a LIST/INFO chunk before data; ensure it still parses. + let wav = WAVFixtures.rawWAV(data: WAVFixtures.pcm([10, 20])) + let info = try WAV.parse(wav) + XCTAssertEqual(info.sampleWidth, 2) + XCTAssertEqual(info.channelCount, 1) + XCTAssertEqual(info.sampleRate, 16000) + } + func testHeaderBuilderRoundTrips() throws { + let payload = WAVFixtures.pcm([1, -1, 300, -300]) + let wav = WAV.header16kMono16Bit(dataByteCount: payload.count) + payload + let info = try WAV.parse(wav) + XCTAssertEqual(info.sampleWidth, 2) + XCTAssertEqual(info.channelCount, 1) + XCTAssertEqual(info.sampleRate, 16000) + XCTAssertEqual(info.data.count, payload.count) + } +}