From 98ac8d476c4d4cfc3df95ad061dc550119b045ce Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 15:11:58 -0400 Subject: [PATCH 1/4] [agent] Add a native Swift client alongside Hammerspoon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessibility is currently granted to Hammerspoon — a general-purpose scriptable Lua runtime — and its config is a symlink into this repo, so a git pull changes what that grant covers without re-prompting. A single-purpose bundle asks for the same permission with far less behind it. Ships as an opt-in preview that changes nothing about the existing path: client/init.lua and install-client.sh are untouched, so a running install keeps working and rolling back is uninstall plus relaunching Hammerspoon. The Lua client is deleted in a follow-up, once both machines are proven on the agent. Three decisions the constraints made rather than preference: An .app bundle, not a bare binary. TCC keys a bundle's grants to its identifier and signature, so they survive a rebuild; a bare binary is keyed by path and cdhash and loses the grant every recompile, while macOS leaves the stale row visible with its toggle still ON. Microphone additionally needs NSMicrophoneUsageDescription, which needs a plist. Carbon RegisterEventHotKey, not an NSEvent global monitor. A global monitor cannot consume the event, so Ctrl+Alt+Space would also reach the focused app. RegisterEventHotKey consumes it, delivers pressed and released separately, and needs no permission of its own — it is what hs.hotkey used underneath. Accessibility is still required, for the synthesized Cmd+V. The mic-status file contract survives. TCC attributes a request to the responsible process, so a probe run from a shell tests the terminal's grant and produces a confidently wrong PASS. Only the agent can answer for the agent, so it writes the answer where the shell can read it. Config moves to ~/.config/hark/client.json, beside the server's own config. JSON rather than TOML so this stays a single-file swiftc build: Swift has no stdlib TOML parser and neither a SwiftPM manifest nor a hand-rolled one earns its keep for two fields. Solves: GitHub issue #2, part 1 of 2 Tests: 29 pytest cases; shellcheck; new macOS CI job builds and verifies the signed bundle --- .github/workflows/ci.yml | 24 +- .gitignore | 4 + README.md | 45 ++- client/agent/Info.plist | 57 +++ client/agent/build-agent.sh | 81 ++++ client/agent/hark-agent.swift | 717 ++++++++++++++++++++++++++++++++++ install-agent.sh | 447 +++++++++++++++++++++ tests/test_install_agent.py | 327 ++++++++++++++++ 8 files changed, 1698 insertions(+), 4 deletions(-) create mode 100644 client/agent/Info.plist create mode 100755 client/agent/build-agent.sh create mode 100644 client/agent/hark-agent.swift create mode 100755 install-agent.sh create mode 100644 tests/test_install_agent.py 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..2871a45 --- /dev/null +++ b/client/agent/build-agent.sh @@ -0,0 +1,81 @@ +#!/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. +codesign --force --options runtime --timestamp=none \ + --sign "$SIGN_IDENTITY" "$APP/Contents/MacOS/rec" +codesign --force --options runtime --timestamp=none \ + --sign "$SIGN_IDENTITY" "$APP" + +log "verifying" +codesign --verify --strict --verbose=2 "$APP" 2>&1 | sed 's/^/ /' + +log "built $APP" diff --git a/client/agent/hark-agent.swift b/client/agent/hark-agent.swift new file mode 100644 index 0000000..b5f9072 --- /dev/null +++ b/client/agent/hark-agent.swift @@ -0,0 +1,717 @@ +// 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") + +// ============================================================================ +// 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 + + // 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) + 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 checkAccessibility() { + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + let trusted = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary) + 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/install-agent.sh b/install-agent.sh new file mode 100755 index 0000000..8c46f01 --- /dev/null +++ b/install-agent.sh @@ -0,0 +1,447 @@ +#!/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" +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" +} + +# ============================================================================== +# 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 +} + +# Accessibility is queried directly rather than through a status file: unlike +# the microphone, the answer does not depend on which process asks. The agent +# needs it to synthesize Cmd+V — without it, recording and transcription both +# work and nothing ever appears. +check_accessibility() { + local db="/Library/Application Support/com.apple.TCC/TCC.db" + if [[ ! -r "$db" ]]; then + # Expected: the system TCC database is not world-readable, and reading it + # needs Full Disk Access. Not a failure — say so rather than reporting a + # permission we cannot see as missing. + printf ' \033[1;33mSKIP\033[0m Accessibility (cannot read TCC.db without Full Disk Access)\n' + printf ' check by hand: System Settings -> Privacy & Security -> Accessibility -> hark\n' + return 0 + fi + if sqlite3 "$db" \ + "select 1 from access where service='kTCCServiceAccessibility' and client='$AGENT_LABEL' and auth_value>0" \ + 2>/dev/null | grep -q 1; then + doctor_pass "Accessibility is granted to hark" + else + doctor_fail "Accessibility is granted to hark" \ + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark" + 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" + +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 + +write_plist +reload_agent + +# The agent needs a moment to register the hotkey, run its microphone probe +# and write the status file the doctor reads. +sleep 3 + +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..e4a1d67 --- /dev/null +++ b/tests/test_install_agent.py @@ -0,0 +1,327 @@ +"""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() + + +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 + + +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 From dc9ae3453177ccb3090f72b7a7233059eca90217 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 15:24:04 -0400 Subject: [PATCH 2/4] [agent] Add the audio-input entitlement the runtime requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent never appeared in System Settings -> Privacy & Security -> Microphone, and rec exited 3 instantly on every launch. Under the hardened runtime, which build-agent.sh enables and which notarization will require, microphone access needs com.apple.security.device.audio-input. Without it TCC does not deny the request after asking - it refuses to ASK: Prompting policy for hardened runtime; service: kTCCServiceMicrophone requires entitlement com.apple.security.device.audio-input but it is missing for responsible={com.drycodeworks.hark-agent} Policy disallows prompt for ...; access to kTCCServiceMicrophone denied Both ends of that are silent. The pane lists only apps that have successfully requested, so a refused prompt means no row ever appears and there is nothing to switch on. The code sees authorizationStatus == .denied immediately, which is exactly what a real user refusal looks like. Only the unified log names the entitlement. Dropping --options runtime would also have fixed it, and would have broken notarization later instead. The entitlement is the real fix, so the hardened runtime stays on and a test pins it there. Both binaries are signed with it: the app is the responsible process tccd checks, rec is the process that opens the device. The build now asserts the entitlement survived into the signature, because a signature that merely verifies proves nothing - the bundle signs, launches and runs perfectly without it and only fails at the microphone, where it reads as a permission problem rather than a build problem. Two diagnostics the same run exposed: The installer slept a fixed 3s before running --doctor, which raced the consent dialog and reported a spurious microphone FAIL while the prompt was still on screen. It now polls for the probe's own status file, since that cannot land until the user has answered. rec's permission message named Hammerspoon. rec is spawned by both clients and TCC attributes the grant to whichever is responsible, so the row to switch on reads "Hammerspoon" under the Lua client and "hark" under the agent. Naming one sent users looking for a row that was never going to be there. Solves: hark issue #2 — first real run on the Studio Tests: 6 cases pinning the entitlement, both signing calls, the build-time assertion, the hardened runtime and rec's app-agnostic help --- client/agent/build-agent.sh | 26 ++++++++++++++ client/agent/hark-agent.entitlements | 26 ++++++++++++++ client/rec.swift | 7 +++- install-agent.sh | 24 +++++++++++-- tests/test_install_agent.py | 51 ++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 client/agent/hark-agent.entitlements diff --git a/client/agent/build-agent.sh b/client/agent/build-agent.sh index 2871a45..3a0ec17 100755 --- a/client/agent/build-agent.sh +++ b/client/agent/build-agent.sh @@ -70,12 +70,38 @@ log "signing with identity: $SIGN_IDENTITY" # 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/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 index 8c46f01..71bed9e 100755 --- a/install-agent.sh +++ b/install-agent.sh @@ -427,12 +427,30 @@ if [[ "$KEEP_HAMMERSPOON" -eq 0 ]] && pgrep -x Hammerspoon >/dev/null 2>&1; then 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 -# The agent needs a moment to register the hotkey, run its microphone probe -# and write the status file the doctor reads. -sleep 3 +# 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 diff --git a/tests/test_install_agent.py b/tests/test_install_agent.py index e4a1d67..9e63592 100644 --- a/tests/test_install_agent.py +++ b/tests/test_install_agent.py @@ -106,6 +106,57 @@ def test_executable_name_matches_what_the_build_produces(self, plist): 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 From e8f3b60ce1f77f2ef14fe1dcc21200ec1df78d9a Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 15:35:12 -0400 Subject: [PATCH 3/4] [agent] Report Accessibility from the agent, not TCC.db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor reported "PASS Accessibility is granted" while the agent was alerting on screen that it could not paste, and System Settings drew a switched-ON toggle for a binary that nothing trusted. Three sources agreeing with each other and disagreeing with reality. The row in TCC.db outlives the grant it describes. An ad-hoc signature's designated requirement is a bare cdhash: designated => cdhash H"6836bec46e8c7d394cf1ba94421ff18a31674867" so every rebuild is a new identity. The old row survives with auth_value=2, the pane keeps drawing its toggle, and the running binary is trusted by nobody. Reading that row can only ever report what was true for some earlier build. This is the same rule the microphone probe already followed, arrived at the expensive way: only the process can answer for the process. The agent now records its own AXIsProcessTrusted() result to ~/.config/hark/agent-accessibility-status and the doctor reads that. A missing report is a SKIP with guidance, never a PASS - an agent predating this check must not be reported as granted. Trust is re-checked at paste time rather than only at launch, since a grant can be revoked, or silently invalidated by a rebuild, while the process keeps running. Paste is the moment it matters. Also holds Cmd+V for 200ms between key-down and key-up, matching hs.eventtap.keyStroke's `local keyDelay = 200000`. A zero-duration keystroke is silently dropped by some apps. That was not what broke this - the process being untrusted was - but back-to-back posting was a real difference from the implementation being replaced. Verified end to end on the Studio: four consecutive dictations recorded, transcribed and pasted at the cursor. Solves: hark issue #2 — the false PASS found during first-run bring-up Tests: 7 cases covering the doctor's three states, that the mechanism cannot come back, and that trust is rechecked at paste time --- client/agent/hark-agent.swift | 56 +++++++++++++++++++++++++++++++- install-agent.sh | 40 +++++++++++++---------- tests/test_install_agent.py | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 17 deletions(-) diff --git a/client/agent/hark-agent.swift b/client/agent/hark-agent.swift index b5f9072..be4f713 100644 --- a/client/agent/hark-agent.swift +++ b/client/agent/hark-agent.swift @@ -78,6 +78,22 @@ let logPath = home.appendingPathComponent("Library/Logs/hark-agent.log") // 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 // ============================================================================ @@ -271,10 +287,39 @@ func paste(_ text: String) { 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) - up.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) + } } // ============================================================================ @@ -578,9 +623,18 @@ func registerHotKey() -> Bool { // 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( diff --git a/install-agent.sh b/install-agent.sh index 71bed9e..128b3af 100755 --- a/install-agent.sh +++ b/install-agent.sh @@ -42,6 +42,7 @@ 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" SERVER_KEY="$HARK_CONFIG_DIR/key" LEGACY_CONFIG="$HOME/.hammerspoon/hark-config.lua" @@ -306,27 +307,34 @@ check_mic() { esac } -# Accessibility is queried directly rather than through a status file: unlike -# the microphone, the answer does not depend on which process asks. The agent -# needs it to synthesize Cmd+V — without it, recording and transcription both -# work and nothing ever appears. +# 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 db="/Library/Application Support/com.apple.TCC/TCC.db" - if [[ ! -r "$db" ]]; then - # Expected: the system TCC database is not world-readable, and reading it - # needs Full Disk Access. Not a failure — say so rather than reporting a - # permission we cannot see as missing. - printf ' \033[1;33mSKIP\033[0m Accessibility (cannot read TCC.db without Full Disk Access)\n' - printf ' check by hand: System Settings -> Privacy & Security -> Accessibility -> hark\n' + 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 sqlite3 "$db" \ - "select 1 from access where service='kTCCServiceAccessibility' and client='$AGENT_LABEL' and auth_value>0" \ - 2>/dev/null | grep -q 1; then - doctor_pass "Accessibility is granted to hark" + 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" + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark, then restart the agent" fi } diff --git a/tests/test_install_agent.py b/tests/test_install_agent.py index 9e63592..1044d22 100644 --- a/tests/test_install_agent.py +++ b/tests/test_install_agent.py @@ -306,6 +306,66 @@ def test_a_missing_status_file_is_not_a_pass(self, tmp_path): 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 + + +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. From 9d5a7bde79e3b02e87208019c972c6d994ea239a Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 15:43:31 -0400 Subject: [PATCH 4/4] [agent] Clear the stale TCC grant a rebuild invalidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rebuild changes the ad-hoc cdhash, which is the whole of the bundle's designated requirement, so the Accessibility grant stops applying. macOS does not tidy up after it: the old row survives with auth_value=2 and System Settings keeps drawing a switched-ON toggle for a binary nothing trusts. Observed twice in one afternoon (6836bec4... -> be5a5c92...), and it is genuinely misleading - you go to grant the permission, find it already granted, and go looking somewhere else for the fault. The installer now records the installed cdhash and, when it changes, says so plainly and clears the entry with tccutil so the agent's prompt lands on a cleared slot rather than a stale one. Toggling it off and on by hand does the same thing; this removes the detour. Only Accessibility is reset. It is the grant observed to break, and the microphone path already tells the truth on its own - the agent's probe runs rec and reports what actually happened, so a stale microphone row cannot produce the false PASS a stale Accessibility row did. Resetting it anyway would cost a consent dialog for nothing. A Developer ID signature makes this dead code, since the requirement becomes the certificate rather than the hash. Until then this is the difference between one clean prompt and a confusing detour on every rebuild. Solves: hark issue #2 — the re-grant dance, hit on the first real update Tests: 5 cases covering first install, unchanged reinstall, a changed binary, and that the microphone grant is left alone --- install-agent.sh | 60 ++++++++++++++++++++++++++++++++++++ tests/test_install_agent.py | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/install-agent.sh b/install-agent.sh index 128b3af..3fe9b79 100755 --- a/install-agent.sh +++ b/install-agent.sh @@ -43,6 +43,9 @@ 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" @@ -149,6 +152,58 @@ resolve_config() { 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 # ============================================================================== @@ -426,6 +481,11 @@ mkdir -p "$APP_DIR" 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 diff --git a/tests/test_install_agent.py b/tests/test_install_agent.py index 1044d22..4700b07 100644 --- a/tests/test_install_agent.py +++ b/tests/test_install_agent.py @@ -352,6 +352,67 @@ def test_a_missing_report_is_not_a_pass(self, tmp_path): 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.