diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e5c63ad..2bb1d7b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,4 +59,26 @@ jobs:
# Preinstalled on the Ubuntu runner image.
- name: shellcheck
- run: shellcheck install-server.sh install-client.sh
+ run: |
+ shellcheck install-server.sh install-client.sh install-agent.sh \
+ client/agent/build-agent.sh
+
+ # The agent is macOS-only and needs a real Swift toolchain, so this cannot
+ # join the portable matrix above. It builds the bundle and asserts the
+ # signature verifies - an unsigned or broken-signature bundle is exactly the
+ # state in which TCC grants stop surviving a rebuild.
+ agent:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Build hark.app
+ run: ./client/agent/build-agent.sh
+
+ - name: Verify the bundle signature
+ run: codesign --verify --strict --verbose=2 build/hark.app
+
+ - name: Verify the bundled recorder is present and signed
+ run: |
+ test -x build/hark.app/Contents/MacOS/rec
+ codesign --verify --strict build/hark.app/Contents/MacOS/rec
diff --git a/.gitignore b/.gitignore
index 6d3dd88..a7b47a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,7 @@ config.toml
# Local migration notes, not part of the project.
LAPTOP-MIGRATION.local.md
+
+# Built agent bundle. client/agent/build-agent.sh writes here; the sources it
+# compiles are the tracked artifact, never the .app.
+build/
diff --git a/README.md b/README.md
index 4fc5cde..5fb171b 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,38 @@ It:
Safe to re-run at any time; every step checks current state first.
+#### Native agent (preview, opt-in)
+
+There is a second client — a native Swift agent that does the same job without
+Hammerspoon. It is not the default yet, and installing it changes nothing about
+the Hammerspoon path:
+
+```bash
+./install-agent.sh # build, install to ~/Applications, load at login
+./install-agent.sh --doctor # read-only diagnosis
+./install-agent.sh --uninstall
+```
+
+Why it exists: Accessibility is currently granted to Hammerspoon — a
+general-purpose scriptable Lua runtime — and its config is a symlink into this
+repo, so `git pull` changes what that grant covers without re-prompting. A
+single-purpose bundle asks for the same permission with far less behind it.
+See [issue #2](https://github.com/DRYCodeWorks/hark/issues/2).
+
+**The two clients cannot both hold the hotkey.** `Ctrl+Alt+Space` is a
+system-wide registration and exactly one process gets it; whichever starts
+first wins and the other reports that it could not register. `install-agent.sh`
+quits Hammerspoon for you unless you pass `--keep-hammerspoon`.
+
+Migration is non-destructive in both directions. `~/.hammerspoon/hark-config.lua`
+is read into `~/.config/hark/client.json` and never modified, so rolling back is
+just `./install-agent.sh --uninstall` and relaunching Hammerspoon.
+
+You will be prompted for Microphone and Accessibility again — TCC keys grants to
+a code identity, and the agent is a different one from Hammerspoon. Until a
+Developer ID certificate is in place the bundle is ad-hoc signed, which means
+those grants survive until the binary changes and you re-grant after a rebuild.
+
### 3. Configuration
Everything is optional — the defaults are the working single-machine setup.
@@ -383,15 +415,20 @@ transient.
```
install-server.sh transcription side: deps, model, plists, services
install-client.sh hotkey/mic/paste side, plus --doctor
+install-agent.sh native agent install/doctor/uninstall (preview)
client/
init.lua Hammerspoon client
rec.swift AVAudioEngine recorder, built at install time
hark-config.example.lua shape of ~/.hammerspoon/hark-config.lua
+ agent/
+ hark-agent.swift native client — hotkey, capture, POST, paste
+ Info.plist bundle identity + microphone usage string
+ build-agent.sh assembles and signs hark.app
config.example.toml shape of ~/.config/hark/config.toml
src/hark/ the HTTP service
launchd/ plist templates, rendered by hark.plists
-tests/ pytest suite (67) + test_client_record.lua (8)
-.github/workflows/ci.yml both suites + shellcheck, on Linux and macOS
+tests/ pytest suite + test_client_record.lua (8)
+.github/workflows/ci.yml both suites + shellcheck + the agent build
docs/ design spec + implementation plan
```
@@ -400,7 +437,9 @@ Run the suites locally the way CI does:
```bash
uv run --locked pytest -q
lua tests/test_client_record.lua
-shellcheck install-server.sh install-client.sh
+shellcheck install-server.sh install-client.sh install-agent.sh \
+ client/agent/build-agent.sh
+./client/agent/build-agent.sh # macOS only
```
## License
diff --git a/client/agent/Info.plist b/client/agent/Info.plist
new file mode 100644
index 0000000..003b8e0
--- /dev/null
+++ b/client/agent/Info.plist
@@ -0,0 +1,57 @@
+
+
+
+
+ CFBundleName
+ hark
+
+ CFBundleDisplayName
+ hark
+
+
+ CFBundleIdentifier
+ com.drycodeworks.hark-agent
+
+ CFBundleExecutable
+ hark-agent
+
+ CFBundlePackageType
+ APPL
+
+ CFBundleShortVersionString
+ 0.1.0
+
+ CFBundleVersion
+ 1
+
+
+ LSUIElement
+
+
+
+ NSMicrophoneUsageDescription
+ hark records your voice while you hold the dictation hotkey, and sends it to your own transcription server. Audio is never stored after the transcript comes back.
+
+ LSMinimumSystemVersion
+ 13.0
+
+ NSHighResolutionCapable
+
+
+
diff --git a/client/agent/build-agent.sh b/client/agent/build-agent.sh
new file mode 100755
index 0000000..3a0ec17
--- /dev/null
+++ b/client/agent/build-agent.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+#
+# Build client/agent/hark-agent.swift into hark.app.
+#
+# ./client/agent/build-agent.sh [output-dir]
+#
+# Output defaults to build/ at the repo root. install-client.sh calls this and
+# then copies the bundle into place; run it directly when iterating on the
+# agent itself.
+#
+# The bundle is ad-hoc signed (`codesign -s -`). That is not decoration:
+#
+# - An UNSIGNED bundle has no stable code identity at all, so TCC re-prompts
+# essentially at random and grants do not survive a rebuild.
+# - An AD-HOC signature gives it a cdhash identity. Grants survive until the
+# binary changes, which is the best available answer during development
+# and means one re-grant per rebuild rather than one per launch.
+# - A DEVELOPER ID signature makes grants survive rebuilds outright, because
+# the identity is then the certificate rather than the hash. That is what
+# a release build wants, and it is tracked separately - the certificate is
+# gated on Apple Developer enrollment paperwork, not on this script.
+#
+# When a Developer ID identity is available, pass it:
+#
+# HARK_SIGN_IDENTITY="Developer ID Application: ... (TEAMID)" \
+# ./client/agent/build-agent.sh
+#
+# Notarization is a separate step against a release artifact; see DRY-723.
+
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+AGENT_DIR="$REPO_DIR/client/agent"
+CLIENT_DIR="$REPO_DIR/client"
+OUT_DIR="${1:-$REPO_DIR/build}"
+APP="$OUT_DIR/hark.app"
+
+# Ad-hoc unless the caller supplies a real identity.
+SIGN_IDENTITY="${HARK_SIGN_IDENTITY:--}"
+
+log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
+err() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; }
+
+if ! command -v swiftc >/dev/null 2>&1; then
+ err "swiftc not found. Install the Xcode command line tools: xcode-select --install"
+ exit 1
+fi
+
+log "building into $APP"
+rm -rf "$APP"
+mkdir -p "$APP/Contents/MacOS"
+
+# The agent itself.
+swiftc -O -o "$APP/Contents/MacOS/hark-agent" "$AGENT_DIR/hark-agent.swift"
+
+# rec ships INSIDE the bundle. Two reasons: the app is self-contained, and a
+# nested binary is covered by the bundle's own signature, so signing does not
+# become a two-artifact problem later.
+swiftc -O -o "$APP/Contents/MacOS/rec" "$CLIENT_DIR/rec.swift"
+
+cp "$AGENT_DIR/Info.plist" "$APP/Contents/Info.plist"
+
+# Marks the directory as a bundle for Launch Services. Without it the app can
+# still be exec'd but `open` and login-item registration misbehave.
+printf 'APPL????' > "$APP/Contents/PkgInfo"
+
+log "signing with identity: $SIGN_IDENTITY"
+# --options runtime enables the hardened runtime, which notarization requires
+# and which is harmless ad-hoc. --force so a rebuild replaces the previous
+# signature rather than failing on it. --deep is deliberately NOT used: it is
+# deprecated and signs nested code with the wrong flags; the explicit rec sign
+# below is the supported way to cover a nested binary.
+#
+# --entitlements is NOT optional under the hardened runtime. Microphone access
+# requires com.apple.security.device.audio-input, and without it TCC refuses to
+# PROMPT rather than denying after a prompt - so the app never appears in the
+# Microphone pane and the code sees an instant .denied it cannot distinguish
+# from a real refusal. See the entitlements file for the tccd log line.
+#
+# Both binaries get it: rec is the process that opens the device, and the app
+# is the responsible process tccd checks the entitlement on.
+ENTITLEMENTS="$AGENT_DIR/hark-agent.entitlements"
+
+codesign --force --options runtime --timestamp=none \
+ --entitlements "$ENTITLEMENTS" \
+ --sign "$SIGN_IDENTITY" "$APP/Contents/MacOS/rec"
+codesign --force --options runtime --timestamp=none \
+ --entitlements "$ENTITLEMENTS" \
+ --sign "$SIGN_IDENTITY" "$APP"
+
+log "verifying"
+codesign --verify --strict --verbose=2 "$APP" 2>&1 | sed 's/^/ /'
+
+# Assert the entitlement actually made it into the signature. A signature that
+# verifies is not evidence of this: the app signs, launches and runs perfectly
+# without it, and only fails when it reaches for the microphone - at which
+# point the failure names a permission problem rather than a build problem.
+if ! codesign -d --entitlements - --xml "$APP" 2>/dev/null \
+ | plutil -convert xml1 -o - - 2>/dev/null \
+ | grep -q "com.apple.security.device.audio-input"; then
+ err "the signed bundle is missing com.apple.security.device.audio-input"
+ err "TCC will refuse to prompt and the microphone will read as denied."
+ exit 1
+fi
+log "entitlement present: com.apple.security.device.audio-input"
+
+log "built $APP"
diff --git a/client/agent/hark-agent.entitlements b/client/agent/hark-agent.entitlements
new file mode 100644
index 0000000..d262a40
--- /dev/null
+++ b/client/agent/hark-agent.entitlements
@@ -0,0 +1,26 @@
+
+
+
+
+
+ com.apple.security.device.audio-input
+
+
+
diff --git a/client/agent/hark-agent.swift b/client/agent/hark-agent.swift
new file mode 100644
index 0000000..be4f713
--- /dev/null
+++ b/client/agent/hark-agent.swift
@@ -0,0 +1,771 @@
+// hark-agent — hold-to-talk dictation agent.
+//
+// Hold Ctrl+Alt+Space, speak, release. `rec` (client/rec.swift, bundled at
+// Contents/MacOS/rec) records the mic to a WAV, the WAV is POSTed to the
+// hark server, the transcript comes back in the HTTP response, and it lands
+// on the clipboard and gets pasted (Cmd+V) into whatever app has focus.
+//
+// This replaces client/init.lua and, with it, the Hammerspoon dependency.
+// See docs/superpowers/specs/2026-07-14-dictate-design.md for why the
+// transcript is pasted at the OS cursor rather than injected server-side:
+// the server cannot know which pane you are looking at, but macOS always
+// knows what has focus.
+//
+// WHY A BUNDLE AND NOT A BARE BINARY
+//
+// Both permissions this needs are keyed by TCC to a code identity. For an
+// .app that identity is the bundle ID plus its signature, and it survives
+// rebuilds. For a bare binary it is the path and the cdhash, so every
+// recompile invalidates the grant - and macOS leaves the old row visible in
+// the privacy pane with its toggle still ON while the grant no longer
+// applies. No prompt, no error, the hotkey simply stops firing. Bundle
+// stability is the single thing Hammerspoon was still buying us.
+//
+// Microphone additionally requires NSMicrophoneUsageDescription in an
+// Info.plist, which a bare binary has nowhere to put.
+//
+// WHY CARBON RegisterEventHotKey AND NOT AN NSEvent GLOBAL MONITOR
+//
+// NSEvent.addGlobalMonitorForEvents cannot consume the event it observes, so
+// Ctrl+Alt+Space would reach the focused app as well as us. RegisterEventHotKey
+// consumes it, delivers pressed AND released as distinct events, and needs no
+// permission of its own. It is also what Hammerspoon's hs.hotkey used
+// underneath, so it is already proven against this exact key and hold pattern.
+//
+// Accessibility is still required - not for the hotkey, but for synthesizing
+// the Cmd+V at the end. One prompt, not two.
+
+import AVFoundation
+import AppKit
+import Carbon.HIToolbox
+import Foundation
+
+// ============================================================================
+// Paths
+// ============================================================================
+
+let home = FileManager.default.homeDirectoryForCurrentUser
+
+// Beside the server's own config.toml and key. The client half is JSON rather
+// than TOML purely so this stays a single-file swiftc build: Swift has no
+// stdlib TOML parser, and neither a SwiftPM manifest nor a hand-rolled parser
+// earns its keep for three fields.
+let configPath = home.appendingPathComponent(".config/hark/client.json")
+
+// NOT /tmp/hark.wav. The Hammerspoon client uses that path, and the two are
+// designed to coexist on the same Mac while a user migrates. Sharing it would
+// let a recording from one client be read by the other.
+let wavPath = URL(fileURLWithPath: "/tmp/hark-agent.wav")
+
+let micProbePath = URL(fileURLWithPath: "/tmp/hark-agent-mic-probe.wav")
+
+// ~/Library/Logs is where macOS expects an app's logs and where Console.app
+// looks without being told. The Hammerspoon client wrote to ~/.hammerspoon/
+// because that was the only directory it owned.
+let logPath = home.appendingPathComponent("Library/Logs/hark-agent.log")
+
+// Read by install-client.sh --doctor. The contract is unchanged from the
+// Hammerspoon client's ~/.hammerspoon/.hark-mic-status, and it still exists
+// for the same reason: TCC attributes a microphone request to the RESPONSIBLE
+// process, so a probe run from a shell would test Terminal's grant, not this
+// agent's, and would report a confidently wrong PASS. Only the agent can
+// answer for the agent, so it writes the answer down where the shell can read
+// it.
+//
+// Format, positional and read line-by-line by --doctor:
+// 1: "ok" | "denied" | "error"
+// 2: timestamp
+// 3: optional single-line detail
+let micStatusPath = home.appendingPathComponent(".config/hark/agent-mic-status")
+
+// Same contract, same reason, for Accessibility — and it exists because
+// reading TCC.db instead produced a confident FALSE PASS on 2026-08-03.
+//
+// The row in TCC.db outlives the grant it describes. An ad-hoc signature's
+// designated requirement is a bare `cdhash`, so every rebuild is a new
+// identity; the old row stays, still reading auth_value=2, still drawing a
+// switched-ON toggle in System Settings, while the running binary is not
+// trusted at all. The doctor reported PASS from that row while the agent was
+// simultaneously alerting that it could not paste.
+//
+// Only the process itself can answer, via AXIsProcessTrusted(). So it writes
+// the answer down, exactly as the microphone probe does. Reading TCC.db also
+// needed Full Disk Access, which the doctor did not necessarily have.
+let accessibilityStatusPath = home.appendingPathComponent(
+ ".config/hark/agent-accessibility-status")
+
+// ============================================================================
+// Logging
+// ============================================================================
+
+let timestampFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ return f
+}()
+
+// Mirrors the server's logging discipline: diagnostics only, never transcript
+// content. rec's stderr names the device or the failure, not speech.
+func log(_ message: String) {
+ let line = "\(timestampFormatter.string(from: Date())) \(message)\n"
+ FileHandle.standardError.write(line.data(using: .utf8)!)
+
+ let dir = logPath.deletingLastPathComponent()
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+
+ guard let data = line.data(using: .utf8) else { return }
+ if let handle = try? FileHandle(forWritingTo: logPath) {
+ defer { try? handle.close() }
+ _ = try? handle.seekToEnd()
+ try? handle.write(contentsOf: data)
+ } else {
+ try? data.write(to: logPath)
+ }
+}
+
+func beep() {
+ // "Basso" is a built-in macOS alert sound, chosen because it reads as a
+ // failure tone rather than routine feedback.
+ NSSound(named: NSSound.Name("Basso"))?.play()
+}
+
+// ============================================================================
+// On-screen overlay
+// ============================================================================
+//
+// Replaces hs.alert. A non-activating floating panel: it must never steal
+// focus, because the whole point of this tool is that the transcript lands in
+// whatever the user was already typing into.
+
+final class Overlay {
+ static let shared = Overlay()
+
+ private var panel: NSPanel?
+ private var dismissTimer: Timer?
+
+ func show(_ text: String, duration: TimeInterval?) {
+ hide()
+
+ let label = NSTextField(wrappingLabelWithString: text)
+ label.font = .systemFont(ofSize: 15, weight: .medium)
+ label.textColor = .white
+ label.alignment = .center
+ label.isEditable = false
+ label.isSelectable = false
+ label.drawsBackground = false
+ label.preferredMaxLayoutWidth = 520
+
+ // CGFloat.greatestFiniteMagnitude spelled out: NSSize has Int, Double
+ // and CGFloat initializers, so the bare member is ambiguous.
+ let size = label.sizeThatFits(NSSize(width: 520, height: CGFloat.greatestFiniteMagnitude))
+ let padding: CGFloat = 22
+ let frame = NSRect(x: 0, y: 0, width: size.width + padding * 2, height: size.height + padding * 2)
+
+ // .nonactivatingPanel is the load-bearing flag - without it, showing
+ // the panel pulls keyboard focus away from the app the user is
+ // dictating into.
+ let panel = NSPanel(
+ contentRect: frame,
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.level = .floating
+ panel.ignoresMouseEvents = true
+ panel.hasShadow = true
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
+
+ let container = NSVisualEffectView(frame: frame)
+ container.material = .hudWindow
+ container.blendingMode = .behindWindow
+ container.state = .active
+ container.wantsLayer = true
+ container.layer?.cornerRadius = 14
+ container.layer?.masksToBounds = true
+
+ label.frame = NSRect(x: padding, y: padding, width: size.width, height: size.height)
+ container.addSubview(label)
+ panel.contentView = container
+
+ if let screen = NSScreen.main {
+ let visible = screen.visibleFrame
+ panel.setFrameOrigin(NSPoint(
+ x: visible.midX - frame.width / 2,
+ y: visible.minY + visible.height * 0.18
+ ))
+ }
+
+ panel.orderFrontRegardless()
+ self.panel = panel
+
+ if let duration {
+ dismissTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in
+ DispatchQueue.main.async { Overlay.shared.hide() }
+ }
+ }
+ }
+
+ func hide() {
+ dismissTimer?.invalidate()
+ dismissTimer = nil
+ panel?.orderOut(nil)
+ panel = nil
+ }
+}
+
+func alert(_ text: String, _ duration: TimeInterval = 6) {
+ Overlay.shared.show(text, duration: duration)
+}
+
+func failAlert(_ text: String, _ duration: TimeInterval = 7) {
+ beep()
+ alert(text, duration)
+}
+
+// ============================================================================
+// Configuration
+// ============================================================================
+
+struct ClientConfig: Decodable {
+ let server: String?
+ let key: String?
+}
+
+// Loopback default: the single-machine setup, where the server runs on this
+// same Mac. For two machines, set `server` to the transcribing machine's
+// private address.
+var serverURL = "http://127.0.0.1:8911/dictate"
+var harkKey = ""
+
+func loadConfig() {
+ guard let data = try? Data(contentsOf: configPath) else {
+ alert("hark: missing \(configPath.path) — run install-client.sh", 20)
+ return
+ }
+ guard let config = try? JSONDecoder().decode(ClientConfig.self, from: data) else {
+ alert("hark: \(configPath.path) is not valid JSON — run install-client.sh", 20)
+ return
+ }
+ if let server = config.server, !server.isEmpty { serverURL = server }
+ if let key = config.key { harkKey = key }
+
+ if harkKey.isEmpty {
+ alert("hark: no key configured in \(configPath.path)", 20)
+ }
+}
+
+// ============================================================================
+// Paste
+// ============================================================================
+
+func paste(_ text: String) {
+ // Deliberately NOT saving and restoring the previous clipboard contents.
+ // Leaving the transcript on the clipboard means a misfired paste - wrong
+ // window focused, target app swallowing the keystroke - is recoverable
+ // with a manual Cmd+V instead of having to re-speak the whole utterance.
+ // Do not "fix" this by adding save/restore.
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+
+ guard let source = CGEventSource(stateID: .combinedSessionState) else {
+ failAlert("hark: could not create an event source to paste with.")
+ return
+ }
+
+ // Setting .maskCommand explicitly rather than relying on ambient modifier
+ // state: the response can arrive while Ctrl and Alt are still physically
+ // held, and an inherited Ctrl+Alt+Cmd+V is not a paste in any app.
+ let v = CGKeyCode(kVK_ANSI_V)
+ guard let down = CGEvent(keyboardEventSource: source, virtualKey: v, keyDown: true),
+ let up = CGEvent(keyboardEventSource: source, virtualKey: v, keyDown: false)
+ else {
+ failAlert("hark: could not synthesize the paste keystroke.")
+ return
+ }
+ down.flags = .maskCommand
+ up.flags = .maskCommand
+
+ // Recorded so a failure to paste names its own cause. Everything up to
+ // here succeeds and logs "pasting N chars" whether or not the keystroke
+ // is ever delivered, so without this an untrusted process is
+ // indistinguishable from a dropped event.
+ // Re-checked and re-recorded at paste time, not just at launch. The grant
+ // can be revoked, or silently invalidated by a rebuild, while the process
+ // keeps running - and this is the moment it actually matters.
+ let trusted = AXIsProcessTrusted()
+ writeAccessibilityStatus(trusted)
+ if !trusted {
+ failAlert(
+ "hark: Accessibility is not granted, so the transcript cannot be pasted.\n"
+ + "It IS on the clipboard — press Cmd+V.\n"
+ + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark."
+ )
+ return
+ }
+
+ // NEVER follow this with Return. The user reviews the transcript before
+ // submitting it; auto-submit is a hard non-goal (see the design spec).
+ down.post(tap: .cghidEventTap)
+
+ // HOLD THE KEY. Posting key-up immediately after key-down produces a
+ // zero-duration keystroke that many apps silently drop - the events are
+ // delivered, nothing acts on them, and the transcript just never appears.
+ // hs.eventtap.keyStroke, the implementation this replaces, holds for
+ // 200 ms (`local keyDelay = 200000` then usleep between down and up), and
+ // that is the only difference between the two once the flags and the
+ // event tap match. Async rather than a sleep so the overlay's own timers
+ // are not stalled behind it.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+ up.post(tap: .cghidEventTap)
+ }
+}
+
+// ============================================================================
+// HTTP
+// ============================================================================
+//
+// Every failure branch both beeps AND names a likely cause. A silent failure
+// is the worst outcome: if dictation does nothing, the instinct is to try
+// again, and a second silent failure reads as "the mic is broken" when the
+// real cause might be a stale key or a downed tailnet link.
+
+// Best-effort extraction of the server's {"detail": "..."} error body, so its
+// already-specific explanation reaches the alert instead of being dropped.
+func extractDetail(_ data: Data?) -> String {
+ guard let data, !data.isEmpty else { return "(no response body)" }
+ if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let detail = object["detail"] as? String {
+ return detail
+ }
+ return String(data: data, encoding: .utf8) ?? "(unreadable response body)"
+}
+
+func send(_ audio: Data) {
+ guard let url = URL(string: serverURL) else {
+ failAlert("hark: \(serverURL) is not a valid URL — check \(configPath.path)")
+ return
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue(harkKey, forHTTPHeaderField: "X-Hark-Key")
+ request.setValue("audio/wav", forHTTPHeaderField: "Content-Type")
+ request.httpBody = audio
+ request.timeoutInterval = 120
+
+ log("sending \(audio.count) bytes to \(serverURL)")
+
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ DispatchQueue.main.async {
+ handleResponse(data: data, response: response, error: error)
+ }
+ }.resume()
+}
+
+func handleResponse(data: Data?, response: URLResponse?, error: Error?) {
+ // Connection-level failure: unreachable host, DNS failure, timeout,
+ // refused. Distinct from any HTTP status, and almost always the network
+ // path rather than hark itself.
+ if let error {
+ failAlert(
+ "hark: can't reach the server (\(serverURL)).\n"
+ + "Check the tailnet is up (tailscale status) and hark is running.\n"
+ + error.localizedDescription
+ )
+ return
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ failAlert("hark: got a non-HTTP response from \(serverURL).")
+ return
+ }
+
+ if http.statusCode == 200 {
+ guard let data,
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let text = object["text"] as? String
+ else {
+ failAlert("hark: 200 OK but the response wasn't the expected JSON.")
+ return
+ }
+
+ if text.isEmpty {
+ // Not an error: silence, or audio that transcribed to no
+ // alphanumeric content. Paste nothing.
+ alert("heard nothing", 1.5)
+ return
+ }
+
+ // Length only, never the transcript itself - mirroring the server's
+ // own discipline. The log is local, but there is no reason to put
+ // speech content in a log at all.
+ log("pasting \(text.count) chars")
+ paste(text)
+ return
+ }
+
+ let detail = extractDetail(data)
+
+ switch http.statusCode {
+ case 401:
+ failAlert(
+ "hark: 401 unauthorized — \(detail)\n"
+ + "Check that the key in \(configPath.path) matches the server's "
+ + "~/.config/hark/key (re-run install-client.sh to refetch it)."
+ )
+ case 415:
+ failAlert(
+ "hark: 415 unsupported media type — \(detail)\n"
+ + "This is a client bug (wrong Content-Type header), not a mic problem. "
+ + "Please report it."
+ )
+ case 400:
+ failAlert(
+ "hark: 400 bad request — \(detail)\n"
+ + "Almost certainly a microphone permission problem: check System "
+ + "Settings -> Privacy & Security -> Microphone -> turn hark ON."
+ )
+ case 503:
+ failAlert(
+ "hark: 503 — whisper-server is down on the server. \(detail)\n"
+ + "Check /tmp/hark-whisper.err on the server."
+ )
+ default:
+ failAlert("hark: unexpected HTTP \(http.statusCode) — \(detail)")
+ }
+}
+
+// ============================================================================
+// Recording lifecycle
+// ============================================================================
+
+// rec ships inside the bundle rather than being installed alongside it, so
+// the app is self-contained and a future codesign covers the recorder as a
+// nested binary without a second signing step.
+let recorderPath: URL? = Bundle.main.executableURL?
+ .deletingLastPathComponent()
+ .appendingPathComponent("rec")
+
+var recorder: Process?
+
+// rec's last one-line reason for exiting non-zero, so the alert the user
+// actually sees names the cause instead of pointing at a log file.
+var lastRecorderFailure: String?
+
+func startRecording() {
+ if recorder != nil { return } // already recording; guards a spurious double key-down
+
+ if harkKey.isEmpty {
+ failAlert("hark: no key configured — run install-client.sh or edit \(configPath.path)", 5)
+ return
+ }
+
+ guard let recorderPath, FileManager.default.isExecutableFile(atPath: recorderPath.path) else {
+ failAlert("hark: the bundled recorder is missing. Re-run install-client.sh.")
+ return
+ }
+
+ try? FileManager.default.removeItem(at: wavPath) // never read a stale WAV
+
+ Overlay.shared.show("● Recording…", duration: nil)
+
+ let process = Process()
+ process.executableURL = recorderPath
+ process.arguments = [wavPath.path]
+
+ let stderrPipe = Pipe()
+ process.standardError = stderrPipe
+
+ process.terminationHandler = { finished in
+ let stderrData = try? stderrPipe.fileHandleForReading.readToEnd()
+ let stderrText = String(data: stderrData ?? Data(), encoding: .utf8) ?? ""
+
+ DispatchQueue.main.async {
+ recorder = nil
+ lastRecorderFailure = nil
+
+ // rec catches SIGTERM, finalizes the WAV and exits 0, so unlike
+ // ffmpeg a non-zero exit here means something actually went wrong
+ // and its stderr is one explanatory line rather than a banner.
+ if finished.terminationStatus != 0 {
+ log("rec exited \(finished.terminationStatus): \(stderrText.trimmingCharacters(in: .whitespacesAndNewlines))")
+ var reason = stderrText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if reason.hasPrefix("rec: ") { reason = String(reason.dropFirst(5)) }
+ if !reason.isEmpty { lastRecorderFailure = reason }
+ }
+
+ Overlay.shared.hide()
+
+ // Sent with no settling delay. rec releases the AVAudioFile -
+ // which is what finalizes the WAV header - and stops the engine
+ // BEFORE exit, so by the time this runs the file is already
+ // complete. The process exit IS the guarantee; a fixed sleep here
+ // would be pure latency on every utterance.
+ sendRecording()
+ }
+ }
+
+ do {
+ try process.run()
+ recorder = process
+ } catch {
+ Overlay.shared.hide()
+ failAlert("hark: the recorder failed to start (\(recorderPath.path)): \(error.localizedDescription)", 5)
+ }
+}
+
+func stopRecording() {
+ guard let process = recorder else { return } // key released with nothing recording
+ // SIGTERM; rec finalizes the WAV header and exits 0. Deliberately does NOT
+ // clear `recorder` or hide the indicator - the termination handler does
+ // both, at the moment rec has actually exited.
+ process.terminate()
+}
+
+func sendRecording() {
+ guard let audio = try? Data(contentsOf: wavPath) else {
+ // rec deletes the file rather than leave an unusable one, and exits
+ // with a single explanatory line. Showing that line is what
+ // distinguishes a denied microphone from a muted one from a dead
+ // device; sending the user to a log to find out is how a permission
+ // failure gets misread as a transcription failure.
+ failAlert(
+ "hark: nothing was recorded.\n"
+ + (lastRecorderFailure ?? "See \(logPath.path) for the reason."),
+ 8
+ )
+ return
+ }
+
+ guard !audio.isEmpty else {
+ // Not a permission problem: rec settles that with TCC before it opens
+ // the device, and deletes the file rather than leave an empty one. A
+ // zero-byte file means rec died before finalizing the WAV header.
+ failAlert(
+ "hark: recorded a zero-byte file - rec exited before finalizing the WAV.\n"
+ + (lastRecorderFailure ?? "See \(logPath.path) for the reason."),
+ 8
+ )
+ return
+ }
+
+ send(audio)
+}
+
+// ============================================================================
+// Hotkey
+// ============================================================================
+
+var hotKeyRef: EventHotKeyRef?
+
+// A C function pointer, so it can capture nothing - startRecording and
+// stopRecording are globals for exactly this reason. Carbon dispatches these
+// on the main thread, which is also where every global they touch is mutated.
+let hotKeyHandler: EventHandlerUPP = { _, event, _ -> OSStatus in
+ guard let event else { return OSStatus(eventNotHandledErr) }
+ switch Int(GetEventKind(event)) {
+ case kEventHotKeyPressed:
+ startRecording()
+ case kEventHotKeyReleased:
+ stopRecording()
+ default:
+ return OSStatus(eventNotHandledErr)
+ }
+ return noErr
+}
+
+func registerHotKey() -> Bool {
+ var eventSpecs = [
+ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
+ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)),
+ ]
+
+ let installed = InstallEventHandler(
+ GetApplicationEventTarget(), hotKeyHandler, eventSpecs.count, &eventSpecs, nil, nil
+ )
+ guard installed == noErr else {
+ log("InstallEventHandler failed: \(installed)")
+ return false
+ }
+
+ // 'HARK' as an OSType, the conventional four-char signature.
+ let hotKeyID = EventHotKeyID(signature: OSType(0x4841_524B), id: 1)
+
+ // Ctrl+Alt+Space, NOT Option+Cmd+Space: the latter is macOS's built-in
+ // Finder search shortcut and the system wins that fight.
+ let registered = RegisterEventHotKey(
+ UInt32(kVK_Space),
+ UInt32(controlKey | optionKey),
+ hotKeyID,
+ GetApplicationEventTarget(),
+ 0,
+ &hotKeyRef
+ )
+ guard registered == noErr else {
+ log("RegisterEventHotKey failed: \(registered)")
+ return false
+ }
+ return true
+}
+
+// ============================================================================
+// Startup self-check: Accessibility
+// ============================================================================
+//
+// The hotkey itself does not need Accessibility - RegisterEventHotKey works
+// without it. The Cmd+V does: CGEvent.post is refused for an untrusted
+// process, silently. So recording would work, transcription would work, and
+// nothing would ever appear. Check it loudly at startup instead.
+//
+// Unlike the Hammerspoon client, which could only nag, this can actually
+// trigger the system prompt - the app is the thing being granted, so it is
+// allowed to ask.
+
+func writeAccessibilityStatus(_ trusted: Bool) {
+ try? FileManager.default.createDirectory(
+ at: accessibilityStatusPath.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+ let body = "\(trusted ? "ok" : "denied")\n\(timestampFormatter.string(from: Date()))\n"
+ try? body.write(to: accessibilityStatusPath, atomically: true, encoding: .utf8)
+}
+
+func checkAccessibility() {
+ let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
+ let trusted = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
+ writeAccessibilityStatus(trusted)
+ guard !trusted else { return }
+
+ alert(
+ "hark: Accessibility is NOT granted.\n"
+ + "Recording will work but the transcript can never be pasted.\n"
+ + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark.",
+ 20
+ )
+}
+
+// ============================================================================
+// Startup self-check: Microphone
+// ============================================================================
+//
+// macOS's Microphone pane has no "+" button - it lists only apps that have
+// ALREADY REQUESTED access. On a fresh install hark has never asked, so it
+// does not appear, so there is nothing to toggle. The only way to make the
+// consent dialog appear is to actually try to open the mic, which is what
+// this probe does at startup rather than waiting for the first hotkey press.
+//
+// rec runs as this agent's CHILD, so TCC attributes the request to the agent
+// (the responsible app) and the Info.plist usage string is the one shown.
+
+func writeMicStatus(_ status: String, _ detail: String?) {
+ try? FileManager.default.createDirectory(
+ at: micStatusPath.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+
+ var body = "\(status)\n\(timestampFormatter.string(from: Date()))\n"
+ if let detail {
+ // One line, always third: --doctor reads it positionally and rec's
+ // stderr can carry newlines.
+ let collapsed = detail.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
+ body += "\(collapsed)\n"
+ }
+ try? body.write(to: micStatusPath, atomically: true, encoding: .utf8)
+}
+
+func probeMicrophone() {
+ guard let recorderPath, FileManager.default.isExecutableFile(atPath: recorderPath.path) else {
+ // Not a permission problem - the recorder simply is not there. Leave
+ // the status file untouched rather than writing a misleading "denied".
+ log("microphone probe skipped - no bundled recorder")
+ return
+ }
+
+ try? FileManager.default.removeItem(at: micProbePath)
+
+ let process = Process()
+ process.executableURL = recorderPath
+ process.arguments = [micProbePath.path, "0.4"]
+
+ let stderrPipe = Pipe()
+ process.standardError = stderrPipe
+
+ process.terminationHandler = { finished in
+ let stderrData = try? stderrPipe.fileHandleForReading.readToEnd()
+ let stderrText = (String(data: stderrData ?? Data(), encoding: .utf8) ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ DispatchQueue.main.async {
+ try? FileManager.default.removeItem(at: micProbePath)
+
+ if finished.terminationStatus == 0 {
+ writeMicStatus("ok", nil)
+ return // silent on success - do not nag on every launch
+ }
+
+ // rec asks TCC before it opens the device and reserves exit 3 for
+ // the answer. This used to be inferred from an empty capture,
+ // which cannot work: an ungranted process still receives buffers,
+ // full length and all zeros (hark issue #9).
+ let denied = finished.terminationStatus == 3
+ let reason = "rec exited \(finished.terminationStatus)."
+ + (stderrText.isEmpty ? "" : " stderr: \(stderrText)")
+
+ writeMicStatus(denied ? "denied" : "error", reason)
+ log("microphone probe failed - \(reason)")
+
+ if denied {
+ alert(
+ "hark needs Microphone permission.\n"
+ + "A consent dialog should have appeared just now - click Allow.\n"
+ + "If you missed it: System Settings -> Privacy & Security -> "
+ + "Microphone -> turn ON hark.",
+ 20
+ )
+ } else {
+ alert(
+ "hark: the microphone probe failed, but not on permission.\n"
+ + reason + "\n"
+ + "Run ./install-client.sh --doctor for the full picture.",
+ 20
+ )
+ }
+ }
+ }
+
+ do {
+ try process.run()
+ } catch {
+ writeMicStatus("error", "rec failed to start: \(error.localizedDescription)")
+ }
+}
+
+// ============================================================================
+// Main
+// ============================================================================
+
+let app = NSApplication.shared
+
+// .accessory rather than .regular: no Dock icon, no menu bar, and - critically
+// - activating the app never takes focus from whatever the user is dictating
+// into. LSUIElement in Info.plist covers launch; this covers the running case.
+app.setActivationPolicy(.accessory)
+
+log("hark-agent \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") starting")
+
+loadConfig()
+
+if !registerHotKey() {
+ failAlert(
+ "hark: could not register the Ctrl+Alt+Space hotkey.\n"
+ + "Another app is probably already using it.",
+ 20
+ )
+} else {
+ alert("hark loaded", 1.5)
+}
+
+checkAccessibility()
+probeMicrophone()
+
+app.run()
diff --git a/client/rec.swift b/client/rec.swift
index 7e0d61c..a5e4f62 100644
--- a/client/rec.swift
+++ b/client/rec.swift
@@ -59,8 +59,13 @@ let maxSeconds = args.count >= 3 ? Double(args[2]) : nil
// So a frame count can never see a denial - and neither can the sample-rate
// guard below, because format negotiation succeeds under denial too. The only
// way to learn the answer is to ask TCC for it.
+// Deliberately does NOT name the app to enable. rec is spawned by both
+// clients, and TCC attributes the grant to whichever is RESPONSIBLE - so the
+// row to switch on says "Hammerspoon" under the Lua client and "hark" under
+// the native agent. Naming one sent users to look for a row that was never
+// going to be there.
let permissionHelp = "System Settings -> Privacy & Security -> Microphone "
- + "-> turn Hammerspoon ON"
+ + "-> turn on the app that launched this (hark, or Hammerspoon)"
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
diff --git a/install-agent.sh b/install-agent.sh
new file mode 100755
index 0000000..3fe9b79
--- /dev/null
+++ b/install-agent.sh
@@ -0,0 +1,533 @@
+#!/usr/bin/env bash
+#
+# hark — native agent setup.
+#
+# Installs the Swift agent (client/agent/) as ~/Applications/hark.app and
+# registers it as a LaunchAgent so it starts at login. This is the eventual
+# replacement for install-client.sh's Hammerspoon path.
+#
+# ./install-agent.sh install or update the agent
+# ./install-agent.sh --doctor read-only diagnosis, changes nothing
+# ./install-agent.sh --uninstall remove the agent and its LaunchAgent
+#
+# WHY THIS IS A SEPARATE SCRIPT
+#
+# The agent and the Hammerspoon client are designed to coexist while you
+# migrate, so nothing here touches ~/.hammerspoon or install-client.sh. When
+# the Lua client is deleted this script folds back into install-client.sh; see
+# GitHub issue #2.
+#
+# THEY CANNOT BOTH HOLD THE HOTKEY.
+#
+# Ctrl+Alt+Space is a system-wide registration and exactly one process gets
+# it. Whichever of the two starts first wins, and the loser reports that it
+# could not register. Coexist means "both installed, one running" - not "both
+# listening". This script quits Hammerspoon for you unless --keep-hammerspoon
+# is given.
+#
+# CONFIG MIGRATION
+#
+# ~/.hammerspoon/hark-config.lua (Lua) becomes ~/.config/hark/client.json.
+# The old file is read but never modified, so rolling back to the Hammerspoon
+# client is just quitting the agent and relaunching Hammerspoon.
+
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+BUILD_DIR="$REPO_DIR/build"
+APP_SRC="$BUILD_DIR/hark.app"
+APP_DIR="$HOME/Applications"
+APP_DST="$APP_DIR/hark.app"
+
+HARK_CONFIG_DIR="$HOME/.config/hark"
+CLIENT_CONFIG="$HARK_CONFIG_DIR/client.json"
+MIC_STATUS="$HARK_CONFIG_DIR/agent-mic-status"
+ACCESSIBILITY_STATUS="$HARK_CONFIG_DIR/agent-accessibility-status"
+# The code identity the last install was granted against. See
+# reset_stale_grants_on_identity_change().
+INSTALLED_CDHASH="$HARK_CONFIG_DIR/.agent-cdhash"
+SERVER_KEY="$HARK_CONFIG_DIR/key"
+
+LEGACY_CONFIG="$HOME/.hammerspoon/hark-config.lua"
+
+LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
+AGENT_LABEL="com.drycodeworks.hark-agent"
+AGENT_PLIST="$LAUNCH_AGENTS/$AGENT_LABEL.plist"
+
+DEFAULT_SERVER="http://127.0.0.1:8911/dictate"
+
+log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
+err() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; }
+
+doctor_failures=0
+doctor_pass() { printf ' \033[1;32mPASS\033[0m %s\n' "$1"; }
+doctor_fail() {
+ printf ' \033[1;31mFAIL\033[0m %s\n' "$1"
+ printf ' \033[1;33mfix:\033[0m %s\n' "$2"
+ doctor_failures=$((doctor_failures + 1))
+}
+
+# ==============================================================================
+# Config
+# ==============================================================================
+
+# Extracts a quoted field from the legacy Lua config, e.g. for a line
+# ` server = "http://...",` prints `http://...`.
+legacy_field() {
+ local field="$1"
+ [[ -f "$LEGACY_CONFIG" ]] || return 1
+ local value
+ value="$(grep -E "^[[:space:]]*${field}[[:space:]]*=" "$LEGACY_CONFIG" 2>/dev/null \
+ | sed -E 's/^[^"]*"([^"]*)".*/\1/' || true)"
+ [[ -n "$value" ]] || return 1
+ printf '%s' "$value"
+}
+
+# Reads a string field out of client.json without needing jq. Deliberately
+# narrow: these two fields are written by this script, so the shape is known.
+json_field() {
+ local field="$1"
+ [[ -f "$CLIENT_CONFIG" ]] || return 1
+ local value
+ value="$(grep -E "\"${field}\"[[:space:]]*:" "$CLIENT_CONFIG" 2>/dev/null \
+ | sed -E 's/.*"'"${field}"'"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' || true)"
+ [[ -n "$value" ]] || return 1
+ printf '%s' "$value"
+}
+
+# Escapes the two characters that can appear in a key or URL and would break
+# the JSON we emit. Keys are base64-ish and URLs are plain, so this is a
+# guard rather than a general-purpose escaper.
+json_escape() {
+ printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
+}
+
+write_client_config() {
+ local server="$1" key="$2"
+ mkdir -p "$HARK_CONFIG_DIR"
+ # Written 600 BEFORE the secret goes in, so there is no window where the
+ # key exists in a world-readable file.
+ : > "$CLIENT_CONFIG"
+ chmod 600 "$CLIENT_CONFIG"
+ cat > "$CLIENT_CONFIG" < cat ~/.config/hark/key\n'
+ exit 1
+ fi
+
+ write_client_config "$server" "$key"
+ log "wrote $CLIENT_CONFIG (600) — server: $server"
+}
+
+# ==============================================================================
+# Stale TCC grants
+# ==============================================================================
+#
+# An ad-hoc signature's designated requirement is a bare content hash:
+#
+# designated => cdhash H"6836bec46e8c7d394cf1ba94421ff18a31674867"
+#
+# so every rebuild is a new code identity and the Accessibility grant stops
+# applying. What macOS does NOT do is tidy up: the old row survives with
+# auth_value=2 and System Settings keeps drawing a switched-ON toggle for a
+# binary nothing trusts. Observed twice on 2026-08-03, and it is genuinely
+# misleading - you go to grant the permission, find it already granted, and
+# conclude the problem is somewhere else.
+#
+# Toggling it off and on by hand works. So does this, without the detour.
+#
+# Only Accessibility is reset, for two reasons. It is the grant observed to
+# break on rebuild, and the microphone path already tells the truth on its own:
+# the agent's probe actually runs rec and reports what happened, so a stale
+# microphone row cannot produce a false PASS the way a stale Accessibility row
+# did. Resetting it anyway would cost a consent dialog for nothing.
+#
+# A Developer ID signature makes this whole function dead code, because the
+# requirement becomes the certificate rather than the hash.
+current_cdhash() {
+ codesign -dvvv "$APP_DST" 2>&1 | sed -n 's/^CDHash=//p' | head -1
+}
+
+reset_stale_grants_on_identity_change() {
+ local new_hash old_hash=""
+ new_hash="$(current_cdhash)"
+ [[ -n "$new_hash" ]] || return 0
+ [[ -f "$INSTALLED_CDHASH" ]] && old_hash="$(cat "$INSTALLED_CDHASH")"
+
+ mkdir -p "$HARK_CONFIG_DIR"
+ printf '%s' "$new_hash" > "$INSTALLED_CDHASH"
+
+ # First install, or the same binary reinstalled: nothing to invalidate.
+ [[ -n "$old_hash" && "$old_hash" != "$new_hash" ]] || return 0
+
+ warn "the agent binary changed (${old_hash:0:12}… -> ${new_hash:0:12}…)."
+ warn "Ad-hoc signing ties TCC grants to that hash, so the Accessibility grant"
+ warn "no longer applies — and macOS would still show its toggle switched ON."
+ if tccutil reset Accessibility "$AGENT_LABEL" >/dev/null 2>&1; then
+ warn "Cleared the stale entry. You will be asked to grant it again."
+ else
+ warn "Could not clear it automatically. Toggle hark OFF and back ON in"
+ warn "System Settings -> Privacy & Security -> Accessibility."
+ fi
+}
+
+# ==============================================================================
+# LaunchAgent
+# ==============================================================================
+#
+# RunAtLoad only, no KeepAlive. A crashed agent should stay down and be
+# noticed, not be silently resurrected into a crash loop that looks like
+# "the hotkey is flaky".
+#
+# ProgramArguments points INSIDE the bundle. That is deliberate and is what
+# keeps TCC attributing the microphone and Accessibility grants to
+# com.drycodeworks.hark-agent: the executable is covered by the bundle's code
+# signature, so its identity resolves to the bundle regardless of who exec'd
+# it. `open -a` would work too but gives launchd nothing to supervise.
+
+write_plist() {
+ mkdir -p "$LAUNCH_AGENTS"
+ cat > "$AGENT_PLIST" <
+
+
+
+ Label
+ ${AGENT_LABEL}
+ ProgramArguments
+
+ ${APP_DST}/Contents/MacOS/hark-agent
+
+ RunAtLoad
+
+ ProcessType
+ Interactive
+ StandardOutPath
+ /tmp/hark-agent.out
+ StandardErrorPath
+ /tmp/hark-agent.err
+
+
+EOF
+ log "wrote $AGENT_PLIST"
+}
+
+agent_loaded() {
+ # Captured into a variable first, NOT piped. `launchctl list | grep -q X`
+ # under `set -o pipefail` reports every service as not-loaded: grep exits at
+ # the first match, launchctl takes SIGPIPE, and pipefail propagates it.
+ local listing
+ listing="$(launchctl list 2>/dev/null || true)"
+ printf '%s' "$listing" | grep -q "$AGENT_LABEL"
+}
+
+reload_agent() {
+ local domain
+ domain="gui/$(id -u)"
+ if agent_loaded; then
+ launchctl bootout "$domain/$AGENT_LABEL" 2>/dev/null || true
+ # `Bootstrap failed: 5: Input/output error` right after a bootout is
+ # usually the old instance still tearing down, not a bad plist.
+ sleep 1
+ fi
+ if ! launchctl bootstrap "$domain" "$AGENT_PLIST" 2>/dev/null; then
+ sleep 2
+ launchctl bootstrap "$domain" "$AGENT_PLIST" 2>/dev/null || {
+ err "launchctl bootstrap failed for $AGENT_LABEL"
+ err "try: launchctl bootout $domain/$AGENT_LABEL && launchctl bootstrap $domain $AGENT_PLIST"
+ return 1
+ }
+ fi
+ log "loaded $AGENT_LABEL"
+}
+
+# ==============================================================================
+# Doctor
+# ==============================================================================
+
+check_app_installed() {
+ if [[ ! -d "$APP_DST" ]]; then
+ doctor_fail "hark.app is installed" "run ./install-agent.sh"
+ return 1
+ fi
+ doctor_pass "hark.app is installed at $APP_DST"
+}
+
+check_signature() {
+ if [[ ! -d "$APP_DST" ]]; then
+ doctor_fail "hark.app has a valid signature" "run ./install-agent.sh"
+ return 1
+ fi
+ if ! codesign --verify --strict "$APP_DST" 2>/dev/null; then
+ doctor_fail "hark.app has a valid signature" \
+ "rebuild it: ./client/agent/build-agent.sh && ./install-agent.sh"
+ return 1
+ fi
+ local identity
+ identity="$(codesign -dvv "$APP_DST" 2>&1 | grep -E '^Signature=' | cut -d= -f2- || true)"
+ doctor_pass "hark.app signature is valid (${identity:-unknown})"
+}
+
+check_config() {
+ if [[ ! -f "$CLIENT_CONFIG" ]]; then
+ doctor_fail "$CLIENT_CONFIG exists" "run ./install-agent.sh"
+ return 1
+ fi
+ local perms
+ perms="$(stat -f '%OLp' "$CLIENT_CONFIG")"
+ if [[ "$perms" != "600" ]]; then
+ doctor_fail "$CLIENT_CONFIG is 600 (it holds a secret)" "chmod 600 $CLIENT_CONFIG"
+ return 1
+ fi
+ if ! json_field key >/dev/null; then
+ doctor_fail "$CLIENT_CONFIG has a key" "run ./install-agent.sh"
+ return 1
+ fi
+ doctor_pass "$CLIENT_CONFIG is present, 600, and has a key"
+}
+
+check_agent_running() {
+ if ! agent_loaded; then
+ doctor_fail "the agent is loaded in launchd" "run ./install-agent.sh"
+ return 1
+ fi
+ if ! pgrep -f "$APP_DST/Contents/MacOS/hark-agent" >/dev/null 2>&1; then
+ doctor_fail "the agent process is running" \
+ "check /tmp/hark-agent.err and ~/Library/Logs/hark-agent.log"
+ return 1
+ fi
+ doctor_pass "the agent is loaded and running"
+}
+
+# Reads the outcome the AGENT's own startup probe wrote. This is the only
+# reliable way to learn whether the agent can reach the microphone: TCC
+# attributes a request to the responsible process, and rec runs as the
+# agent's child — so running rec from THIS shell would test the terminal's
+# grant, a different permission that produces a confidently wrong PASS.
+# Never run rec from here to "test" this; read what the agent wrote.
+check_mic() {
+ if [[ ! -f "$MIC_STATUS" ]]; then
+ doctor_fail "the agent can reach the microphone" \
+ "the agent hasn't probed yet — is it running? (./install-agent.sh)"
+ return 1
+ fi
+ local status detail
+ status="$(sed -n '1p' "$MIC_STATUS")"
+ detail="$(sed -n '3p' "$MIC_STATUS" || true)"
+ case "$status" in
+ ok)
+ doctor_pass "the agent can reach the microphone"
+ ;;
+ denied)
+ doctor_fail "the agent can reach the microphone" \
+ "System Settings -> Privacy & Security -> Microphone -> turn ON hark"
+ ;;
+ *)
+ doctor_fail "the agent can reach the microphone (probe said: $status)" \
+ "${detail:-see ~/Library/Logs/hark-agent.log}"
+ ;;
+ esac
+}
+
+# Reads what the agent's own AXIsProcessTrusted() call recorded — NOT TCC.db.
+#
+# This check used to query TCC.db directly and it produced a confident FALSE
+# PASS on 2026-08-03: it reported "Accessibility is granted" while the agent
+# was simultaneously alerting on screen that it could not paste. The row in
+# TCC.db outlives the grant it describes. An ad-hoc signature's designated
+# requirement is a bare `cdhash`, so every rebuild is a new identity — the old
+# row survives with auth_value=2, System Settings keeps drawing a switched-ON
+# toggle, and the running binary is trusted by nobody.
+#
+# So the same rule as the microphone applies for the same underlying reason:
+# only the process can answer for the process. Reading TCC.db also required
+# Full Disk Access, which this script does not necessarily have.
+check_accessibility() {
+ local status_file="$ACCESSIBILITY_STATUS"
+ if [[ ! -f "$status_file" ]]; then
+ # An agent older than this check, or one that has not started yet. Not a
+ # failure, and deliberately not a PASS either.
+ printf ' \033[1;33mSKIP\033[0m Accessibility (the agent has not reported yet)\n'
+ printf ' if dictation records but nothing pastes, that is this permission:\n'
+ printf ' System Settings -> Privacy & Security -> Accessibility -> hark\n'
+ return 0
+ fi
+ if [[ "$(sed -n '1p' "$status_file")" == "ok" ]]; then
+ doctor_pass "Accessibility is granted (agent reported at $(sed -n '2p' "$status_file"))"
+ else
+ doctor_fail "Accessibility is granted to hark" \
+ "System Settings -> Privacy & Security -> Accessibility -> turn ON hark, then restart the agent"
+ fi
+}
+
+check_hotkey_conflict() {
+ if pgrep -x Hammerspoon >/dev/null 2>&1; then
+ doctor_fail "nothing else holds Ctrl+Alt+Space" \
+ "Hammerspoon is running and owns the hotkey — quit it (osascript -e 'quit app \"Hammerspoon\"')"
+ return 1
+ fi
+ doctor_pass "nothing else is holding Ctrl+Alt+Space"
+}
+
+run_doctor() {
+ printf '\nhark agent diagnostics\n\n'
+ check_app_installed || true
+ check_signature || true
+ check_config || true
+ check_agent_running || true
+ check_hotkey_conflict || true
+ check_mic || true
+ check_accessibility || true
+ printf '\n'
+ if [[ "$doctor_failures" -gt 0 ]]; then
+ err "$doctor_failures check(s) failed"
+ return 1
+ fi
+ log "all checks passed"
+}
+
+# ==============================================================================
+# Uninstall
+# ==============================================================================
+
+run_uninstall() {
+ if agent_loaded; then
+ launchctl bootout "gui/$(id -u)/$AGENT_LABEL" 2>/dev/null || true
+ log "unloaded $AGENT_LABEL"
+ fi
+ rm -f "$AGENT_PLIST"
+ rm -rf "$APP_DST"
+ log "removed $APP_DST and $AGENT_PLIST"
+ # client.json is deliberately left in place: it holds the shared secret and
+ # is what a reinstall (or the Hammerspoon client) would want back.
+ log "left $CLIENT_CONFIG alone — delete it by hand if you meant to."
+}
+
+# ==============================================================================
+# Main
+# ==============================================================================
+
+# Sourcing this file defines the helpers and check_* functions and stops here,
+# so the test suite can exercise them without running an install. Everything
+# below this line only runs when the script is executed directly.
+if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
+ return 0
+fi
+
+KEEP_HAMMERSPOON=0
+MODE="install"
+for arg in "$@"; do
+ case "$arg" in
+ --doctor) MODE="doctor" ;;
+ --uninstall) MODE="uninstall" ;;
+ --keep-hammerspoon) KEEP_HAMMERSPOON=1 ;;
+ -h|--help)
+ sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *)
+ err "unknown argument: $arg (try --help)"
+ exit 2
+ ;;
+ esac
+done
+
+case "$MODE" in
+ doctor) run_doctor; exit $? ;;
+ uninstall) run_uninstall; exit 0 ;;
+esac
+
+log "building the agent"
+"$REPO_DIR/client/agent/build-agent.sh" "$BUILD_DIR"
+
+log "installing to $APP_DST"
+mkdir -p "$APP_DIR"
+# Replaced wholesale rather than copied over: a stale file left inside the
+# bundle invalidates the signature, and the failure surfaces much later as an
+# unexplained TCC re-prompt.
+rm -rf "$APP_DST"
+cp -R "$APP_SRC" "$APP_DST"
+
+# Must run AFTER the copy (it hashes the installed bundle) and BEFORE the agent
+# restarts, so the agent's prompt lands on a cleared entry rather than a stale
+# one that claims to be granted already.
+reset_stale_grants_on_identity_change
+
+resolve_config
+
+if [[ "$KEEP_HAMMERSPOON" -eq 0 ]] && pgrep -x Hammerspoon >/dev/null 2>&1; then
+ warn "Hammerspoon is running and owns Ctrl+Alt+Space — quitting it so the agent can register."
+ warn "Pass --keep-hammerspoon to leave it alone (the agent will then fail to bind the hotkey)."
+ osascript -e 'quit app "Hammerspoon"' 2>/dev/null || true
+ sleep 1
+fi
+
+# Cleared BEFORE the agent restarts, so the wait below observes THIS run's
+# probe rather than instantly succeeding on the previous run's file.
+rm -f "$MIC_STATUS"
+
+write_plist
+reload_agent
+
+# Wait for the agent's microphone probe to report, rather than sleeping a
+# fixed interval. The probe cannot finish until the user has answered the
+# consent dialog, so any fixed wait either races a human or pads every
+# already-granted re-run. A 3s sleep here reported a spurious FAIL on the
+# first install, with the prompt still on screen.
+printf '==> waiting for the microphone probe (answer the prompt if one appears)'
+probe_started_at="$(date +%s)"
+while [[ ! -f "$MIC_STATUS" ]]; do
+ if [[ $(($(date +%s) - probe_started_at)) -ge 45 ]]; then
+ printf '\n'
+ warn "the probe did not report within 45s — the doctor below may be stale"
+ break
+ fi
+ printf '.'
+ sleep 1
+done
+printf '\n'
+
+printf '\n'
+if run_doctor; then
+ printf '\n'
+ log "setup complete — hold Ctrl+Alt+Space and speak."
+else
+ printf '\n'
+ warn "setup finished with failing checks — see the fixes above."
+ warn "Both permission prompts only appear once the agent asks, so re-run"
+ warn "./install-agent.sh --doctor after granting them."
+ exit 1
+fi
diff --git a/tests/test_install_agent.py b/tests/test_install_agent.py
new file mode 100644
index 0000000..4700b07
--- /dev/null
+++ b/tests/test_install_agent.py
@@ -0,0 +1,499 @@
+"""Guard install-agent.sh and the agent bundle's metadata.
+
+The agent's failure modes are almost all silent. A missing Info.plist key
+kills the process the moment it opens the microphone; a wrong bundle
+identifier orphans every TCC grant with the toggle still showing ON; a doctor
+that reports a denied microphone as PASS sends the user looking somewhere
+else entirely. None of those announce themselves, so they get asserted here.
+
+The scripted checks are exercised by sourcing install-agent.sh, which stops at
+its source guard with every function defined and nothing installed.
+"""
+
+import plistlib
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parent.parent
+SCRIPT = REPO / "install-agent.sh"
+INFO_PLIST = REPO / "client" / "agent" / "Info.plist"
+AGENT_SWIFT = REPO / "client" / "agent" / "hark-agent.swift"
+BUILD_SCRIPT = REPO / "client" / "agent" / "build-agent.sh"
+
+BUNDLE_ID = "com.drycodeworks.hark-agent"
+
+# doctor_pass/doctor_fail colour their markers unconditionally.
+_ANSI = re.compile(r"\x1b\[[0-9;]*m")
+
+macos_only = pytest.mark.skipif(
+ sys.platform != "darwin", reason="uses BSD stat / macOS-only tooling"
+)
+
+
+def run_sourced(body: str, env_overrides: dict[str, str] | None = None) -> tuple[int, str]:
+ """Source install-agent.sh, then run `body` with its functions available."""
+ script = f'set -uo pipefail\nsource "{SCRIPT}"\n{body}\n'
+ proc = subprocess.run(
+ ["bash", "-c", script],
+ capture_output=True,
+ text=True,
+ env={**dict(__import__("os").environ), **(env_overrides or {})},
+ )
+ return proc.returncode, _ANSI.sub("", proc.stdout + proc.stderr)
+
+
+# ---------------------------------------------------------------------------
+# Sourcing must not install anything
+# ---------------------------------------------------------------------------
+
+
+def test_sourcing_the_script_installs_nothing(tmp_path):
+ rc, out = run_sourced("echo SOURCED", {"HOME": str(tmp_path)})
+ assert rc == 0, out
+ assert "SOURCED" in out
+ assert not (tmp_path / "Applications").exists()
+ assert not (tmp_path / "Library" / "LaunchAgents").exists()
+ assert not (tmp_path / ".config").exists()
+
+
+# ---------------------------------------------------------------------------
+# Info.plist — the keys whose absence is fatal and silent
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def plist():
+ return plistlib.loads(INFO_PLIST.read_bytes())
+
+
+@pytest.fixture(scope="module")
+def source():
+ return AGENT_SWIFT.read_text()
+
+
+class TestInfoPlist:
+ def test_declares_a_microphone_usage_description(self, plist):
+ # Without this key macOS does not show an empty prompt - it kills the
+ # process outright the moment it touches the device.
+ assert plist["NSMicrophoneUsageDescription"].strip()
+
+ def test_the_usage_description_explains_the_ask(self, plist):
+ # This string IS the consent dialog. A placeholder here is a real
+ # defect, not a cosmetic one.
+ text = plist["NSMicrophoneUsageDescription"].lower()
+ assert "hark" in text
+ assert any(word in text for word in ("dictat", "voice", "transcri"))
+
+ def test_is_a_background_agent(self, plist):
+ # A Dock icon for a process with no clickable window is noise, and
+ # .regular activation would let the overlay steal focus.
+ assert plist["LSUIElement"] is True
+
+ def test_bundle_identifier_is_the_one_tcc_will_key_grants_to(self, plist):
+ assert plist["CFBundleIdentifier"] == BUNDLE_ID
+
+ def test_bundle_id_does_not_collide_with_the_server_launchd_label(self, plist):
+ # The server's label is com.drycodeworks.hark. Different namespaces, so
+ # this is about keeping `launchctl list | grep` unambiguous.
+ assert plist["CFBundleIdentifier"] != "com.drycodeworks.hark"
+
+ def test_executable_name_matches_what_the_build_produces(self, plist):
+ assert plist["CFBundleExecutable"] == "hark-agent"
+ assert f'MacOS/{plist["CFBundleExecutable"]}' in BUILD_SCRIPT.read_text()
+
+
+class TestEntitlements:
+ """The hardened runtime turns a missing entitlement into a non-event.
+
+ Observed 2026-08-03: with `--options runtime` but no
+ com.apple.security.device.audio-input, tccd logs "Policy disallows prompt
+ ... access to kTCCServiceMicrophone denied" and never shows a dialog. The
+ app therefore never appears in the Microphone pane at all — that pane
+ lists only apps that have successfully requested — and the code sees
+ .denied instantly, indistinguishable from a real user refusal. Nothing
+ short of the unified log names the cause.
+ """
+
+ ENTITLEMENTS = REPO / "client" / "agent" / "hark-agent.entitlements"
+
+ def test_declares_audio_input(self):
+ ents = plistlib.loads(self.ENTITLEMENTS.read_bytes())
+ assert ents.get("com.apple.security.device.audio-input") is True
+
+ def test_the_build_signs_both_binaries_with_it(self):
+ # The app is the responsible process tccd checks the entitlement on;
+ # rec is the process that actually opens the device. Signing only one
+ # of them reintroduces the same silent denial.
+ build = BUILD_SCRIPT.read_text()
+ assert build.count("--entitlements") >= 2
+
+ def test_the_build_fails_when_the_entitlement_is_missing(self):
+ # A signature that merely *verifies* proves nothing here: the bundle
+ # signs, launches and runs fine without the entitlement and only fails
+ # at the microphone, where it looks like a permission problem rather
+ # than a build problem. So the build asserts it explicitly.
+ build = BUILD_SCRIPT.read_text()
+ assert "com.apple.security.device.audio-input" in build
+ assert "codesign -d --entitlements" in build
+
+ def test_hardened_runtime_stays_on(self):
+ # Dropping --options runtime would also fix the microphone, and would
+ # break notarization later instead. The entitlement is the right fix.
+ assert "--options runtime" in BUILD_SCRIPT.read_text()
+
+
+def test_rec_permission_help_does_not_name_a_single_app():
+ # rec is spawned by both clients and TCC attributes the grant to whichever
+ # is responsible, so the row to enable reads "Hammerspoon" under the Lua
+ # client and "hark" under the agent. Naming one sends half the users
+ # looking for a row that was never going to be there.
+ rec = (REPO / "client" / "rec.swift").read_text()
+ start = rec.index("let permissionHelp")
+ help_text = rec[start : rec.index("\n\n", start)]
+ assert "hark" in help_text and "Hammerspoon" in help_text
+
+
+def test_launchagent_label_matches_the_bundle_id():
+ # Not required by macOS, but a mismatch makes every diagnostic ambiguous -
+ # and check_accessibility below looks the client up in TCC.db BY this
+ # string, where the value that matters is the bundle id.
+ assert f'AGENT_LABEL="{BUNDLE_ID}"' in SCRIPT.read_text()
+
+
+# ---------------------------------------------------------------------------
+# Config: JSON the Swift side can actually decode
+# ---------------------------------------------------------------------------
+
+
+class TestClientConfig:
+ def test_written_config_is_valid_json_with_both_fields(self, tmp_path):
+ import json
+
+ rc, out = run_sourced(
+ 'write_client_config "http://example:8911/dictate" "s3cr3t"',
+ {"HOME": str(tmp_path)},
+ )
+ assert rc == 0, out
+ written = json.loads((tmp_path / ".config/hark/client.json").read_text())
+ assert written == {"server": "http://example:8911/dictate", "key": "s3cr3t"}
+
+ @macos_only
+ def test_written_config_is_600_because_it_holds_a_secret(self, tmp_path):
+ rc, out = run_sourced(
+ 'write_client_config "http://x/dictate" "k"', {"HOME": str(tmp_path)}
+ )
+ assert rc == 0, out
+ mode = (tmp_path / ".config/hark/client.json").stat().st_mode & 0o777
+ assert mode == 0o600
+
+ def test_a_key_containing_quotes_does_not_produce_broken_json(self, tmp_path):
+ # Keys are base64-ish today, so this is a guard rather than a fix for
+ # something observed. Broken JSON here is silent: the agent alerts
+ # "not valid JSON" and the hotkey does nothing.
+ import json
+
+ rc, out = run_sourced(
+ r"""write_client_config 'http://x/dictate' 'a"b\c' """,
+ {"HOME": str(tmp_path)},
+ )
+ assert rc == 0, out
+ written = json.loads((tmp_path / ".config/hark/client.json").read_text())
+ assert written["key"] == r'a"b\c'
+
+ def test_json_field_round_trips_what_write_client_config_wrote(self, tmp_path):
+ rc, out = run_sourced(
+ 'write_client_config "http://rt:8911/dictate" "rtkey"\n'
+ "json_field server\necho\njson_field key",
+ {"HOME": str(tmp_path)},
+ )
+ assert rc == 0, out
+ assert "http://rt:8911/dictate" in out
+ assert "rtkey" in out
+
+
+class TestLegacyMigration:
+ def _legacy(self, home: Path, server: str, key: str) -> None:
+ d = home / ".hammerspoon"
+ d.mkdir(parents=True)
+ (d / "hark-config.lua").write_text(
+ f'return {{\n server = "{server}",\n key = "{key}",\n}}\n'
+ )
+
+ def test_reads_the_hammerspoon_config_shape(self, tmp_path):
+ self._legacy(tmp_path, "http://old:8911/dictate", "oldkey")
+ rc, out = run_sourced("legacy_field server\necho\nlegacy_field key", {"HOME": str(tmp_path)})
+ assert rc == 0, out
+ assert "http://old:8911/dictate" in out
+ assert "oldkey" in out
+
+ def test_migration_never_modifies_the_hammerspoon_config(self, tmp_path):
+ # Rolling back must stay as cheap as relaunching Hammerspoon.
+ self._legacy(tmp_path, "http://old:8911/dictate", "oldkey")
+ legacy = tmp_path / ".hammerspoon" / "hark-config.lua"
+ before = legacy.read_bytes()
+ rc, out = run_sourced("resolve_config", {"HOME": str(tmp_path)})
+ assert rc == 0, out
+ assert legacy.read_bytes() == before
+
+ def test_an_existing_client_json_wins_over_the_legacy_config(self, tmp_path):
+ import json
+
+ self._legacy(tmp_path, "http://old:8911/dictate", "oldkey")
+ rc, _ = run_sourced(
+ 'write_client_config "http://new:8911/dictate" "newkey"', {"HOME": str(tmp_path)}
+ )
+ assert rc == 0
+ rc, out = run_sourced("resolve_config", {"HOME": str(tmp_path)})
+ assert rc == 0, out
+ written = json.loads((tmp_path / ".config/hark/client.json").read_text())
+ assert written["key"] == "newkey", "a re-run clobbered a hand-edited config"
+
+ def test_falls_back_to_the_local_server_key(self, tmp_path):
+ import json
+
+ d = tmp_path / ".config/hark"
+ d.mkdir(parents=True)
+ (d / "key").write_text("localkey\n")
+ rc, out = run_sourced("resolve_config", {"HOME": str(tmp_path)})
+ assert rc == 0, out
+ written = json.loads((d / "client.json").read_text())
+ # Trailing newline stripped, or the header goes out with one in it.
+ assert written["key"] == "localkey"
+
+ def test_no_key_anywhere_fails_loudly_rather_than_writing_an_empty_key(self, tmp_path):
+ rc, out = run_sourced("resolve_config", {"HOME": str(tmp_path)})
+ assert rc != 0
+ assert "no shared secret" in out
+ assert not (tmp_path / ".config/hark/client.json").exists()
+
+
+# ---------------------------------------------------------------------------
+# Doctor: no false PASSes
+# ---------------------------------------------------------------------------
+
+
+class TestMicDoctor:
+ def _status(self, home: Path, body: str) -> None:
+ d = home / ".config/hark"
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "agent-mic-status").write_text(body)
+
+ def test_ok_passes(self, tmp_path):
+ self._status(tmp_path, "ok\n2026-08-03 12:00:00\n")
+ rc, out = run_sourced("check_mic", {"HOME": str(tmp_path)})
+ assert rc == 0, out
+ assert "PASS" in out
+
+ def test_denied_is_not_a_pass(self, tmp_path):
+ self._status(tmp_path, "denied\n2026-08-03 12:00:00\nrec exited 3.\n")
+ rc, out = run_sourced("check_mic", {"HOME": str(tmp_path)})
+ assert "FAIL" in out
+ assert "Microphone" in out
+
+ def test_error_is_not_a_pass_and_surfaces_the_detail(self, tmp_path):
+ self._status(tmp_path, "error\n2026-08-03 12:00:00\nrec exited 5. no audio\n")
+ rc, out = run_sourced("check_mic", {"HOME": str(tmp_path)})
+ assert "FAIL" in out
+ assert "no audio" in out
+
+ def test_a_missing_status_file_is_not_a_pass(self, tmp_path):
+ rc, out = run_sourced("check_mic", {"HOME": str(tmp_path)})
+ assert "FAIL" in out
+ assert "PASS" not in out
+
+
+class TestAccessibilityDoctor:
+ """This check produced a confident FALSE PASS on 2026-08-03.
+
+ It queried TCC.db and reported "Accessibility is granted" while the agent
+ was simultaneously alerting on screen that it could not paste. The row
+ outlives the grant it describes: an ad-hoc signature's designated
+ requirement is a bare cdhash, so every rebuild is a new identity, and the
+ stale row keeps auth_value=2 while System Settings keeps drawing a
+ switched-ON toggle for a binary nobody trusts.
+ """
+
+ def _status(self, home: Path, body: str) -> None:
+ d = home / ".config/hark"
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "agent-accessibility-status").write_text(body)
+
+ def test_never_reads_tcc_db(self):
+ # The bug was structural, not a typo. Reading that file at all is the
+ # defect, so it is the file's presence that is asserted against.
+ # The comment block still explains what TCC.db is and why it is wrong,
+ # so the string itself must stay legal. What must not survive is the
+ # mechanism: a query, and the service name it would query for.
+ text = SCRIPT.read_text()
+ assert "sqlite3" not in text
+ assert "kTCCServiceAccessibility" not in text
+
+ def test_ok_passes(self, tmp_path):
+ self._status(tmp_path, "ok\n2026-08-03 15:32:00\n")
+ rc, out = run_sourced("check_accessibility", {"HOME": str(tmp_path)})
+ assert rc == 0, out
+ assert "PASS" in out
+
+ def test_denied_is_not_a_pass(self, tmp_path):
+ self._status(tmp_path, "denied\n2026-08-03 15:28:00\n")
+ rc, out = run_sourced("check_accessibility", {"HOME": str(tmp_path)})
+ assert "FAIL" in out
+ assert "PASS" not in out
+
+ def test_a_missing_report_is_not_a_pass(self, tmp_path):
+ # An agent that predates this check, or has not started. Reporting
+ # PASS here is exactly the failure being fixed.
+ rc, out = run_sourced("check_accessibility", {"HOME": str(tmp_path)})
+ assert "PASS" not in out
+ assert "SKIP" in out
+
+
+class TestStaleGrantReset:
+ """A rebuild invalidates the Accessibility grant but not its TCC row.
+
+ Observed twice on 2026-08-03: cdhash 6836bec4… -> be5a5c92… left the row
+ reading auth_value=2 with System Settings still drawing a switched-ON
+ toggle, while the agent reported `denied`. The installer clears it so the
+ user is asked again instead of finding a permission already "granted".
+ """
+
+ def _stub_codesign(self, tmp_path: Path, cdhash: str) -> str:
+ import os
+
+ stub = tmp_path / "bin"
+ stub.mkdir(exist_ok=True)
+ (stub / "codesign").write_text(f"#!/bin/sh\necho 'CDHash={cdhash}' >&2\n")
+ (stub / "codesign").chmod(0o755)
+ # Never actually reset a real grant from the suite.
+ (stub / "tccutil").write_text("#!/bin/sh\nexit 0\n")
+ (stub / "tccutil").chmod(0o755)
+ return f"{stub}:{os.environ['PATH']}"
+
+ def test_first_install_records_the_hash_and_resets_nothing(self, tmp_path):
+ path = self._stub_codesign(tmp_path, "aaaa1111")
+ rc, out = run_sourced(
+ "reset_stale_grants_on_identity_change",
+ {"HOME": str(tmp_path), "PATH": path},
+ )
+ assert rc == 0, out
+ assert "binary changed" not in out
+ assert (tmp_path / ".config/hark/.agent-cdhash").read_text() == "aaaa1111"
+
+ def test_reinstalling_the_same_binary_does_not_reset(self, tmp_path):
+ # Re-running the installer on an unchanged build must not cost the
+ # user a consent dialog.
+ path = self._stub_codesign(tmp_path, "aaaa1111")
+ env = {"HOME": str(tmp_path), "PATH": path}
+ run_sourced("reset_stale_grants_on_identity_change", env)
+ rc, out = run_sourced("reset_stale_grants_on_identity_change", env)
+ assert rc == 0, out
+ assert "binary changed" not in out
+
+ def test_a_changed_binary_clears_the_stale_grant(self, tmp_path):
+ env_a = {"HOME": str(tmp_path), "PATH": self._stub_codesign(tmp_path, "aaaa1111")}
+ run_sourced("reset_stale_grants_on_identity_change", env_a)
+ env_b = {"HOME": str(tmp_path), "PATH": self._stub_codesign(tmp_path, "bbbb2222")}
+ rc, out = run_sourced("reset_stale_grants_on_identity_change", env_b)
+ assert rc == 0, out
+ assert "binary changed" in out
+ assert (tmp_path / ".config/hark/.agent-cdhash").read_text() == "bbbb2222"
+
+ def test_only_accessibility_is_reset(self):
+ # The microphone path already tells the truth: the agent's probe runs
+ # rec and reports the outcome, so a stale mic row cannot produce a
+ # false PASS. Resetting it would cost a dialog for nothing.
+ body = SCRIPT.read_text()
+ body = body[body.index("reset_stale_grants_on_identity_change() {") :]
+ body = body[: body.index("\n}\n")]
+ assert "tccutil reset Accessibility" in body
+ assert "tccutil reset Microphone" not in body
+
+
+def test_the_agent_reports_its_own_trust_state(source):
+ # Only the process can answer for the process — the same rule the
+ # microphone probe already followed, arrived at the expensive way.
+ assert "writeAccessibilityStatus" in source
+ assert "agent-accessibility-status" in source
+
+
+def test_trust_is_rechecked_at_paste_time_not_only_at_launch(source):
+ # A grant can be revoked, or silently invalidated by a rebuild, while the
+ # process keeps running. Paste is the moment it matters.
+ paste_body = source[source.index("func paste(") : source.index("// HTTP")]
+ assert "AXIsProcessTrusted()" in paste_body
+
+
+def test_agent_loaded_survives_the_pipefail_sigpipe_trap(tmp_path):
+ """`launchctl list | grep -q X` under pipefail reports everything unloaded.
+
+ grep exits at the first match, launchctl takes SIGPIPE, and pipefail
+ propagates it - so the check reports every service as not-loaded while
+ they are all running. This bit install-server.sh once already (156bb69).
+ The fix is to capture into a variable first, which is what is asserted
+ here: a stub launchctl emitting many lines must still be detected.
+ """
+ stub = tmp_path / "bin"
+ stub.mkdir()
+ (stub / "launchctl").write_text(
+ "#!/bin/sh\n"
+ "echo 'PID\tStatus\tLabel'\n"
+ f"echo '1\t0\t{BUNDLE_ID}'\n"
+ + "".join(f"echo '{n}\t0\tcom.example.filler{n}'\n" for n in range(2, 400))
+ )
+ (stub / "launchctl").chmod(0o755)
+
+ import os
+
+ rc, out = run_sourced(
+ "agent_loaded && echo DETECTED || echo MISSED",
+ {"HOME": str(tmp_path), "PATH": f"{stub}:{os.environ['PATH']}"},
+ )
+ assert "DETECTED" in out, out
+
+
+# ---------------------------------------------------------------------------
+# The agent's own invariants, asserted against its source
+# ---------------------------------------------------------------------------
+
+
+class TestAgentSource:
+ def test_never_presses_return_after_pasting(self, source):
+ # Auto-submit is a hard non-goal: the user reviews the transcript
+ # before sending it. kVK_Return appearing here at all is the defect.
+ assert "kVK_Return" not in source
+ assert "kVK_ANSI_KeypadEnter" not in source
+
+ def test_does_not_save_and_restore_the_clipboard(self, source):
+ # Leaving the transcript on the clipboard makes a misfired paste
+ # recoverable with a manual Cmd+V. See the comment in paste().
+ assert "pasteboardItems" not in source
+ assert "restoreClipboard" not in source
+
+ def test_uses_a_wav_path_the_hammerspoon_client_cannot_collide_with(self, source):
+ # Both clients are installed at once during a migration. Sharing
+ # /tmp/hark.wav would let one read the other's recording.
+ assert '"/tmp/hark-agent.wav"' in source
+ assert '"/tmp/hark.wav"' not in source
+
+ def test_logs_transcript_length_but_never_content(self, source):
+ assert "text.count) chars" in source
+
+ def test_sends_the_key_as_the_hark_header(self, source):
+ assert '"X-Hark-Key"' in source
+ assert '"audio/wav"' in source
+
+ def test_registers_both_press_and_release(self, source):
+ # A hold-to-talk hotkey that only handles kEventHotKeyPressed records
+ # forever. Both kinds must be in the event spec AND handled.
+ assert "kEventHotKeyPressed" in source
+ assert "kEventHotKeyReleased" in source
+
+ def test_treats_rec_exit_3_as_a_permission_denial(self, source):
+ # rec reserves exit 3 for "TCC said no". Inferring denial from an
+ # empty capture cannot work - an ungranted process still receives
+ # full-length buffers of zeros (issue #9).
+ assert "terminationStatus == 3" in source