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