From 1e71db4dc210c32504ae90fd49082743894a1789 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 17:09:01 -0400 Subject: [PATCH 1/4] [client] Wire the installer to the Swift agent, delete Hammerspoon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main was left in an intermediate state: #12 landed swift/ additively and touched nothing else, and the PRs carrying the installer work were closed as superseded because they were built on the client implementation #12 replaced. So the repo had the real client with no way to install it, while install-client.sh still installed the hammerspoon cask and the README still documented it. Anyone following the README got Hammerspoon. Removed: client/init.lua, client/hark-config.example.lua, tests/test_client_record.lua, the Lua toolchain and suite from CI, and the cask from the install path. client/rec.swift goes too — the Swift Recorder inlines capture, so it had no callers left. install-client.sh now builds swift/ into ~/Applications/Hark.app and registers `hark agent` as a LaunchAgent. It keeps what the Hammerspoon installer did that the agent had no equivalent for: fetching the shared secret over SSH for a two-machine setup, and the /health check — everything local can be healthy while the server is unreachable, and from the user's chair that is indistinguishable from a microphone fault. THE DOCTOR NOW READS THE AGENT'S OWN status.json, and nothing is measured from outside. Both permissions were got wrong the other way during bring-up: - a microphone probe run from the installer tests the TERMINAL's grant, because TCC attributes to the responsible process - querying TCC.db for Accessibility reports what was true for an EARLIER build: an ad-hoc designated requirement is a bare cdhash, so a rebuild is a new identity while the old row still reads granted and System Settings still draws a switched-ON toggle Both produced confident false PASSes. status.json also carries the hotkey binding, which used to be a hardcoded literal, and a freshness check — the heartbeat rewrites every 30s, so a stale file means the agent died without saying so and every field is a claim about a dead pid. The installer will not write a config the agent refuses. The agent's transport policy is plain HTTP to loopback always, to a numeric IP only with an explicit allowPlaintext, to a hostname never; writing an invalid config would just move the failure to first launch, where it reads as a broken agent rather than a wrong address. Migration is kept: ~/.hammerspoon/hark-config.lua is still read into client.json when the latter is absent, since for anyone mid-upgrade that file is the only place their key lives. Hammerspoon is quit but NOT uninstalled and its grants are NOT revoked — the README says to do both by hand, because revoking them is the actual point of #2 and that is not something to do silently to someone's machine. Solves: GitHub issue #2 — the wiring #13/#14 carried before being closed Tests: 119 pass; new coverage for the transport policy, the status.json doctor states, and staleness. Doctor verified against a live agent: 10/10. --- .github/workflows/ci.yml | 37 +- README.md | 250 ++++--- client/hark-config.example.lua | 36 - client/init.lua | 543 -------------- client/rec.swift | 237 ------ install-client.sh | 1275 +++++++++++++++----------------- tests/test_client_record.lua | 231 ------ tests/test_install_client.py | 473 ++++++++++++ 8 files changed, 1244 insertions(+), 1838 deletions(-) delete mode 100644 client/hark-config.example.lua delete mode 100644 client/init.lua delete mode 100644 client/rec.swift delete mode 100644 tests/test_client_record.lua create mode 100644 tests/test_install_client.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5c63ad..f14e221 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,19 +38,6 @@ jobs: - name: pytest run: uv run --locked pytest -q - - name: Install Lua - run: | - if [ "$RUNNER_OS" = "macOS" ]; then - brew install lua - else - sudo apt-get update && sudo apt-get install -y lua5.4 - sudo ln -sf "$(command -v lua5.4)" /usr/local/bin/lua - fi - - # The client-side suite. It has never been wired into anything, so it - # only ran when someone remembered it existed. - - name: Lua client tests - run: lua tests/test_client_record.lua shellcheck: runs-on: ubuntu-latest @@ -60,3 +47,27 @@ jobs: # Preinstalled on the Ubuntu runner image. - name: shellcheck run: shellcheck install-server.sh install-client.sh + + # The client is macOS-only and needs a real Swift toolchain, so it cannot + # join the portable matrix above. 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. + swift: + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + + - name: Test + run: cd swift && swift test + + - name: Build Hark.app + run: cd swift && swift build -c release && bash Packaging/build-app.sh + + - name: Verify the signature + run: codesign --verify --strict --verbose=2 swift/Packaging/Hark.app + + - name: Verify the microphone entitlement survived signing + run: | + codesign -d --entitlements - --xml swift/Packaging/Hark.app 2>/dev/null \ + | grep -q 'com.apple.security.device.audio-input' \ + || { echo "missing audio-input entitlement — TCC will refuse to prompt"; exit 1; } diff --git a/README.md b/README.md index 4fc5cde..3533749 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ the clipboard and is pasted at the cursor. Transcription is local (whisper.cpp, model held resident). Audio never leaves your hardware — there is no cloud ASR and no account. -macOS only, by construction: it is built out of launchd, AVAudioEngine, -Hammerspoon, and macOS's TCC permission model. +macOS only, by construction: it is built out of launchd, AVAudioEngine, Carbon +hotkeys, and macOS's TCC permission model. ## Why off-the-shelf dictation apps can't do this @@ -133,29 +133,62 @@ The shared secret lives at `~/.config/hark/key` (mode 600), outside the repo. ./install-client.sh ``` +For a two-machine setup, pass the server's SSH host to skip the prompt: + +```bash +./install-client.sh dans-mac-studio +``` + It: -1. installs `hammerspoon` (cask) via Homebrew if missing, and compiles - `client/rec.swift` to `~/.hammerspoon/rec` with `swiftc`, +1. builds `swift/` into `~/Applications/Hark.app` with SwiftPM and signs it, 2. obtains the shared secret (locally, or over SSH for a two-machine setup), 3. asks you nothing about microphones — `rec` records the system default input, chosen in System Settings → Sound → Input, -4. curls `/health` and tells you plainly if the server isn't reachable, -5. writes `~/.hammerspoon/hark-config.lua` (mode 600 — it holds the secret - in plaintext) and links `client/init.lua` to `~/.hammerspoon/init.lua`. If - that path already exists as a real file rather than a symlink, this is a - **hard stop** with the exact command to fix it, not a warning you can miss, -6. **actually starts Hammerspoon with the new config loaded** — launching it - if it wasn't running, quitting and relaunching if it was. Hammerspoon does - not auto-reload its config, and installing a cask does not run the app. - This step being missing was once the entire cause of "holding the hotkey - does nothing at all", -7. handles the two permissions below, -8. finishes by running the same live checks as `--doctor` and refuses to print +4. writes `~/.config/hark/client.json` (mode 600 — it holds the secret in + plaintext), +5. registers `hark agent` as the LaunchAgent `com.drycodeworks.hark-agent`, so + it starts at login and comes back after a reboot, +6. waits for the agent's own microphone probe, which is what triggers the + consent dialog, +7. finishes by running the same live checks as `--doctor` and refuses to print "setup complete" if any fail. Safe to re-run at any time; every step checks current state first. +#### Upgrading from the Hammerspoon client + +Before 2026-08-03 the client was Hammerspoon plus 505 lines of Lua. That meant +Accessibility — permission to observe every keystroke — was granted to a +general-purpose scriptable runtime whose config was a symlink into this repo, +so a `git pull` changed what the grant covered without re-prompting. The native +agent asks for the same permission with far less behind it. +See [issue #2](https://github.com/DRYCodeWorks/hark/issues/2). + +`./install-client.sh` migrates you: `~/.hammerspoon/hark-config.lua` is read +into `~/.config/hark/client.json` and never modified, and Hammerspoon is quit +so the agent can take the hotkey (`Ctrl+Alt+Space` is a system-wide +registration and exactly one process gets it). + +Afterwards, clean up by hand — the installer deliberately does not: + +```bash +brew uninstall --cask hammerspoon +rm ~/.hammerspoon/init.lua +``` + +**Revoking Hammerspoon's Accessibility and Microphone grants is the actual +point of the exercise**, and quitting the app does not do it. Switch it off in +System Settings → Privacy & Security. + +You will be prompted for both permissions again: TCC keys grants to a code +identity, and the agent is a different one. Until a Developer ID certificate is +in place the bundle is ad-hoc signed, whose designated requirement is a bare +`cdhash` — so **every rebuild is a new identity and the grants must be given +again**. `install-client.sh` detects the change and clears the stale entry for +you, because macOS otherwise leaves the old row in place with its toggle still +switched ON for a binary nothing trusts. + ### 3. Configuration Everything is optional — the defaults are the working single-machine setup. @@ -172,53 +205,62 @@ person's noise. Read-only — changes nothing, exits non-zero if anything is wrong. Run it any time the hotkey stops working, instead of re-running the whole install: -- Hammerspoon.app installed -- Hammerspoon actually running -- `~/.hammerspoon/init.lua` is a symlink to this repo's `client/init.lua` -- `~/.hammerspoon/hark-config.lua` exists, is mode 600, has a non-empty key -- `rec` is built, and which binary `init.lua` would actually resolve -- Hammerspoon can reach the microphone — read from - `~/.hammerspoon/.hark-mic-status`, the outcome `client/init.lua`'s own - startup probe wrote. This is the only reliable signal: `--doctor` - deliberately does **not** run its own probe, since that would test the - *terminal's* microphone permission rather than Hammerspoon's — a different - grant, and a confidently wrong PASS. +- `Hark.app` is installed, and its signature verifies +- `~/.config/hark/client.json` exists, is mode 600, has a non-empty key +- the agent is loaded in launchd and actually running +- nothing else is holding `Ctrl+Alt+Space` +- the agent can reach the microphone +- Accessibility is granted - the server's `/health` is reachable -- the key **actually authenticates** — it POSTs a tiny silent WAV to - `/dictate` and checks the response isn't a 401 Each `FAIL` line names its exact fix. -`--doctor` does **not** check Accessibility. Only a real hotkey press confirms -that one. +**Every permission check reads what the agent itself reported**, from +`~/.config/hark/status.json` — which the agent rewrites every 30 s, so a stale +file means it died without saying so. Nothing is measured from the outside, and +that is not incidental: + +- Running a microphone probe from `--doctor` would test the **terminal's** + grant, because TCC attributes to the responsible process. A confidently + wrong PASS. +- Querying `TCC.db` for Accessibility reports what was true for **some earlier + build**. The row outlives the grant it describes, so after a rebuild it still + reads granted while the running binary is trusted by nothing. This check did + exactly that once, printing PASS while the agent was alerting on screen that + it could not paste. + +Only the process can answer for the process. Everything else is a guess that +sometimes agrees. ## Two permissions the installer cannot grant for you -Both need a human click in System Settings — macOS doesn't allow a script to -flip either — but they work fundamentally differently, and `install-client.sh` handles -them differently on purpose. These two cost a full debugging session to +Both need a human click — macOS doesn't allow a script to flip either — and +they fail in different ways. These cost a full debugging session each to understand, so they are worth reading before you hit them. -1. **Accessibility** — needed for the global hotkey and for synthesizing the - ⌘V paste. Without it, `hs.hotkey.bind` silently never fires: no error, no - console message, nothing. This one **is** pre-grantable — the Accessibility - pane has a "+" button and lists every installed app whether or not it has - ever run — so `install-client.sh` opens the pane and blocks until you confirm. - -2. **Microphone** — `rec` runs as Hammerspoon's *child process*, so macOS - attributes microphone access to **Hammerspoon**, not to `rec`. This one is - **not** pre-grantable: the Microphone pane has **no "+" button**. Unlike - Accessibility, it lists only apps that have *already requested* access. - Hammerspoon will not appear there — there is nothing to toggle — until - something has actually tried to open the mic. - - So permission must be **triggered**, never pre-granted. `client/init.lua` - runs a short (~0.4s) `rec` probe on every config load, which is what fires - the consent dialog. On success it stays silent and writes `ok` to - `~/.hammerspoon/.hark-mic-status`; on failure it shows a long-lived - alert, writes `denied`, and logs `rec`'s stderr. Once the probe has run - once, Hammerspoon **is** listed in the Microphone pane, so the recovery - path works. +1. **Accessibility** — needed to synthesize the ⌘V paste, *not* for the + hotkey (`RegisterEventHotKey` needs no permission). Without it, recording + and transcription both succeed and nothing ever appears. The agent checks + at startup and again at paste time, and if it is missing it says so and + tells you the transcript is on the clipboard. + +2. **Microphone** — `rec` runs as the agent's *child process*, so macOS + attributes access to **hark**, not to `rec`. This one is **not** + pre-grantable: the Microphone pane has **no "+" button**, and lists only + apps that have *already requested* access. hark will not appear there — + there is nothing to toggle — until something has actually tried to open the + mic. + + So permission must be **triggered**, never pre-granted. The agent runs a + short (~0.4s) `rec` probe at startup, which is what fires the consent + dialog. On success it stays silent and writes `ok`; on failure it alerts, + writes `denied`, and logs `rec`'s stderr. + + One trap worth knowing if you fork this: under the hardened runtime, a + missing `com.apple.security.device.audio-input` entitlement makes TCC + refuse to *prompt at all*. The app then never appears in the pane, and the + code sees an instant `.denied` indistinguishable from a real refusal. + Nothing but the unified log names the cause. Then test for real: put your cursor at a shell prompt, hold **Ctrl+Alt+Space — all three keys together, not the spacebar alone**, say a short sentence, @@ -239,9 +281,9 @@ re-render and reload the plists: bind = "10.x.x.x" # never 0.0.0.0 ``` -On the recording machine, point `server` in -`~/.hammerspoon/hark-config.lua` at the same address. `install-client.sh` will offer -to fetch the key over SSH. +On the recording machine, point `server` in `~/.config/hark/client.json` at +the same address. `install-client.sh` fetches the key over SSH — pass the +server's SSH host as an argument, or let it prompt. `whisper.host` stays loopback in both cases and is not configurable. It is the component that handles raw audio, and audio should not cross a network even a @@ -249,22 +291,34 @@ trusted one. ## Changing the hotkey -Edit the last real line of `client/init.lua`: +Edit `register()` in `swift/Sources/hark/Hotkey.swift`: -```lua -hs.hotkey.bind({ "ctrl", "alt" }, "space", startRecording, stopRecording) +```swift +RegisterEventHotKey( + UInt32(kVK_Space), + UInt32(controlKey | optionKey), + ... ``` -Hammerspoon accepts the standard modifier names (`"cmd"`, `"alt"`, `"ctrl"`, -`"shift"`, `"fn"`) and most key names as lowercase strings. Reload -Hammerspoon's config after editing (menu bar icon → Reload Config). +The key is a `kVK_*` virtual keycode from Carbon's `Events.h`; the modifiers +are `controlKey`, `optionKey`, `cmdKey` and `shiftKey`, OR'd together. Then +re-run `./install-client.sh`. + +`RegisterEventHotKey` rather than a `CGEventTap` is a decision with evidence +behind it. A tap reconstructs the chord from the modifier flags carried on each +event, and releasing Ctrl+Alt+Space almost always lifts a modifier at or before +the space bar — so the key-up arrives with the bits already clear. Measured over +45 s of ordinary use: **303 key-downs, 1 key-up**. Carbon delivers pressed and +released as distinct events, does not condition the release on modifier state, +consumes the chord so it does not also reach the focused app, and needs no +permission of its own. -Avoid `{"cmd","alt"}` + `"space"` — that's macOS's Finder search shortcut, and -the system wins that fight before Hammerspoon sees the event. +Avoid `cmdKey | optionKey` + space — that's macOS's Finder search shortcut, and +the system wins that fight before `RegisterEventHotKey` sees it. -The Fn/🌐 key needs a different mechanism entirely (an `hs.eventtap` watching +The Fn/🌐 key needs a different mechanism entirely (a `CGEventTap` watching `flagsChanged`, the Input Monitoring permission, and disabling the system's own -Fn action). It is **not** a drop-in change to the `bind` call above. +Fn action). It is **not** a drop-in change to the call above. ## Troubleshooting @@ -272,19 +326,20 @@ Fn action). It is **not** a drop-in change to the `bind` call above. happens" reports are one of its checks, not a deeper bug. 0. **Nothing happened, but `--doctor` passes everything.** Check you're - pressing **Ctrl+Alt+Space — all three keys together**. Then open the - Hammerspoon console (menu bar icon → Console) and look for a Lua error. - If Accessibility is missing, the hotkey silently never fires. -1. **`/tmp/hark.wav` is zero bytes, or the microphone check FAILs.** A - microphone permission problem almost every time: System Settings → Privacy - & Security → Microphone → Hammerspoon must be ON. If Hammerspoon isn't - listed at all, it hasn't asked yet — reload its config to re-run the probe. + pressing **Ctrl+Alt+Space — all three keys together**. Then read + `~/Library/Logs/hark-agent.log`: if it shows `pasting N chars`, the + transcript reached your clipboard and only the paste failed, which is + Accessibility. +1. **Recording produces nothing, or the microphone check FAILs.** A microphone + permission problem almost every time: System Settings → Privacy & Security + → Microphone → **hark** must be ON. If hark isn't listed at all, it hasn't + successfully asked yet — see the entitlement note above. 2. **A beep and an alert naming an HTTP status.** The alert names the likely cause: - - **401** — the key in `~/.hammerspoon/hark-config.lua` doesn't match + - **401** — the key in `~/.config/hark/client.json` doesn't match `~/.config/hark/key` on the server. Re-run `install-client.sh`. - **415** — a client bug in the `Content-Type` header; shouldn't happen - with an unmodified `init.lua`. + with an unmodified agent. - **400** — the server rejected the audio; usually the same mic-permission issue as #1, caught server-side. - **503** — `whisper-server` is down. Check `/tmp/hark-whisper.err`. @@ -299,19 +354,20 @@ happens" reports are one of its checks, not a deeper bug. filtered on the *audio*, not on the text. Speak louder or closer, check the input device, and see `SILENCE_RMS_THRESHOLD` below. -**`~/.hammerspoon/hark.log` is the load-bearing diagnostic.** Hammerspoon's -`print()` reaches only the in-app console, which is not persisted and cannot be -read out of band — a flake an hour old otherwise leaves zero evidence anywhere. -`init.lua` appends `rec`'s exit code and stderr to that file on every non-zero -exit. +**`/tmp/hark-agent.err` and `~/.config/hark/status.json` are the load-bearing +diagnostics.** The log carries the byte count of each request and the *length* — +never the content — of each transcript; `status.json` carries the live +microphone, Accessibility and hotkey state. A flake an hour old otherwise leaves +no evidence anywhere. ## What's been tested, and what hasn't -One person, one pair of Macs, one microphone. CI runs the server suite (67 -pytest), the client suite (8 Lua tests against a stubbed Hammerspoon) and -shellcheck, on both Linux and macOS. What CI cannot reach is everything the -permissions model touches — a real microphone, a real TCC grant, a real paste -into a real window. Specifically worth knowing: +One person, two Macs, one microphone. CI runs the pytest suite on Linux and +macOS, shellcheck, and a macOS job that builds `hark.app` and verifies its +signature. What CI cannot reach is everything the permissions model touches — a +real microphone, a real TCC grant, a real paste into a real window. Every bug +found during the agent's first bring-up lived in exactly that gap, and each one +reported success while being broken. Specifically worth knowing: **The silence threshold is calibrated against synthetic audio, not a real microphone.** `SILENCE_RMS_THRESHOLD = 150.0` sits ~16× above the noise floor @@ -382,25 +438,27 @@ transient. ``` install-server.sh transcription side: deps, model, plists, services -install-client.sh hotkey/mic/paste side, plus --doctor -client/ - init.lua Hammerspoon client - rec.swift AVAudioEngine recorder, built at install time - hark-config.example.lua shape of ~/.hammerspoon/hark-config.lua +install-client.sh builds + installs the agent, plus --doctor +swift/ + Sources/hark/ the agent — hotkey, capture, paste, overlay + Sources/HarkCore/ config, client, WAV, sanitise, server + Packaging/build-app.sh assembles and signs Hark.app + Tests/ SwiftPM suite (48) config.example.toml shape of ~/.config/hark/config.toml -src/hark/ the HTTP service +src/hark/ the Python HTTP service (still the one in use) 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 -docs/ design spec + implementation plan +tests/ pytest suite +.github/workflows/ci.yml pytest + shellcheck + the signed bundle build +docs/ design specs ``` 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 +cd swift && swift test # macOS only +cd swift && bash Packaging/build-app.sh ``` ## License diff --git a/client/hark-config.example.lua b/client/hark-config.example.lua deleted file mode 100644 index 9299cf6..0000000 --- a/client/hark-config.example.lua +++ /dev/null @@ -1,36 +0,0 @@ --- Template for ~/.hammerspoon/hark-config.lua. --- --- install-client.sh generates the real file for you (fetches `key` from the --- server over SSH, builds the recorder, chmod 600's the result). This example --- exists so the expected shape is documented in the repo and so you can --- hand-write the file if you'd rather not run the script. --- --- DO NOT commit a copy of this file with a real `key` value filled in. --- ~/.hammerspoon/hark-config.lua lives outside this repo entirely, for --- exactly that reason. - -return { - -- Where hark is listening. Loopback is the single-machine setup, where - -- the server runs on this same Mac, and is the default. - -- - -- For the two-machine setup (this Mac records, another transcribes), use the - -- transcribing machine's private address instead — a Tailscale/tailnet IP, a - -- VPN address, or a LAN address you trust. It must match `server.bind` in - -- that machine's ~/.config/hark/config.toml. - server = "http://127.0.0.1:8911/dictate", - - -- The shared secret from the server's ~/.config/hark/key. Sent as the - -- X-Hark-Key header on every request; a wrong or missing value here is - -- what a 401 response means. - key = "REPLACE_WITH_THE_SERVER_KEY", - - -- There is NO microphone setting. rec records the system default input, so - -- pick the mic in System Settings -> Sound -> Input like any other app. - -- (Earlier versions took an avfoundation device index here. Those indices - -- are positional and renumber whenever a virtual device comes or goes, so - -- they were a standing source of "it recorded the wrong thing".) - - -- Optional. Path to the recorder binary that install-client.sh compiles from - -- client/rec.swift. Omit it and init.lua uses ~/.hammerspoon/rec. - recorder = "/Users/you/.hammerspoon/rec", -} diff --git a/client/init.lua b/client/init.lua deleted file mode 100644 index 9f95cda..0000000 --- a/client/init.lua +++ /dev/null @@ -1,543 +0,0 @@ ---[[ -hark — hold-to-talk client for the server's `hark` service. - -Hold Ctrl+Alt+Space, speak, release. rec (client/rec.swift, built by -install-client.sh) records the mic to a WAV, the WAV -is POSTed to the 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 - a Claude Code prompt in a remote tmux pane, Slack, a browser, -anything. See docs/superpowers/specs/2026-07-14-dictate-design.md, section -"REVISED 2026-07-14", for why this pastes at the OS cursor instead of the -server injecting into a specific tmux pane: the server cannot know which -pane you're looking at, but macOS always knows what has focus. - -This has been exercised end to end - real microphone, real paste target - but -by one person on one pair of Macs. If something misbehaves, the Hammerspoon -console (menu bar icon -> Console) is the first place to look, since Lua -syntax and runtime errors show up there and nowhere else. Note that print() -reaches only that console and is not persisted; anything worth diagnosing -later is appended to ~/.hammerspoon/hark.log instead. -]] - --- ============================================================================ --- Configuration — edit ~/.hammerspoon/hark-config.lua, NOT this file. --- ============================================================================ --- --- install-client.sh generates that file for you. Its expected shape (see also --- client/hark-config.example.lua): --- --- return { --- server = "http://127.0.0.1:8911/dictate", --- key = "", --- recorder = "/Users/you/.hammerspoon/rec", -- optional; this is the default --- } --- --- There is no microphone setting: rec records the system default input. --- Choose it in System Settings -> Sound -> Input. --- --- This file is never committed with a real key - it lives outside the repo, --- in ~/.hammerspoon/, and setup.sh chmod 600's it because it holds a secret --- in plaintext. - -local configPath = os.getenv("HOME") .. "/.hammerspoon/hark-config.lua" -local loadedOk, userConfig = pcall(dofile, configPath) -if not loadedOk or type(userConfig) ~= "table" then - userConfig = {} - hs.alert.show( - "hark: missing or broken " .. configPath .. " — run install-client.sh", - 5 - ) -end - --- Loopback default: the single-machine setup, where hark runs on this same --- Mac. For the two-machine setup, set `server` in hark-config.lua to the --- transcribing machine's private address. -local SERVER = userConfig.server or "http://127.0.0.1:8911/dictate" -local HARK_KEY = userConfig.key -local WAV_PATH = "/tmp/hark.wav" - --- Deliberately NOT /tmp/hark.wav: a stale probe file must never be --- mistaken for a real recorded utterance, and vice versa. Lives under --- ~/.hammerspoon/ (not /tmp) so it doesn't collide with anything else that --- cleans /tmp; deleted immediately after every probe regardless of outcome. -local MIC_PROBE_PATH = os.getenv("HOME") .. "/.hammerspoon/.hark-mic-probe.wav" - --- Read by install-client.sh's --doctor (and the end of a normal setup.sh run) --- to learn whether HAMMERSPOON - not the terminal running setup.sh - can --- reach the microphone. TCC grants are per responsible-app: a probe run --- from the shell would test the terminal's permission, not Hammerspoon's, --- and would be worse than useless (a confidently wrong PASS). This file is --- the only reliable way for setup.sh to learn the real answer. -local MIC_STATUS_PATH = os.getenv("HOME") .. "/.hammerspoon/.hark-mic-status" - --- print() reaches the Hammerspoon console and nowhere else, and that console --- is not persisted - so a flake that happened an hour ago leaves no evidence --- anywhere on disk. rec's stderr is the ONLY thing that says why a --- recording failed, which makes it exactly the thing worth keeping. -local LOG_PATH = os.getenv("HOME") .. "/.hammerspoon/hark.log" - -if not HARK_KEY or HARK_KEY == "" then - hs.alert.show("hark: no key configured in " .. configPath, 5) -end - --- ============================================================================ --- Small helpers --- ============================================================================ - --- Mirrors the server's logging discipline: diagnostics only, never transcript --- content. rec's stderr names the device or the failure, not speech. -local function logLine(msg) - print("hark: " .. msg) - local f = io.open(LOG_PATH, "a") - if f then - f:write(os.date("%Y-%m-%d %H:%M:%S ") .. msg .. "\n") - f:close() - end -end - -local function beep() - -- "Basso" is one of macOS's built-in system alert sounds - -- (/System/Library/Sounds/Basso.aiff) - chosen because it reads as an - -- error/failure tone, distinct from routine feedback. - local sound = hs.sound.getByName("Basso") - if sound then - sound:play() - end -end - --- Best-effort extraction of FastAPI's {"detail": "..."} error body, so the --- server's own (already-specific) explanation reaches the alert instead of --- being silently dropped. Falls back to the raw body if it isn't JSON. -local function extractDetail(body) - if not body or body == "" then - return "(no response body)" - end - local ok, parsed = pcall(hs.json.decode, body) - if ok and type(parsed) == "table" and type(parsed.detail) == "string" then - return parsed.detail - end - return body -end - --- install-client.sh compiles client/rec.swift to here. userConfig.recorder --- overrides it, for a build kept somewhere else. -local RECORDER_PATH = os.getenv("HOME") .. "/.hammerspoon/rec" - -local function resolveRecorder() - if userConfig.recorder and hs.fs.attributes(userConfig.recorder) then - return userConfig.recorder - end - if hs.fs.attributes(RECORDER_PATH) then - return RECORDER_PATH - end - return nil -end - --- `mic` selected an avfoundation device INDEX back when ffmpeg did the --- recording. rec records the system default input instead, which is both --- steadier (indices renumber when a virtual device like Loom's comes and --- goes) and the setting people already expect to control this - System --- Settings -> Sound -> Input. Say so once rather than silently ignoring a --- key someone deliberately set. -if userConfig.mic then - print("hark: `mic` in " .. configPath .. " is no longer used - rec records " - .. "the system default input. Choose it in System Settings -> Sound -> Input.") -end - --- ============================================================================ --- Recording indicator --- ============================================================================ - -local recordingAlertId = nil - -local function showRecordingIndicator() - -- 30s ceiling in case something goes wrong and hideRecordingIndicator() - -- never runs; closeSpecific() below is what normally clears it early. - recordingAlertId = hs.alert.show("● Recording…", nil, nil, 30) -end - -local function hideRecordingIndicator() - if recordingAlertId then - hs.alert.closeSpecific(recordingAlertId, 0) - recordingAlertId = nil - end -end - --- ============================================================================ --- HTTP response handling --- ============================================================================ --- --- Every branch below both beeps AND shows an alert naming the likely cause. --- A silent failure is the worst outcome here - if dictation does nothing, --- the instinct is to just try again, and a second silent failure reads as --- "the mic isn't working" when the real cause might be a stale key or a --- downed tailnet link. - -local function handleDictateResponse(status, body) - -- hs.http reports connection-level failures (host unreachable, DNS - -- failure, timeout, refused) as a NEGATIVE status with an error message in - -- `body` - documented behaviour, distinct from a normal HTTP status. - if status < 0 then - beep() - hs.alert.show( - "hark: can't reach the server (" .. SERVER .. ").\n" - .. "Check the tailnet is up (tailscale status) and hark is running.\n" - .. tostring(body), - 6 - ) - return - end - - if status == 200 then - local ok, parsed = pcall(hs.json.decode, body or "") - if not ok or type(parsed) ~= "table" or type(parsed.text) ~= "string" then - beep() - hs.alert.show("hark: 200 OK but the response wasn't the expected JSON: " .. tostring(body), 6) - return - end - - if parsed.text == "" then - -- Not an error: silence, or audio that transcribed to no alphanumeric - -- content. Paste nothing. - hs.alert.show("heard nothing", 1.5) - return - end - - -- Log the LENGTH only, never the transcript itself, mirroring the - -- server's own logging discipline (src/hark/app.py) - the console is - -- local, but there's no reason to put speech content in a log at all. - print("hark: pasting " .. #parsed.text .. " chars") - - -- Deliberately NOT saving/restoring the previous clipboard contents. - -- Leaving the transcript on the clipboard means a misfired paste (wrong - -- window focused, paste blocked by the target app, etc.) is recoverable - -- with a manual Cmd+V instead of having to re-speak the whole utterance. - -- Do not "fix" this by adding clipboard save/restore. - hs.pasteboard.setContents(parsed.text) - - -- Synthesize the paste. NEVER follow this with Return/Enter - the user - -- reviews the transcript before submitting it; auto-submit is a hard - -- non-goal (see the design spec's "Non-goals" section). - hs.eventtap.keyStroke({ "cmd" }, "v") - return - end - - local detail = extractDetail(body) - beep() - - if status == 401 then - hs.alert.show( - "hark: 401 unauthorized — " .. detail .. "\n" - .. "Check that the key in " .. configPath .. " matches the server's " - .. "~/.config/hark/key (re-run install-client.sh to refetch it).", - 7 - ) - elseif status == 415 then - hs.alert.show( - "hark: 415 unsupported media type — " .. detail .. "\n" - .. "This is a client bug (wrong Content-Type header), not a mic problem. " - .. "Please report it.", - 7 - ) - elseif status == 400 then - hs.alert.show( - "hark: 400 bad request — " .. detail .. "\n" - .. "Almost certainly a microphone permission problem: check System " - .. "Settings -> Privacy & Security -> Microphone -> Hammerspoon is ON.", - 7 - ) - elseif status == 503 then - hs.alert.show( - "hark: 503 — whisper-server is down on the server. " .. detail .. "\n" - .. "Check /tmp/hark-whisper.err on the server.", - 7 - ) - else - hs.alert.show("hark: unexpected HTTP " .. tostring(status) .. " — " .. detail, 6) - end -end - --- ============================================================================ --- Send the recorded WAV --- ============================================================================ - --- rec's last one-line reason for exiting non-zero, so the alert the user --- actually sees can name the cause instead of pointing at the log. Cleared on --- every successful capture; see launchRecorder. -local lastRecorderFailure = nil - -local function sendRecording() - if not HARK_KEY or HARK_KEY == "" then - beep() - hs.alert.show("hark: no key configured — run install-client.sh or edit " .. configPath, 5) - return - end - - local f = io.open(WAV_PATH, "rb") - if not f then - beep() - -- rec deletes the file rather than leave an unusable one, and exits with a - -- single explanatory line. Show that line: it distinguishes a denied - -- microphone from a muted one from a dead device, and sending the user to - -- the log to find out is how a permission failure gets read as a - -- transcription failure. - hs.alert.show( - "hark: nothing was recorded.\n" - .. (lastRecorderFailure or ("See " .. LOG_PATH .. " for the reason.")), - 8 - ) - return - end - local audio = f:read("*a") - f:close() - - if not audio or #audio == 0 then - beep() - -- 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 here means rec died before finalizing the WAV header. - hs.alert.show( - "hark: recorded a zero-byte file - rec exited before finalizing the WAV.\n" - .. (lastRecorderFailure or ("See " .. LOG_PATH .. " for the reason.")), - 8 - ) - return - end - - print("hark: sending " .. #audio .. " bytes to " .. SERVER) - hs.http.asyncPost(SERVER, audio, { - ["X-Hark-Key"] = HARK_KEY, - ["Content-Type"] = "audio/wav", - }, handleDictateResponse) -end - --- ============================================================================ --- Record lifecycle --- ============================================================================ - -local recorderTask = 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 a --- single explanatory line rather than a multi-kilobyte banner. That is what --- makes plain `exitCode ~= 0` the right condition to log on. -local function launchRecorder(recorderPath) - recorderTask = hs.task.new(recorderPath, function(exitCode, _, stdErr) - -- Fires once rec has actually exited, which - because it caught the - -- SIGTERM from :terminate() below and released the AVAudioFile before - -- exiting - is also the moment the WAV header is final and the file is - -- safe to read. A stronger guarantee than any fixed sleep would be. - recorderTask = nil - lastRecorderFailure = nil - if exitCode ~= 0 then - logLine("rec exited " .. tostring(exitCode) .. ": " .. tostring(stdErr)) - -- rec's stderr is one line, already phrased for a human, and prefixed - -- "rec: " - strip the prefix and hand the rest to sendRecording. - local reason = tostring(stdErr):gsub("%s+$", ""):gsub("^rec: ", "") - if reason ~= "" then - lastRecorderFailure = reason - end - end - hideRecordingIndicator() - -- Sent immediately, with no settling delay. rec releases the AVAudioFile - -- (which finalizes the WAV header) and stops the engine BEFORE exit(0), - -- so by the time this callback runs the file is already complete - the - -- process-exit callback is the guarantee, and the 150 ms of "belt and - -- braces" that used to sit here was pure latency on every utterance. - sendRecording() - end, { WAV_PATH }) - - return recorderTask:start() -end - -local function startRecording() - if recorderTask then - return -- already recording; guards a spurious double key-down - end - - if not HARK_KEY or HARK_KEY == "" then - beep() - hs.alert.show("hark: no key configured — run install-client.sh or edit " .. configPath, 5) - return - end - - local recorderPath = resolveRecorder() - if not recorderPath then - beep() - hs.alert.show( - "hark: the recorder is not built (looked in " .. RECORDER_PATH .. "). " - .. "Run install-client.sh.", - 6 - ) - return - end - - os.remove(WAV_PATH) -- never read a stale WAV from a previous utterance - showRecordingIndicator() - - if not launchRecorder(recorderPath) then - recorderTask = nil - hideRecordingIndicator() - beep() - hs.alert.show("hark: the recorder failed to start (" .. recorderPath .. ")", 5) - end -end - -local function stopRecording() - if not recorderTask then - return -- key released with nothing recording (e.g. rec already died) - end - recorderTask:terminate() -- SIGTERM; rec finalizes the WAV header and exits 0 - -- Do NOT clear recorderTask or hide the indicator here. The completion - -- callback registered in launchRecorder() does both, exactly when rec - -- has actually exited - see the comment there. -end - --- ============================================================================ --- Hotkey --- ============================================================================ --- --- hs.hotkey.bind's real signature (verified against --- https://www.hammerspoon.org/docs/hs.hotkey.html#bind, not assumed) is: --- --- hs.hotkey.bind(mods, key, [message,] pressedfn, releasedfn, repeatfn) --- --- pressedfn fires on key-down, releasedfn on key-up, repeatfn on OS --- auto-repeat while held - three DISTINCT callback slots, not one callback --- with a boolean. `message` is an optional string at position 3; omitting --- it (as here) means position 3 is pressedfn, position 4 is releasedfn. --- repeatfn is also omitted - trailing Lua arguments can simply be left off - --- since nothing needs to happen while the key is held beyond what --- startRecording() already did on the initial press. --- --- Ctrl+Alt+Space, NOT Option+Cmd+Space: the latter is macOS's built-in --- Finder search shortcut and the system wins that fight. -hs.hotkey.bind({ "ctrl", "alt" }, "space", startRecording, stopRecording) - --- ============================================================================ --- Startup self-check: Accessibility --- ============================================================================ --- --- hs.hotkey.bind() above registers the hotkey unconditionally, but without --- the Accessibility permission Hammerspoon cannot actually capture a global --- keyboard event - the bind call succeeds either way, and the hotkey then --- just silently never fires. No error, no console message: holding --- Ctrl+Alt+Space does literally nothing, which is indistinguishable from --- several other possible causes (install-client.sh never having launched --- Hammerspoon at all, a broken config, etc.) unless this is called out --- explicitly, loudly, right here at load time. --- --- hs.accessibilityState() with no argument just checks and returns a --- boolean - it does not itself trigger the system permission prompt. -if not hs.accessibilityState() then - hs.alert.show( - "hark: Accessibility is NOT granted to Hammerspoon.\n" - .. "The hotkey (Ctrl+Alt+Space) CANNOT work until this is fixed.\n" - .. "System Settings -> Privacy & Security -> Accessibility -> turn ON Hammerspoon.", - 20 - ) -end - --- ============================================================================ --- Startup self-check: Microphone --- ============================================================================ --- --- Unlike Accessibility, macOS's Microphone privacy pane has no "+" button - --- it only lists apps that have ALREADY REQUESTED microphone access. On a --- fresh install Hammerspoon has never asked, so it doesn't appear in the --- list, so there's nothing to toggle. The only way to make macOS show the --- consent dialog (and make Hammerspoon show up in that list at all) is to --- actually try to open the mic - which is exactly what this probe does, at --- load time, instead of waiting for the user's first hotkey press. --- --- rec runs as Hammerspoon's CHILD process, so TCC attributes the request --- to Hammerspoon (the responsible app), not to rec or to whatever --- terminal happens to be running. That attribution is also why this MUST --- run from inside Hammerspoon and can never be equivalently done by running --- rec from a shell script - a shell probe would test the terminal's own --- microphone grant, a different and irrelevant permission. -local function probeMicrophone() - local recorderPath = resolveRecorder() - if not recorderPath then - -- Not a permission problem - the recorder simply isn't built. Leave - -- MIC_STATUS_PATH untouched (setup.sh's --doctor reports "missing" and - -- points at this) rather than writing a misleading "denied". - print("hark: microphone probe skipped - recorder not built at " .. RECORDER_PATH - .. " (run install-client.sh).") - return - end - - os.remove(MIC_PROBE_PATH) -- never inspect a stale probe from an earlier run - - -- status is "ok", "denied", or "error". The third exists because not every - -- way the probe can fail is a permission problem, and saying "denied" for a - -- muted device would send the user to a Microphone toggle that is already on. - local function finish(status, detail) - os.remove(MIC_PROBE_PATH) - - local statusFile = io.open(MIC_STATUS_PATH, "w") - if statusFile then - statusFile:write(status .. "\n" .. os.date("%Y-%m-%d %H:%M:%S") .. "\n") - -- One line, always third: --doctor reads it positionally, and rec's - -- stderr can carry newlines. - if detail then - statusFile:write((detail:gsub("%s+", " ")) .. "\n") - end - statusFile:close() - else - print("hark: could not write " .. MIC_STATUS_PATH .. " - setup.sh --doctor's mic check will report it as missing.") - end - - if status == "ok" then - -- Silent on success - do not nag on every config reload. - return - end - - if detail then - print("hark: microphone probe failed - " .. detail) - end - - if status == "denied" then - hs.alert.show( - "hark: Hammerspoon needs Microphone permission.\n" - .. "A consent dialog should have appeared just now - click Allow, then\n" - .. "reload this config (or just try the hotkey again).\n" - .. "If you missed the dialog or it never appeared: System Settings -> " - .. "Privacy & Security -> Microphone -> turn ON Hammerspoon.", - 20 - ) - else - hs.alert.show( - "hark: the microphone probe failed, but not on permission.\n" - .. (detail or "no detail") .. "\n" - .. "Run ./install-client.sh --doctor for the full picture.", - 20 - ) - end - end - - -- rec asks TCC before it opens the device and reserves exit 3 for the - -- answer, so the permission question is settled by that one code. It used to - -- be inferred from an empty capture, which could not work: an ungranted - -- process still receives buffers, full length and all zeros, so rec exited 0 - -- and this wrote "ok" for a microphone it could not actually hear (issue #9). - local probeTask = hs.task.new(recorderPath, function(exitCode, _, stdErr) - if exitCode == 0 then - finish("ok") - return - end - local reason = "rec exited " .. tostring(exitCode) .. "." - if stdErr and stdErr ~= "" then - reason = reason .. " stderr: " .. stdErr - end - finish(exitCode == 3 and "denied" or "error", reason) - end, { MIC_PROBE_PATH, "0.4" }) - - if not probeTask:start() then - finish("error", "rec failed to start (" .. recorderPath .. ").") - end -end - -probeMicrophone() - -hs.alert.show("hark loaded", 1.5) diff --git a/client/rec.swift b/client/rec.swift deleted file mode 100644 index 7e0d61c..0000000 --- a/client/rec.swift +++ /dev/null @@ -1,237 +0,0 @@ -// rec — record the system default audio input to a 16 kHz mono s16 WAV. -// -// usage: rec [max-seconds] -// -// Runs until SIGTERM (the dictate client sends it on key-up), or until -// max-seconds if given. Exits 0 having written a finalized WAV, or non-zero -// with a one-line reason on stderr. No file is left behind on failure. -// -// 0 a finalized WAV containing audio -// 1 something else went wrong (the reason is on stderr) -// 2 bad usage -// 3 TCC has not granted microphone access - client/init.lua keys on this -// exact code to tell a permission problem from every other failure -// 4 the default input device has no usable input stream (0 ch or 0 Hz) -// 5 the device delivered no audio at all -// 6 the device delivered only digital silence (muted, or level at zero) -// -// WHY THIS EXISTS INSTEAD OF ffmpeg -// -// ffmpeg's avfoundation input device reads the sample format from the first -// buffer the device delivers and accepts only PACKED layouts: f32, or signed -// 16/24/32-bit. A Focusrite Scarlett 2i2 offers exactly one physical layout, -// at every one of its six sample rates: 24-bit signed integer UNPACKED in 4 -// bytes, high-aligned. ffmpeg cannot consume that, and says so with the -// distinctly unhelpful "audio format is not supported". -// -// ffmpeg nonetheless worked about half the time, because CoreAudio sometimes -// hands a capture client the device's VIRTUAL format (Float32, converted by -// the HAL) rather than its physical one - and which one you get varies per -// open. That is the whole of the intermittency. No ffmpeg option influences -// it: the avfoundation demuxer exposes no audio format knob, and there is no -// packed format on the hardware to pin the device to. -// -// AVAudioEngine's inputNode is Float32 by contract and never exposes the -// physical format, so this failure mode cannot occur here. - -import AVFoundation -import Foundation - -func die(_ msg: String, _ code: Int32 = 1) -> Never { - FileHandle.standardError.write("rec: \(msg)\n".data(using: .utf8)!) - exit(code) -} - -let args = CommandLine.arguments -guard args.count >= 2 else { die("usage: rec [max-seconds]", 2) } -let outURL = URL(fileURLWithPath: args[1]) -let maxSeconds = args.count >= 3 ? Double(args[2]) : nil - -// PERMISSION IS A TCC FACT, NOT SOMETHING TO INFER FROM THE AUDIO. -// -// A process with no microphone grant is not refused the device. It opens -// normally, reports the device's real format, and receives the full -// complement of buffers - with every sample exactly zero. Measured on a -// Scarlett 2i2 from a process whose status was notDetermined: 153,600 frames -// in 3 s, peak amplitude 0.0. macOS substitutes silence rather than failing, -// so that an app cannot infer microphone activity it has no right to observe. -// -// 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. -let permissionHelp = "System Settings -> Privacy & Security -> Microphone " - + "-> turn Hammerspoon ON" - -switch AVCaptureDevice.authorizationStatus(for: .audio) { -case .authorized: - break -case .notDetermined: - // Nobody has asked yet, and Hammerspoon is not listed under Microphone - // until something does - so failing outright here would leave the user no - // toggle to flip. Ask, and wait for the answer; continuing without one - // would record the substituted silence. - // - // The wait pumps the main run loop instead of blocking on a semaphore: - // requestAccess delivers its completion on an unspecified queue, and a - // blocked main thread would deadlock if that queue turns out to be main. - var granted: Bool? - AVCaptureDevice.requestAccess(for: .audio) { granted = $0 } - while granted == nil { - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - } - if granted != true { die("microphone access denied - \(permissionHelp)", 3) } -case .denied, .restricted: - die("microphone access denied - \(permissionHelp)", 3) -@unknown default: - die("microphone access is in an unrecognized state - \(permissionHelp)", 3) -} - -let engine = AVAudioEngine() -let input = engine.inputNode -let inFormat = input.inputFormat(forBus: 0) - -// A device that is present but has no usable input stream reports 0 Hz. Bail -// with a specific message rather than installing a tap that never fires. -guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else { - die("default input device reports no usable input stream " - + "(\(inFormat.channelCount) ch, \(inFormat.sampleRate) Hz) - " - + "check System Settings -> Sound -> Input", 4) -} - -guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: 16000, channels: 1, interleaved: true) -else { die("could not build the 16 kHz mono target format") } - -guard let converter = AVAudioConverter(from: inFormat, to: target) else { - die("no converter from \(inFormat) to \(target)") -} - -// Held as an optional so stop() can release it: AVAudioFile writes the final -// WAV header when it is deallocated, so exiting without clearing this leaves -// a file whose header understates its length. -var file: AVAudioFile? -do { - file = try AVAudioFile(forWriting: outURL, settings: target.settings, - commonFormat: .pcmFormatInt16, interleaved: true) -} catch { - die("could not open \(outURL.path) for writing: \(error)") -} - -var framesWritten: AVAudioFramePosition = 0 -// Loudest sample seen, tracked as Int32 because abs(Int16.min) overflows. -// Only the zero/non-zero distinction is used - see stop(). -var peakSample: Int32 = 0 -let launchedAt = DispatchTime.now() -var firstBufferLoggedAt: Double? - -input.installTap(onBus: 0, bufferSize: 4096, format: inFormat) { buffer, _ in - if firstBufferLoggedAt == nil { - let ms = Double(DispatchTime.now().uptimeNanoseconds - launchedAt.uptimeNanoseconds) / 1e6 - firstBufferLoggedAt = ms - if ProcessInfo.processInfo.environment["REC_TIMING"] != nil { - FileHandle.standardError.write("rec: first buffer at \(Int(ms)) ms\n".data(using: .utf8)!) - } - // The countdown starts HERE, not at engine.start(): start() returns - // before a USB interface is actually delivering samples (~670 ms - // early on a Scarlett 2i2), so a window opened there would spend most - // of a short request waiting for the device. "max-seconds" should - // mean seconds of audio. - if let maxSeconds { - DispatchQueue.main.asyncAfter(deadline: .now() + maxSeconds) { stop() } - } - } - // Capacity must cover the resample ratio (48k -> 16k shrinks, but a - // device running at 44.1k or a partial buffer can round up), plus slack. - let ratio = target.sampleRate / inFormat.sampleRate - let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1024 - guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: capacity) else { return } - - var supplied = false - var error: NSError? - converter.convert(to: outBuffer, error: &error) { _, status in - if supplied { status.pointee = .noDataNow; return nil } - supplied = true - status.pointee = .haveData - return buffer - } - if let error { - FileHandle.standardError.write("rec: convert failed: \(error)\n".data(using: .utf8)!) - return - } - guard outBuffer.frameLength > 0 else { return } - do { - try file?.write(from: outBuffer) - framesWritten += AVAudioFramePosition(outBuffer.frameLength) - if let samples = outBuffer.int16ChannelData { - for i in 0.. Never { - input.removeTap(onBus: 0) - engine.stop() - file = nil // releases the AVAudioFile, which finalizes the WAV header - - // Permission was settled before the device was ever opened, so these are - // the two remaining ways to come back with nothing usable, and neither is - // a permission problem. Both leave no file: an empty or silent WAV would - // otherwise be POSTed and come back as "heard nothing", which reads like a - // transcription problem rather than a capture one. Say which it was. - if framesWritten == 0 { - try? FileManager.default.removeItem(at: outURL) - die("the input device delivered no audio at all - check System Settings " - + "-> Sound -> Input", 5) - } - // Every sample exactly zero is not a quiet room - a real ADC has a noise - // floor. It is a muted device, an input level at zero, or a virtual device - // with nothing routed into it. Deliberately an equality test and not a - // loudness threshold: quiet speech must still go through. - if peakSample == 0 { - try? FileManager.default.removeItem(at: outURL) - die("captured \(framesWritten) frames of digital silence - the input " - + "device is muted or its level is at zero", 6) - } - exit(0) -} - -// SIG_IGN first, then a dispatch source: a raw signal handler may not call -// arbitrary code, and exiting from one would skip the header finalization -// above. A dispatch source runs stop() as ordinary code on the main queue. -// -// The sources are kept in this array because they must stay alive for the -// whole run - a signal source that deallocates hands the signal back to its -// default disposition, and the process then dies on SIGTERM (exit 143) -// without ever finalizing the WAV. Retaining a `source as AnyObject` bridge -// does NOT keep the source itself alive; it retains a temporary. -var signalSources: [DispatchSourceSignal] = [] -for sig in [SIGTERM, SIGINT] { - signal(sig, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) - source.setEventHandler { stop() } - source.resume() - signalSources.append(source) -} - -// prepare() allocates the render resources up front, so start() only has to -// bring the device up. -engine.prepare() -do { - try engine.start() -} catch { - die("could not start the audio engine: \(error)") -} - -// Ceiling for the case where the first buffer never arrives at all - a device -// that stopped delivering. Without it the countdown above, which is armed by -// that first buffer, would never be scheduled and the process would sit -// forever. stop() then finds zero frames and exits 5. -if let maxSeconds { - DispatchQueue.main.asyncAfter(deadline: .now() + maxSeconds + 3.0) { stop() } -} - -RunLoop.main.run() diff --git a/install-client.sh b/install-client.sh index 5e3ec05..d298c59 100755 --- a/install-client.sh +++ b/install-client.sh @@ -2,795 +2,706 @@ # # hark — client setup. # -# Run this on the Mac you want to dictate FROM. On a single-machine setup -# that is the same Mac that runs the server; on a two-machine setup it is the -# laptop, not the transcribing desktop. It: +# Run this on the Mac you want to dictate FROM. On a single-machine setup that +# is the same Mac that runs the server; on a two-machine setup it is the +# laptop, not the transcribing desktop. It builds swift/ into +# ~/Applications/Hark.app and registers `hark agent` as a LaunchAgent so it +# starts at login. # -# 1. installs Hammerspoon via Homebrew and builds client/rec.swift -# 2. reads the shared secret from ~/.config/hark/key if the server runs on -# this same Mac; otherwise fetches it from the server over SSH -# 3. (nothing to pick — rec uses the system default input device) -# 4. writes ~/.hammerspoon/hark-config.lua (chmod 600 — it holds a secret) -# 5. links client/init.lua -> ~/.hammerspoon/init.lua (refuses to clobber a -# real file there — see the loud error if that happens) -# 6. actually starts Hammerspoon with the new config loaded (launches it if -# it wasn't running; quits + relaunches it if it was, since Hammerspoon -# does not auto-reload its config) -# 7. checks Accessibility permission for Hammerspoon and, if it's missing, -# opens the exact System Settings pane and BLOCKS until you confirm -# you've granted it. Microphone permission works differently and is NOT -# blocked on the same way — see step 8b's comment for why — instead -# this waits (up to 30s) for client/init.lua's own startup microphone -# probe to report an outcome, which is what actually triggers the -# consent dialog -# 8. runs the same live checks as `--doctor` (below) and refuses to print -# "setup complete" if any of them fail +# ./install-client.sh install or update +# ./install-client.sh skip the "server SSH host" prompt +# ./install-client.sh --doctor read-only diagnosis, changes nothing +# ./install-client.sh --uninstall remove the agent and its LaunchAgent # -# `./install-client.sh --doctor` runs step 8's checks on their own, read-only, -# changing nothing — useful any time the hotkey isn't working and you want to -# know exactly which piece is broken, without re-running the whole install. +# Safe to re-run: every step checks current state first, and the key is always +# re-read, so this doubles as "resync my key after the server rotated it". # -# Safe to re-run: every step checks current state before acting, and step 2 -# always re-reads the key fresh (so it also doubles as "resync my key after -# the server rotated it"). Re-running with permissions already granted is -# fast — the Accessibility check in step 7 only opens System Settings and -# blocks when it can't confirm the permission is already there, and the -# microphone probe in step 8b reports "ok" almost immediately once it's -# already been granted. +# MIGRATING FROM THE HAMMERSPOON CLIENT # -# This has been run for real, end to end, on one pair of Macs. The paths -# least likely to have been exercised on yours are the TCC permission -# prompts, which behave differently depending on what macOS has already -# granted. `--doctor` is the tool for that: it names the failing boundary -# rather than leaving you to guess. +# Until 2026-08-03 the client was Hammerspoon plus 505 lines of Lua, which +# meant Accessibility was granted to a general-purpose scriptable runtime whose +# config was a symlink into this repo — so a `git pull` changed what that grant +# covered without re-prompting. The native agent asks for the same permission +# with far less behind it. See GitHub issue #2. +# +# Two things this still does for anyone crossing that bridge: +# +# - ~/.hammerspoon/hark-config.lua is read into ~/.config/hark/client.json, +# if the latter does not exist yet. The old file is never modified. +# - Hammerspoon is quit if it is running, because Ctrl+Alt+Space is a +# system-wide registration and exactly one process gets it — whichever +# starts first wins and the loser reports it could not register. Pass +# --keep-hammerspoon to leave it alone. +# +# Once you are on the agent: `brew uninstall --cask hammerspoon` and remove +# ~/.hammerspoon/init.lua. Revoking Hammerspoon's Accessibility and Microphone +# grants is the actual point of the exercise, and quitting the app does not do +# it for you. set -euo pipefail -CONFIG_DIR="$HOME/.hammerspoon" -CONFIG_FILE="$CONFIG_DIR/hark-config.lua" -# Must match init.lua's RECORDER_PATH. -RECORDER_BIN="$CONFIG_DIR/rec" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# This script lives at the repo root; the client sources it installs live in -# client/. Keep these separate — conflating them silently symlinks -# ~/.hammerspoon/init.lua to a path that does not exist. -CLIENT_DIR="$REPO_DIR/client" -HARK_PORT=8911 -HAMMERSPOON_APP="/Applications/Hammerspoon.app" -HAMMERSPOON_BUNDLE_ID="org.hammerspoon.Hammerspoon" +APP_SRC="$REPO_DIR/swift/Packaging/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" +STATUS_JSON="$HARK_CONFIG_DIR/status.json" +# 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" + +# The server's SSH host, for a two-machine setup. Set by a bare argument or +# HARK_SERVER_HOST; otherwise prompted for, and only when no key is found +# locally. Deliberately separate from the server URL — see fetch_key_over_ssh. +SERVER_HOST="${HARK_SERVER_HOST:-}" 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)) +} + # ============================================================================== -# Diagnostics — shared between `--doctor` and the end of a normal run. -# -# Every check_* function prints exactly one PASS/FAIL line (with a remedy on -# FAIL) and returns 0/1. They never exit the script themselves: callers must -# use them as an `if`/`!` condition or append `|| true` when calling them as -# a bare statement, since a bare failing call under `set -e` would otherwise -# abort the whole script — the same class of bug this script already hit -# once with ffmpeg's expected-nonzero device-listing exit. +# Config # ============================================================================== -DOCTOR_FAILURES=0 +# 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" +} -doctor_pass() { printf ' \033[1;32mPASS\033[0m %s\n' "$1"; } -doctor_fail() { - printf ' \033[1;31mFAIL\033[0m %s\n' "$1" - printf ' fix: %s\n' "$2" - DOCTOR_FAILURES=$((DOCTOR_FAILURES + 1)) +# 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" } -check_hammerspoon_installed() { - if [[ -d "$HAMMERSPOON_APP" ]]; then - doctor_pass "Hammerspoon.app installed" - return 0 - fi - doctor_fail "Hammerspoon.app installed" "brew install --cask hammerspoon" - return 1 +# 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' } -check_hammerspoon_running() { - if pgrep -x Hammerspoon >/dev/null 2>&1; then - doctor_pass "Hammerspoon is running" - return 0 +# Emits the client config the agent actually accepts. +# +# The agent enforces a transport policy: plain HTTP to loopback always; to a +# numeric IP only with an explicit allowPlaintext; to a HOSTNAME never, because +# a name resolves through something and a MagicDNS name is a hostname. Writing +# a config the agent will refuse just moves the failure to first launch, so the +# refusal happens here where it can be explained. +write_client_config() { + local server="$1" key="$2" host scheme plaintext="false" + scheme="${server%%://*}" + host="${server#*://}"; host="${host%%/*}"; host="${host%%:*}" + + if [[ "$scheme" == "http" ]] && ! is_loopback_host "$host"; then + if ! is_numeric_ip "$host"; then + err "the agent will refuse plain HTTP to the hostname '${host}'." + err "Use the numeric address instead — a Tailscale MagicDNS name is a" + err "hostname, so use the tailnet IP — or serve it over https://." + exit 1 + fi + # A numeric IP on a tailnet is a defensible place for plaintext, but it + # should be a stated decision rather than a silent default. + plaintext="true" + warn "audio and transcripts will cross the network unencrypted to ${host}." + warn "On a tailnet that is defensible; recording it as allowPlaintext." fi - doctor_fail "Hammerspoon is running" "open -a Hammerspoon (or re-run ./install-client.sh, which does this for you)" - return 1 + + mkdir -p "$HARK_CONFIG_DIR" + # 600 BEFORE the secret goes in, so there is no world-readable window. + : > "$CLIENT_CONFIG" + chmod 600 "$CLIENT_CONFIG" + cat > "$CLIENT_CONFIG" <&2 + printf ' %s ("key" field)\n' "$CLIENT_CONFIG" >&2 + printf ' %s (key = "...")\n' "$LEGACY_CONFIG" >&2 + printf ' %s (the server'"'"'s own key, if it runs on this Mac)\n' "$SERVER_KEY" >&2 + printf ' For a two-machine setup, pass the server'"'"'s SSH host:\n' >&2 + printf ' ./install-client.sh \n' >&2 + exit 1 + fi + + write_client_config "$server" "$key" + log "wrote $CLIENT_CONFIG (600) — server: $server" + + # The key came from another Mac but the URL is still loopback, which would + # POST every recording into the void on this one. Cheap to say, and the + # alternative — deriving the URL from the SSH host — is exactly the guess + # that makes a config look healthy while the client silently fails. + if [[ "$server" == "$DEFAULT_SERVER" && -n "$SERVER_HOST" ]]; then + warn "the server URL is still the loopback default, but the key came from" + warn "'$SERVER_HOST'. Set the real address in $CLIENT_CONFIG." fi - doctor_fail "${CONFIG_DIR}/init.lua is a symlink to this repo's client/init.lua" \ - "ln -sf ${CLIENT_DIR}/init.lua ${CONFIG_DIR}/init.lua (if it's a real file instead, move it aside first — see README)" - return 1 } -check_config_file() { - if [[ ! -f "$CONFIG_FILE" ]]; then - doctor_fail "${CONFIG_FILE} exists" "run ./install-client.sh" - return 1 +# Fetches the shared secret from the Mac running the server. Prints it on +# stdout; prints nothing and returns non-zero on any failure. +# +# The SSH host is NOT derived from the server URL, nor the URL from the host. +# An alias that works for `ssh ` — a ~/.ssh/config entry, a MagicDNS +# name — is not necessarily an address curl can reach. +fetch_key_over_ssh() { + if [[ -z "$SERVER_HOST" ]]; then + # Non-interactive (CI, a piped install): fail to the caller's message + # rather than blocking forever on a read that can never be answered. + [[ -t 0 ]] || return 1 + { + echo + echo "No key found on this Mac, so hark's server is presumably another one." + echo + echo "If it should be THIS Mac, quit (Ctrl-C) and run ./install-server.sh first." + echo + echo "Otherwise give the server's SSH host — exactly what you would type for" + echo "'ssh ' today (a ~/.ssh/config alias, a Tailscale name, or an IP)." + } >&2 + read -rp "server SSH host: " SERVER_HOST fi + [[ -n "$SERVER_HOST" ]] || return 1 + + log "fetching the shared secret from ${SERVER_HOST} over SSH..." >&2 + local err_file fetched rc=0 + err_file="$(mktemp)" - local mode - mode="$(stat -f '%Lp' "$CONFIG_FILE" 2>/dev/null || true)" - if [[ "$mode" != "600" ]]; then - doctor_fail "${CONFIG_FILE} is mode 600 (found: ${mode:-unreadable})" "chmod 600 ${CONFIG_FILE}" + fetched="$(ssh -o ConnectTimeout=10 "$SERVER_HOST" 'cat ~/.config/hark/key' 2>"$err_file")" || rc=$? + if [[ "$rc" -ne 0 ]]; then + err "could not fetch the key from '${SERVER_HOST}'. Likely causes:" + err " - you are not on the same network/tailnet right now" + err " - '${SERVER_HOST}' is not the right SSH host/alias for the server" + err " - SSH key auth to that host is not set up (if it hung, that is probably it)" + err " - ~/.config/hark/key does not exist there — run ./install-server.sh on it" + err "ssh said:" + sed 's/^/ /' "$err_file" >&2 || true + rm -f "$err_file" return 1 fi + rm -f "$err_file" - if ! grep -qE '^[[:space:]]*key[[:space:]]*=[[:space:]]*"[^"]+"' "$CONFIG_FILE"; then - doctor_fail "${CONFIG_FILE} has a non-empty key" "re-run ./install-client.sh" + fetched="$(printf '%s' "$fetched" | tr -d '[:space:]')" + if [[ -z "$fetched" ]]; then + err "fetched an EMPTY key from ${SERVER_HOST} — check ~/.config/hark/key there." return 1 fi + printf '%s' "$fetched" +} - doctor_pass "${CONFIG_FILE} exists, mode 600, has a key" - return 0 +# ============================================================================== +# 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 } -# Extracts a quoted field's value from hark-config.lua, e.g. for a line -# ` server = "http://...",` prints `http://...`. Prints nothing (and -# returns 1) if the field isn't present. -config_field() { - local field="$1" - if [[ ! -f "$CONFIG_FILE" ]]; then +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-client.sh" return 1 fi - local value - value="$(grep -E "^[[:space:]]*${field}[[:space:]]*=" "$CONFIG_FILE" 2>/dev/null \ - | sed -E 's/^[^"]*"([^"]*)".*/\1/' || true)" - if [[ -z "$value" ]]; then + 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-client.sh" return 1 fi - printf '%s' "$value" + if ! codesign --verify --strict "$APP_DST" 2>/dev/null; then + doctor_fail "Hark.app has a valid signature" \ + "rebuild it: ./install-client.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})" } -# Mirrors init.lua's resolveRecorder(): trust the configured path if it's a -# real executable, else the default build location. -resolve_recorder_for_doctor() { - local configured - configured="$(config_field recorder || true)" - if [[ -n "$configured" && -x "$configured" ]]; then - printf '%s' "$configured" - return 0 +check_config() { + if [[ ! -f "$CLIENT_CONFIG" ]]; then + doctor_fail "$CLIENT_CONFIG exists" "run ./install-client.sh" + return 1 fi - if [[ -x "$RECORDER_BIN" ]]; then - printf '%s' "$RECORDER_BIN" - return 0 + 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 - return 1 + if ! json_field key >/dev/null; then + doctor_fail "$CLIENT_CONFIG has a key" "run ./install-client.sh" + return 1 + fi + doctor_pass "$CLIENT_CONFIG is present, 600, and has a key" } -check_recorder() { - local resolved - resolved="$(resolve_recorder_for_doctor || true)" - if [[ -z "$resolved" ]]; then - doctor_fail "rec is built" "re-run ./install-client.sh (it compiles client/rec.swift)" +check_agent_running() { + if ! agent_loaded; then + doctor_fail "the agent is loaded in launchd" "run ./install-client.sh" return 1 fi - # Deliberately does NOT run it: rec opens the microphone, and a run from - # this shell would test the terminal's TCC grant rather than Hammerspoon's - # — the same trap check_mic_permission() below exists to avoid. - doctor_pass "rec is built; init.lua would use: ${resolved}" - return 0 + if ! pgrep -f "$APP_DST/Contents/MacOS/hark" >/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 client/init.lua's own startup microphone probe wrote to -# ~/.hammerspoon/.hark-mic-status. This is the ONLY reliable way to learn -# whether HAMMERSPOON can reach the microphone: TCC grants are attributed to -# whichever app is responsible for the process that opened the device, and -# rec runs as Hammerspoon's child — so a probe run from THIS shell script -# would test the terminal's own microphone grant, a different permission -# that would produce a confidently wrong PASS. Never run rec from here to -# "test" this; read the file init.lua already wrote. +# 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. +# Reads one field out of the agent's status.json without needing jq. # -# That is still true now that rec asks TCC directly instead of inferring the -# answer from a frame count: authorizationStatus resolves against the -# responsible process too. Run from a terminal it reports on the terminal. -check_mic_permission() { - local status_file="$CONFIG_DIR/.hark-mic-status" - if [[ ! -f "$status_file" ]]; then - doctor_fail "Hammerspoon can reach the microphone" \ - "Hammerspoon hasn't probed the mic yet — is it running? (open -a Hammerspoon)" +# THE AGENT REPORTS ON ITSELF, and nothing here measures it from outside. +# That is not a style choice — both permissions were got wrong the other way +# during bring-up: +# +# - a microphone probe run from this script tests the TERMINAL's grant, not +# the agent's, because TCC attributes to the responsible process. A +# confidently wrong PASS. +# - querying TCC.db for Accessibility reports what was true for some EARLIER +# build. An ad-hoc signature's designated requirement is a bare cdhash, so +# every rebuild is a new identity while the old row survives reading +# granted — and System Settings keeps drawing a switched-ON toggle for a +# binary nothing trusts. This check printed PASS while the agent was +# alerting on screen that it could not paste. +status_field() { + local field="$1" + [[ -f "$STATUS_JSON" ]] || return 1 + local value + value="$(sed -E 's/.*"'"${field}"'"[[:space:]]*:[[:space:]]*"?([^",}]*)"?.*/\1/' "$STATUS_JSON" 2>/dev/null)" + [[ -n "$value" ]] || return 1 + printf '%s' "$value" +} + +# The heartbeat rewrites status.json every 30s, so a stale file means the agent +# died without saying so and every field in it is a claim about a process that +# no longer exists. +status_is_fresh() { + local written now + written="$(status_field written_epoch)" || return 1 + now="$(date +%s)" + [[ $((now - written)) -lt 120 ]] +} + +check_status_freshness() { + if [[ ! -f "$STATUS_JSON" ]]; then + doctor_fail "the agent has reported its status" \ + "the agent has not started yet — run ./install-client.sh" return 1 fi + if ! status_is_fresh; then + doctor_fail "the agent's status is current" \ + "status.json is stale (>120s) — the agent is not running; check /tmp/hark-agent.err" + return 1 + fi + doctor_pass "the agent is reporting (pid $(status_field pid))" +} - local status - status="$(head -n 1 "$status_file" 2>/dev/null || true)" - case "$status" in - ok) - doctor_pass "Hammerspoon can reach the microphone" - return 0 - ;; - denied) - doctor_fail "Hammerspoon can reach the microphone" \ - "System Settings -> Privacy & Security -> Microphone -> turn ON Hammerspoon (it will be listed now — it has finally asked)" - return 1 - ;; - error) - # The probe failed for a reason that is not permission — a muted device, - # a dead input, rec missing. Sending the user to the Microphone toggle - # would be a wrong answer, so report what actually happened instead. - local detail - detail="$(sed -n '3p' "$status_file" 2>/dev/null || true)" - doctor_fail "Hammerspoon can reach the microphone" \ - "the probe failed, but not on permission: ${detail:-see $status_file}" - return 1 - ;; - *) - doctor_fail "Hammerspoon can reach the microphone (unrecognized status in ${status_file}: '${status:-empty}')" \ - "reload Hammerspoon's config (menu bar icon -> Reload Config) to re-run the probe" - return 1 - ;; +check_mic() { + local v + v="$(status_field microphone || true)" + case "$v" in + authorized) doctor_pass "the agent can reach the microphone" ;; + "") doctor_fail "the agent can reach the microphone" "no status yet — is the agent running?" ;; + *) doctor_fail "the agent can reach the microphone (reported: $v)" \ + "System Settings -> Privacy & Security -> Microphone -> turn ON hark" ;; esac } -check_server_url() { - local server - if ! server="$(config_field server)"; then - doctor_fail "server URL is well-formed" "no server URL in ${CONFIG_FILE} — run ./install-client.sh first" - return 1 - fi - # An SSH-style "user@host" leaking into the HTTP URL. curl tolerates it (so - # a reachability check alone passes), but it is basic-auth userinfo, not an - # SSH target, and Hammerspoon's hs.http (NSURL) is stricter than curl. This - # is exactly how a config can look healthy while the client silently fails. - if [[ "$server" =~ ^[a-z]+://[^/@]+@ ]]; then - doctor_fail "server URL is well-formed (${server})" \ - "the URL contains SSH-style 'user@' userinfo. Re-run ./install-client.sh to rewrite it, or edit ${CONFIG_FILE} and delete the 'user@' from the server line." +# The hotkey field used to be a hardcoded "registered" literal, so it reported +# success whether or not anything was bound — which is exactly how a dead +# CGEventTap looked healthy from outside. It now reflects the real binding. +check_hotkey_bound() { + local v + v="$(status_field hotkey || true)" + case "$v" in + registered) doctor_pass "Ctrl+Alt+Space is bound" ;; + "") doctor_fail "Ctrl+Alt+Space is bound" "no status yet — is the agent running?" ;; + *) doctor_fail "Ctrl+Alt+Space is bound (reported: $v)" \ + "something else is holding the chord — quit it and re-run ./install-client.sh" ;; + esac +} + +check_accessibility() { + local v + v="$(status_field accessibility || true)" + case "$v" in + trusted) doctor_pass "Accessibility is granted" ;; + "") doctor_fail "Accessibility is granted" "no status yet — is the agent running?" ;; + *) doctor_fail "Accessibility is granted (reported: $v)" \ + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark, then re-run this" ;; + esac +} + +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 "server URL is well-formed (${server})" - return 0 + doctor_pass "nothing else is holding Ctrl+Alt+Space" } +# The permissions and the process can all be healthy while the server is +# simply unreachable — a downed tailnet, a stopped service — and the symptom +# of that is identical to a microphone fault from the user's chair: you hold +# the key, speak, and nothing appears. check_health() { local server url - if ! server="$(config_field server)"; then - doctor_fail "server /health reachable" "no server URL in ${CONFIG_FILE} — run ./install-client.sh first" + if ! server="$(json_field server)"; then + doctor_fail "server /health reachable" "no server URL in $CLIENT_CONFIG — run ./install-client.sh" return 1 fi url="${server%/dictate}/health" - if curl -sf --max-time 5 "$url" >/dev/null 2>&1; then - doctor_pass "server /health reachable (${url})" + doctor_pass "server /health reachable ($url)" return 0 fi - doctor_fail "server /health reachable (${url})" "check the tailnet (tailscale status) and that hark is running on the server (ssh launchctl list | grep hark)" + doctor_fail "server /health reachable ($url)" \ + "check the tailnet (tailscale status) and that hark is running on the server (ssh launchctl list | grep hark)" return 1 } -# POSTs a tiny generated-on-the-fly silent WAV to /dictate and checks the key -# authenticates. A 200 or 400 both prove the key is good (the server checks -# X-Hark-Key before it looks at the audio at all, so either response means -# auth passed); a 401 proves it isn't. -check_key_auth() { - local server key - if ! server="$(config_field server)" || ! key="$(config_field key)"; then - doctor_fail "key authenticates against /dictate" "hark-config.lua is missing server or key — run ./install-client.sh" - return 1 - fi - - local tmp_dir tmp_wav - tmp_dir="$(mktemp -d)" - tmp_wav="$tmp_dir/probe.wav" - - # Half a second of silence, rather than recording anything: this check is - # about whether the KEY is accepted, and opening the microphone here would - # both prompt for a permission this script does not need and test the - # terminal's TCC grant instead of Hammerspoon's. The server answers - # 200-with-empty-transcript for silence, which is a pass — only a 401 fails. - # - # Written with printf and dd rather than a python3 one-liner. That one-liner - # quietly made Python a requirement on the CLIENT Mac, which otherwise needs - # only Homebrew, Hammerspoon and swiftc — and when it was missing, the check - # failed in a way that read like a hark problem rather than a missing - # interpreter. - # - # A 16 kHz mono 16-bit WAV of silence is a fixed 44-byte header followed by - # zeros. Header fields below are little-endian: RIFF chunk size 16036 - # (36 + data), fmt chunk 16, PCM format 1, 1 channel, 16000 Hz, byte rate - # 32000, block align 2, 16 bits per sample, data size 16000. - if ! { - printf 'RIFF\244\076\000\000WAVEfmt \020\000\000\000\001\000\001\000\200\076\000\000\000\175\000\000\002\000\020\000data\200\076\000\000' && - dd if=/dev/zero bs=16000 count=1 2>/dev/null - } >"$tmp_wav"; then - doctor_fail "key authenticates against /dictate" "could not write the test WAV to ${tmp_dir}" - rm -rf "$tmp_dir" +run_doctor() { + printf '\nhark client diagnostics\n\n' + check_app_installed || true + check_signature || true + check_config || true + check_agent_running || true + check_status_freshness || true + check_hotkey_conflict || true + check_hotkey_bound || true + check_mic || true + check_accessibility || true + check_health || true + printf '\n' + if [[ "$doctor_failures" -gt 0 ]]; then + err "$doctor_failures check(s) failed" return 1 fi - - local status - status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ - -X POST "$server" \ - -H "X-Hark-Key: ${key}" \ - -H "Content-Type: audio/wav" \ - --data-binary "@${tmp_wav}" 2>/dev/null || true)" - rm -rf "$tmp_dir" - - case "$status" in - 200|400) - doctor_pass "key authenticates against /dictate (HTTP ${status})" - return 0 - ;; - 401) - doctor_fail "key authenticates against /dictate (HTTP 401)" "the key in ${CONFIG_FILE} doesn't match the server's ~/.config/hark/key — re-run ./install-client.sh to refetch it" - return 1 - ;; - *) - doctor_fail "key authenticates against /dictate (got: ${status:-no response})" "could not get a clean response from ${server} — check the tailnet and that hark is running" - return 1 - ;; - esac + log "all checks passed" } -run_diagnostics() { - DOCTOR_FAILURES=0 - echo - check_hammerspoon_installed || true - check_hammerspoon_running || true - check_init_symlink || true - check_config_file || true - check_recorder || true - check_mic_permission || true - check_server_url || true +# ============================================================================== +# Uninstall +# ============================================================================== - check_health || true - check_key_auth || true - echo - if [[ "$DOCTOR_FAILURES" -eq 0 ]]; then - log "All checks passed." - return 0 +run_uninstall() { + if agent_loaded; then + launchctl bootout "gui/$(id -u)/$AGENT_LABEL" 2>/dev/null || true + log "unloaded $AGENT_LABEL" fi - err "${DOCTOR_FAILURES} check(s) failed — see the FAIL lines above, each names its exact fix." - return 1 + 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." } # ============================================================================== -# Arg parsing +# Main # ============================================================================== -DOCTOR_MODE=false -POSITIONAL_ARGS=() +# 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) - DOCTOR_MODE=true - ;; + --doctor) MODE="doctor" ;; + --uninstall) MODE="uninstall" ;; + --keep-hammerspoon) KEEP_HAMMERSPOON=1 ;; -h|--help) - echo "Usage: $0 [--doctor] [server-ssh-host]" - echo " (no args) run the full interactive install" - echo " server-ssh-host skip the 'server SSH host' prompt" - echo " --doctor read-only: run the diagnostic checks and exit" + sed -n '2,37p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 ;; + -*) + err "unknown option: $arg (try --help)" + exit 2 + ;; *) - POSITIONAL_ARGS+=("$arg") + # A bare argument is the server's SSH host, so a two-machine install can + # skip the prompt. Not merged with the server URL: an alias that works + # for `ssh ` is not necessarily something curl can reach. + if [[ -n "$SERVER_HOST" ]]; then + err "more than one SSH host given: '$SERVER_HOST' and '$arg'" + exit 2 + fi + SERVER_HOST="$arg" ;; esac done -# --- 0. sanity --------------------------------------------------------------- - -if [[ "$(uname -s)" != "Darwin" ]]; then - err "this installs macOS-only tools (Hammerspoon, avfoundation). Run it on the client Mac." - exit 1 -fi - -if $DOCTOR_MODE; then - log "hark --doctor: read-only checks, nothing will be changed." - if run_diagnostics; then - exit 0 - else - exit 1 - fi -fi - -if ! command -v brew >/dev/null 2>&1; then - err "Homebrew is not installed. Install it first: https://brew.sh" - exit 1 -fi - -# --- 1. Homebrew installs ----------------------------------------------------- - -log "Checking Hammerspoon..." -if brew list --cask hammerspoon >/dev/null 2>&1; then - log "hammerspoon already installed." -else - log "Installing hammerspoon..." - brew install --cask hammerspoon -fi - -# Recording is NOT done with ffmpeg. Its avfoundation input device accepts -# only packed sample layouts, and a 24-bit USB interface (a Focusrite -# Scarlett 2i2, for one) offers nothing but 24-bit UNPACKED — so ffmpeg dies -# with "audio format is not supported" whenever CoreAudio hands it the -# device's physical format rather than the converted Float32 virtual one. -# Which of the two you get varies per open, so it failed roughly half the -# time. client/rec.swift uses AVAudioEngine, whose input is Float32 by -# contract and never sees the physical format. See its header comment. -log "Building the recorder (client/rec.swift)..." -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 - -mkdir -p "$CONFIG_DIR" -if ! swiftc -O -o "$RECORDER_BIN" "$CLIENT_DIR/rec.swift"; then - err "could not build $CLIENT_DIR/rec.swift" - exit 1 -fi -log "recorder: $RECORDER_BIN" - -# --- 2. Shared secret --------------------------------------------------------- -# -# Two cases, and the local one is the default because it is the one that -# needs no explanation: if hark's server runs on THIS Mac, the key is simply -# sitting in ~/.config/hark/key and there is no network involved at all. -# -# Only when it isn't there do we ask for an SSH host — that means the server -# is another machine. The host is deliberately NOT guessed: an alias that -# works for `ssh ` (e.g. via ~/.ssh/config) is not necessarily a -# hostname curl can reach, so it is asked for separately from the HTTP URL in -# step 4 below. - -LOCAL_KEY_FILE="$HOME/.config/hark/key" -SERVER_HOST="${POSITIONAL_ARGS[0]:-${HARK_SERVER_HOST:-}}" - -if [[ -z "$SERVER_HOST" && -s "$LOCAL_KEY_FILE" ]]; then - log "Found a local shared secret (${LOCAL_KEY_FILE}) — single-machine setup, no SSH needed." - HARK_KEY="$(tr -d '\n' < "$LOCAL_KEY_FILE")" -else - if [[ -z "$SERVER_HOST" ]]; then - echo - echo "No local key at ${LOCAL_KEY_FILE}, so hark's server is presumably" - echo "another machine." - echo - echo "If it should be THIS Mac, quit (Ctrl-C) and run ./install-server.sh first." - echo - echo "Otherwise give the server's SSH host — exactly what you'd type for" - # shellcheck disable=SC2088 # literal text for the user to read, not a path to expand - echo "'ssh ' today (a ~/.ssh/config alias, a Tailscale MagicDNS name," - echo "or a private IP). Not guessed automatically." - read -rp "server SSH host: " SERVER_HOST - fi - if [[ -z "$SERVER_HOST" ]]; then - err "no server SSH host given, aborting." - exit 1 - fi - - log "Fetching the shared secret from ${SERVER_HOST}:~/.config/hark/key over SSH..." - SSH_ERR_FILE="$(mktemp)" - trap 'rm -f "$SSH_ERR_FILE"' EXIT - - if ! HARK_KEY="$(ssh -o ConnectTimeout=10 "$SERVER_HOST" cat ~/.config/hark/key 2>"$SSH_ERR_FILE")"; then - err "could not fetch the key from '${SERVER_HOST}'. Likely causes:" - err " - you're not on the same network/tailnet right now" - err " - '${SERVER_HOST}' isn't the right SSH host/alias for the server" - err " - SSH key auth to that host isn't set up (if it hung, that's probably it)" - err " - ~/.config/hark/key doesn't exist on the server — run" - err " ./install-server.sh there first" - err "ssh said:" - sed 's/^/ /' "$SSH_ERR_FILE" >&2 || true - exit 1 - fi +case "$MODE" in + doctor) run_doctor; exit $? ;; + uninstall) run_uninstall; exit 0 ;; +esac - if [[ -z "$HARK_KEY" ]]; then - err "fetched an EMPTY key from ${SERVER_HOST}. Check ~/.config/hark/key on the server isn't a zero-byte file." - exit 1 - fi -fi -if [[ "$HARK_KEY" == *'"'* || "$HARK_KEY" == *$'\n'* ]]; then - err "the fetched key contains a quote or newline, which would break the generated Lua config. This is unexpected — check ~/.config/hark/key on the server by hand." - exit 1 -fi -log "Got the shared secret (${#HARK_KEY} characters)." +log "building the agent" +(cd "$REPO_DIR/swift" && swift build -c release && bash Packaging/build-app.sh) -# --- 3. Microphone selection -------------------------------------------------- -# -# There is nothing to ask. rec records the SYSTEM DEFAULT input, so the mic is -# chosen in System Settings -> Sound -> Input like every other app on the -# machine. Earlier versions asked for an avfoundation device INDEX, which was -# both an extra thing to get wrong and genuinely unstable: those indices are -# positional, so a virtual device appearing or disappearing (Loom installs -# one) silently renumbers every device after it. - -log "Microphone: whatever is selected in System Settings -> Sound -> Input." - -# --- 4. server HTTP URL + reachability ---------------------------------------- - -if [[ -z "$SERVER_HOST" ]]; then - # Single machine: the server is right here, so there is nothing to ask and - # nothing to resolve. Loopback is not a guess, it is the only correct answer. - DEFAULT_URL="http://127.0.0.1:${HARK_PORT}/dictate" - HARK_URL="$DEFAULT_URL" - log "Server URL: ${HARK_URL} (this Mac)" -else - # SERVER_HOST is an SSH target, so it may carry a "user@" prefix and/or a - # ":port" suffix. Neither belongs in an HTTP URL: "user@" is basic-auth - # userinfo, which hark ignores, and an SSH port is not the HTTP port. - # curl tolerates the userinfo form, so this drifted through --doctor as a - # PASS while writing http://user@some-host:8911/dictate into the config. - # Hammerspoon's hs.http (NSURL) is stricter than curl, so strip both. - HTTP_HOST="${SERVER_HOST##*@}" # drop "user@" - HTTP_HOST="${HTTP_HOST%%:*}" # drop any ":port" - - DEFAULT_URL="http://${HTTP_HOST}:${HARK_PORT}/dictate" - echo - echo "server /dictate URL. This must be directly reachable by curl/HTTP — an" - echo "SSH config alias may not be (SSH config aliases aren't read by curl)." - echo "If '${HTTP_HOST}' isn't itself a resolvable hostname, use the server's" - echo "private IP with port ${HARK_PORT}." - read -rp "server /dictate URL [${DEFAULT_URL}]: " HARK_URL - HARK_URL="${HARK_URL:-$DEFAULT_URL}" -fi +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" -# Guard the hand-typed case too: a pasted "http://user@host:8911/dictate" is -# just as wrong as a derived one. -if [[ "$HARK_URL" =~ ^([a-z]+://)([^/@]+@)(.*)$ ]]; then - HARK_URL="${BASH_REMATCH[1]}${BASH_REMATCH[3]}" - warn "Stripped the 'user@' from the URL — that's SSH syntax, not HTTP." - warn "Using: ${HARK_URL}" -fi +# 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 -HEALTH_URL="${HARK_URL%/dictate}/health" -log "Checking ${HEALTH_URL} ..." -if curl -sf --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then - log "hark is reachable." -else - warn "could not reach ${HEALTH_URL}." - if [[ -z "$SERVER_HOST" ]]; then - warn " - the server doesn't appear to be running on this Mac: ./install-server.sh" - warn " - check its state: launchctl list | grep hark, and /tmp/hark.err" - else - warn " - check the network/tailnet path to ${SERVER_HOST}" - warn " - check hark is running there: ssh ${SERVER_HOST} launchctl list | grep hark" - fi - warn " - the client will still be configured below; fix reachability before using it." -fi - -# --- 5. Write the client config ------------------------------------------------ - -mkdir -p "$CONFIG_DIR" -umask 077 -cat > "$CONFIG_FILE" < Sound -> Input. -return { - server = "${HARK_URL}", - key = "${HARK_KEY}", - recorder = "${RECORDER_BIN}", -} -LUACONFIG -chmod 600 "$CONFIG_FILE" -log "Wrote ${CONFIG_FILE} (chmod 600)." - -# --- 6. Install init.lua ------------------------------------------------------- -# -# A pre-existing REAL file here (not a symlink) means Hammerspoon would load -# THAT file instead of this repo's client/init.lua and hark would never -# fire — silently, with no error anywhere. That is exactly the failure mode -# this whole fix is about, so this is a hard stop, not a warning to scroll past. - -if [[ -e "$CONFIG_DIR/init.lua" && ! -L "$CONFIG_DIR/init.lua" ]]; then - err "${CONFIG_DIR}/init.lua already exists as a REAL file (not a symlink) — refusing to overwrite it." - err "Hammerspoon would load THAT file instead of this repo's client/init.lua, and the hotkey would never be bound." - err "Fix it, then re-run this script:" - err " mv ${CONFIG_DIR}/init.lua ${CONFIG_DIR}/init.lua.bak" - err "(merge anything you need from init.lua.bak into ${CLIENT_DIR}/init.lua by hand afterward, if you had custom config there)" - exit 1 -fi -ln -sf "${CLIENT_DIR}/init.lua" "$CONFIG_DIR/init.lua" -log "Linked ${CONFIG_DIR}/init.lua -> ${CLIENT_DIR}/init.lua" - -# --- 7. Actually start Hammerspoon with the new config ------------------------- -# -# THE BUG THIS SCRIPT USED TO HAVE: `brew install --cask hammerspoon` installs -# the app bundle but never runs it. Every previous version of this script just -# told the user to "open Hammerspoon (menu bar icon) -> Reload Config" — but on -# a fresh install there IS no menu bar icon, because the app has never been -# opened. init.lua never loads, the hotkey never binds, and holding the key -# does literally nothing: no alert, no beep, no error, no HTTP request. That -# exactly matches the symptom this fix exists to close. -# -# Hammerspoon does not auto-reload its config, and the `hs` CLI (`hs -c`, -# which could trigger a reload remotely) is not installed unless the user has -# already run hs.ipc.cliInstall() — so if Hammerspoon is already running, the -# only reliable way to make it pick up a new config is to quit and relaunch it. - -log "Starting Hammerspoon with the new config..." -if pgrep -x Hammerspoon >/dev/null 2>&1; then - log "Hammerspoon is already running — quitting it so it reloads the new config (it does not auto-reload)." - osascript -e 'quit app "Hammerspoon"' >/dev/null 2>&1 || true - for _ in $(seq 1 20); do - if ! pgrep -x Hammerspoon >/dev/null 2>&1; then - break - fi - sleep 0.5 - done - if pgrep -x Hammerspoon >/dev/null 2>&1; then - warn "Hammerspoon didn't quit within 10s — forcing it closed." - pkill -x Hammerspoon >/dev/null 2>&1 || true - sleep 1 - fi -fi +resolve_config -if ! open -a Hammerspoon; then - err "could not launch Hammerspoon via 'open -a Hammerspoon'. Is it installed at ${HAMMERSPOON_APP}?" - exit 1 +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 -HAMMERSPOON_STARTED=false -for _ in $(seq 1 20); do - if pgrep -x Hammerspoon >/dev/null 2>&1; then - HAMMERSPOON_STARTED=true +# 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 "$STATUS_JSON" + +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 "$STATUS_JSON" ]]; 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 - sleep 0.5 -done -if ! $HAMMERSPOON_STARTED; then - err "Hammerspoon did not start within 10s of 'open -a Hammerspoon'." - err "Try opening it by hand from /Applications, then re-run: ./install-client.sh --doctor" - exit 1 -fi -log "Hammerspoon is running with the new config loaded." - -# --- 8. Accessibility permission ------------------------------------------------ -# -# Cannot be granted from a script — macOS requires a human click in System -# Settings. What CAN be scripted: detecting whether it's already granted -# (best-effort — see tcc_allowed below), opening the exact pane instead of -# making the user hunt for it, and blocking here instead of printing advice -# into a wall of text at the very end that's easy to miss. -# -# This works as a pre-grantable, block-and-confirm step because the -# Accessibility pane has a "+" button and lists every installed app whether -# or not it has ever run — Hammerspoon requesting it is not a precondition -# for it appearing in the list. Microphone is fundamentally different (see -# step 8b below): it has no "+" button and only lists apps that have -# ALREADY asked, so the same blocking pattern is impossible to satisfy -# there and must not be used. - -# Best-effort read of the per-user TCC database. This can fail to see -# anything useful if the terminal running this script itself lacks Full Disk -# Access (macOS locks TCC.db down) — that failure mode is handled safely: -# `tcc_allowed` returns false, and the caller treats "unconfirmed" the same -# as "not granted" and blocks. It never trusts a read failure as a pass. -tcc_allowed() { - local service="$1" - if ! command -v sqlite3 >/dev/null 2>&1; then - return 1 - fi - local db="$HOME/Library/Application Support/com.apple.TCC/TCC.db" - local value - value="$(sqlite3 -readonly "$db" \ - "SELECT auth_value FROM access WHERE service='${service}' AND client='${HAMMERSPOON_BUNDLE_ID}' ORDER BY auth_value DESC LIMIT 1;" \ - 2>/dev/null || true)" - [[ "$value" == "2" ]] -} - -require_permission() { - local name="$1" service="$2" pane_url="$3" - log "Checking ${name} permission for Hammerspoon..." - if tcc_allowed "$service"; then - log "${name}: already granted." - return 0 - fi - warn "${name} is not confirmed granted to Hammerspoon." - warn "Opening System Settings -> Privacy & Security -> ${name}..." - open "$pane_url" - echo - read -rp "Toggle Hammerspoon ON for ${name}, then press Enter to continue: " _ - if tcc_allowed "$service"; then - log "${name}: confirmed granted." - else - warn "${name}: still not confirmed granted." - warn " If you definitely toggled it on, this may just be a detection limitation (this check needs Full" - warn " Disk Access for your terminal to read TCC.db reliably) rather than a real problem — the final" - warn " checks below will tell you for sure whether things actually work." - fi -} - -require_permission "Accessibility" "kTCCServiceAccessibility" \ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - -# --- 8b. Microphone permission --------------------------------------------------- -# -# THE BUG THIS REPLACES: this step used to open the Microphone pane and -# block on "Toggle Hammerspoon ON for Microphone, then press Enter" — which -# is impossible to satisfy. Unlike Accessibility, the Microphone pane has no -# "+" button; it only lists apps that have ALREADY REQUESTED access. Before -# Hammerspoon's config has ever tried to open the mic, it cannot appear in -# that list, so there is nothing there to toggle. A user following the old -# instructions correctly has no choice but to Ctrl-C out. -# -# The fix lives in client/init.lua: it now probes the microphone itself at -# config load (which already happened when Hammerspoon (re)started in step -# 7, above) — that's what actually raises the consent dialog, attributed to -# Hammerspoon, because rec runs as its child process. All this step can -# do is wait for init.lua to report the outcome, and it deliberately does -# NOT run its own rec probe to check: a shell-side probe would test THIS -# TERMINAL's microphone grant, a different permission that would produce a -# confidently wrong answer either way. See check_mic_permission() above for -# why reading MIC_STATUS_FILE is the only trustworthy option. - -MIC_STATUS_FILE="$CONFIG_DIR/.hark-mic-status" -MIC_STATUS_TIMEOUT_S=30 - -log "Waiting for Hammerspoon's microphone probe (up to ${MIC_STATUS_TIMEOUT_S}s)..." -echo "A Microphone permission dialog should appear on its own in a moment —" -echo "click Allow. This is triggered by init.lua actually trying to open the" -echo "mic; it's also the only way to make Hammerspoon show up in System" -echo "Settings -> Privacy & Security -> Microphone in the first place." -echo - -MIC_STATUS="" -for _ in $(seq 1 "$MIC_STATUS_TIMEOUT_S"); do - if [[ -f "$MIC_STATUS_FILE" ]]; then - MIC_STATUS="$(head -n 1 "$MIC_STATUS_FILE" 2>/dev/null || true)" - if [[ -n "$MIC_STATUS" ]]; then - break - fi - fi + printf '.' sleep 1 done +printf '\n' -case "$MIC_STATUS" in - ok) - log "Microphone: confirmed working — Hammerspoon's probe captured real audio." - ;; - denied) - warn "Microphone: Hammerspoon's probe got no audio (permission denied, or the dialog was dismissed/missed)." - warn "Fix: System Settings -> Privacy & Security -> Microphone -> turn ON Hammerspoon." - warn " (Hammerspoon WILL be listed there now — it has finally asked.)" - warn "Then re-run: ./install-client.sh --doctor" - ;; - *) - warn "Microphone: no result from Hammerspoon within ${MIC_STATUS_TIMEOUT_S}s (expected at ${MIC_STATUS_FILE})." - warn " Open the Hammerspoon console (menu bar icon -> Console) and check for errors." - warn " Then run: ./install-client.sh --doctor" - ;; -esac - -# --- 9. Final diagnostics ------------------------------------------------------- -# -# Same checks `--doctor` runs. This is the whole point of the fix: the installer -# must never again print "done" while the hotkey is actually dead. - -echo -log "Running final checks (same as ./install-client.sh --doctor)..." -if run_diagnostics; then - cat <<'EOF' - -============================================================================== -SETUP COMPLETE. - -HOTKEY: hold Ctrl + Alt + Space — ALL THREE KEYS TOGETHER, not the spacebar -alone. Release when you're done speaking. - -Test it for real: mosh into the server, put your cursor at a shell prompt, -hold Ctrl+Alt+Space, say a short sentence, release. Expected: the sentence -appears at the prompt within a couple of seconds — NOT executed. - -If anything ever stops working, run this first: ./install-client.sh --doctor -============================================================================== -EOF +printf '\n' +if run_doctor; then + printf '\n' + log "setup complete — hold Ctrl+Alt+Space and speak." else - echo - err "Setup wrote all the files, but the checks above found real problems —" - err "the hotkey will NOT work yet. Fix the FAILs above (each names its exact" - err "fix), then re-run: ./install-client.sh --doctor" + 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-client.sh --doctor after granting them." exit 1 fi diff --git a/tests/test_client_record.lua b/tests/test_client_record.lua deleted file mode 100644 index a8e6b41..0000000 --- a/tests/test_client_record.lua +++ /dev/null @@ -1,231 +0,0 @@ ---[[ -Covers the record lifecycle in client/init.lua. - -Run: lua tests/test_client_record.lua - -There is no Hammerspoon here - `hs` is stubbed, and the hotkey binding is the -seam: hs.hotkey.bind() hands us the real startRecording/stopRecording. - -What is worth pinning down: that a key press launches the recorder with the -arguments rec actually expects, that a release terminates it exactly once, -and that a failure leaves a line in the log. That last one is not decoration -- the log is the only persisted evidence of why a recording failed, and its -absence is what made the original ffmpeg bug take so long to pin down. -]] - -local INIT = (arg[0]:match("^(.*)/tests/[^/]+$") or ".") .. "/client/init.lua" - -local tasks, logged, deferrals, pressKey, releaseKey, missingPaths -local micStatus, alerts, probeTask - --- Installed for the WHOLE run, not just around the load. logLine() fires from --- the task callbacks the tests drive, long after loading - restoring io.open --- any earlier lets this test append its fake stderr to the developer's real --- ~/.hammerspoon/hark.log, which is the file you would go on to read when --- diagnosing a genuine failure. -local realDofile, realOpen = dofile, io.open -dofile = function(path) - if path:match("hark%-config") then - return { server = "http://127.0.0.1:1/dictate", key = "k" } - end - return realDofile(path) -end -io.open = function(path, mode) - if path:match("hark%.log") then - return { write = function(_, s) logged[#logged + 1] = s end, close = function() end } - end - -- Same reasoning as the log, and sharper: the probe tests below drive - -- finish(), which writes this file for real. Without this stub a test run - -- would overwrite the developer's own ~/.hammerspoon/.hark-mic-status - and - -- --doctor would then report a microphone verdict invented by a test. - if path:match("%.hark%-mic%-status$") then - return { write = function(_, s) micStatus[#micStatus + 1] = s end, close = function() end } - end - if path:match("%.wav$") then return nil end - return realOpen(path, mode) -end - -local function buildHs() - local function task(bin, callback, args) - local t = { bin = bin, callback = callback, args = args, terminated = 0 } - t.start = function() t.started = true; return t.startResult ~= false end - t.terminate = function() t.terminated = t.terminated + 1 end - tasks[#tasks + 1] = t - return t - end - - return { - alert = { - show = function(msg) alerts[#alerts + 1] = tostring(msg); return 1 end, - closeSpecific = function() end, - }, - -- missingPaths lets a test pretend the compiled recorder is absent. - fs = { - attributes = function(path) - if missingPaths[path] then return nil end - return { size = 0 } - end, - }, - json = { decode = function() return {} end }, - http = { asyncPost = function() end }, - pasteboard = { setContents = function() end }, - eventtap = { keyStroke = function() end }, - sound = { getByName = function() return { play = function() end } end }, - timer = { doAfter = function() deferrals = deferrals + 1 end }, - task = { new = task }, - accessibilityState = function() return true end, - hotkey = { - bind = function(_, _, pressed, released) pressKey, releaseKey = pressed, released end, - }, - } -end - -local function load() - tasks, logged, deferrals, micStatus, alerts = {}, {}, 0, {}, {} - hs = buildHs() - assert(loadfile(INIT))() - probeTask = tasks[1] -- the load-time microphone probe, kept for its own tests - tasks = {} -end - -local function recorder() return tasks[#tasks] end - --- The status file is written as a few separate :write() calls; --doctor reads --- it by line, so that is how the tests read it too. Line 1 status, 2 stamp, --- 3 detail. -local function micStatusLines() - local lines = {} - for line in table.concat(micStatus):gmatch("([^\n]*)\n") do - lines[#lines + 1] = line - end - return lines -end - -local failures = 0 -local function check(name, fn) - missingPaths = {} - local ok, err = pcall(fn) - if ok then - print("ok - " .. name) - else - failures = failures + 1 - print("FAIL - " .. name .. "\n " .. tostring(err)) - end -end - -check("a key press starts the recorder with just the output path", function() - load() - pressKey() - assert(#tasks == 1, "expected one recorder, got " .. #tasks) - assert(recorder().started, "recorder was never started") - local args = recorder().args - assert(#args == 1, "rec takes the wav path and nothing else, got " .. #args .. " args") - assert(args[1]:match("%.wav$"), "expected a wav path, got " .. tostring(args[1])) -end) - -check("releasing the key terminates the recorder exactly once", function() - load() - pressKey() - releaseKey() - assert(recorder().terminated == 1, "terminated " .. recorder().terminated .. " times, want 1") -end) - -check("a second press while recording does not start a second recorder", function() - load() - pressKey() - pressKey() - assert(#tasks == 1, "spurious double key-down started " .. #tasks .. " recorders") -end) - -check("releasing with nothing recording is harmless", function() - load() - releaseKey() -- no press first - assert(#tasks == 0, "release started something: " .. #tasks) -end) - -check("a clean exit logs nothing", function() - load() - pressKey() - releaseKey() - recorder().callback(0, "", "") - assert(#logged == 0, "clean exit wrote to the log: " .. table.concat(logged, " ")) -end) - -check("a non-zero exit logs the code and rec's stderr", function() - load() - pressKey() - recorder().callback(3, "", "rec: captured no audio - check Microphone") - local line = table.concat(logged, " ") - assert(line:match("3"), "exit code missing from log line: " .. line) - assert(line:match("captured no audio"), "rec's stderr missing from log line: " .. line) -end) - -check("a missing recorder binary starts nothing and does not crash", function() - load() - missingPaths[os.getenv("HOME") .. "/.hammerspoon/rec"] = true - pressKey() - assert(#tasks == 0, "started a recorder that is not installed") -end) - -check("the recording is sent immediately, not after a settling delay", function() - load() - pressKey() - releaseKey() - recorder().callback(0, "", "") - -- rec finalizes the WAV header before exit(0), so the file is already - -- complete when this callback runs. A timer here would be 150 ms of pure - -- latency on every single utterance. - assert(deferrals == 0, "sending was deferred through hs.timer " .. deferrals .. " time(s)") -end) - --- The microphone probe. rec reserves exit 3 for "TCC says no" and uses other --- codes for failures that are not about permission; this mapping is the whole --- basis of --doctor's microphone verdict, so it is worth pinning exactly. --- Getting it wrong is not cosmetic - it is issue #9, where every ungranted --- install got a confident PASS. - -check("the probe records ok when rec exits 0", function() - load() - probeTask.callback(0, "", "") - assert(micStatusLines()[1] == "ok", "wrote " .. tostring(micStatusLines()[1]) .. ", want ok") -end) - -check("exit 3 is recorded as a permission denial", function() - load() - probeTask.callback(3, "", "rec: microphone access denied - System Settings -> ...") - assert(micStatusLines()[1] == "denied", "wrote " .. tostring(micStatusLines()[1]) .. ", want denied") -end) - -check("a failure that is not about permission is not recorded as denied", function() - load() - probeTask.callback(6, "", "rec: captured 6400 frames of digital silence - the input device is muted") - local lines = micStatusLines() - assert(lines[1] == "error", - "a muted device was reported as '" .. tostring(lines[1]) .. "' - that sends the user to a toggle that is already on") - assert((lines[3] or ""):match("digital silence"), - "rec's reason never reached the status file, so --doctor cannot report it: " .. tostring(lines[3])) -end) - -check("a multi-line reason stays on one line in the status file", function() - load() - -- --doctor reads the detail positionally, with sed -n 3p. - probeTask.callback(5, "", "rec: no audio at all\nsecond line\nthird line") - local lines = micStatusLines() - assert(#lines == 3, "status file has " .. #lines .. " lines, want 3") - assert(lines[3]:match("second line"), "the reason was truncated: " .. lines[3]) -end) - -check("a rec failure names its cause in the alert, not just the log path", function() - load() - pressKey() - releaseKey() - -- io.open returns nil for the wav, so this takes sendRecording's - -- nothing-was-recorded path - the one the user actually sees. - recorder().callback(3, "", "rec: microphone access denied - turn Hammerspoon ON\n") - local shown = table.concat(alerts, " ") - assert(shown:match("microphone access denied"), - "the alert did not name the cause, which is how a denied mic reads as a bad transcription: " .. shown) - assert(not shown:match("rec: microphone"), "the 'rec: ' prefix leaked into the alert: " .. shown) -end) - -os.exit(failures == 0 and 0 or 1) diff --git a/tests/test_install_client.py b/tests/test_install_client.py new file mode 100644 index 0000000..fa37ff4 --- /dev/null +++ b/tests/test_install_client.py @@ -0,0 +1,473 @@ +"""Guard install-client.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-client.sh, which stops at +its source guard with every function defined and nothing installed. +""" + +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SCRIPT = REPO / "install-client.sh" +INFO_PLIST = REPO / "swift" / "Packaging" / "Info.plist" +AGENT_SWIFT = REPO / "swift" / "Sources" / "hark" / "AgentController.swift" +BUILD_SCRIPT = REPO / "swift" / "Packaging" / "build-app.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-client.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 +# --------------------------------------------------------------------------- + + + + + + + + + + +# --------------------------------------------------------------------------- +# 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://10.1.2.3: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://10.1.2.3:8911/dictate" + assert written["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://127.0.0.1:8911/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://127.0.0.1:8911/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://10.9.9.9:8911/dictate" "rtkey"\n' + "json_field server\necho\njson_field key", + {"HOME": str(tmp_path)}, + ) + assert rc == 0, out + assert "http://10.9.9.9: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://10.0.0.1:8911/dictate", "oldkey") + rc, out = run_sourced("legacy_field server\necho\nlegacy_field key", {"HOME": str(tmp_path)}) + assert rc == 0, out + assert "http://10.0.0.1: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://10.0.0.1: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://10.0.0.1:8911/dictate", "oldkey") + rc, _ = run_sourced( + 'write_client_config "http://10.0.0.2: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 TestHammerspoonIsGone: + """The Lua client is deleted, not merely unused. + + The point of issue #2 was never the 505 lines — it was that Accessibility + was granted to a general-purpose scriptable runtime whose config was a + symlink into this repo, so a `git pull` changed what that grant covered. + Leaving the files behind would leave that path installable. + """ + + def test_the_lua_client_is_deleted(self): + assert not (REPO / "client" / "init.lua").exists() + assert not (REPO / "client" / "hark-config.example.lua").exists() + assert not (REPO / "tests" / "test_client_record.lua").exists() + + def test_ci_no_longer_installs_lua(self): + ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() + assert "lua" not in ci.lower() + + def test_ci_shellchecks_the_scripts_that_exist(self): + ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() + assert "install-client.sh" in ci + assert "install-agent.sh" not in ci + + def test_the_installer_still_migrates_an_existing_lua_config(self): + # Deleting the client must not strand anyone mid-upgrade: the old + # config is still the only place their key lives. + assert "hark-config.lua" in SCRIPT.read_text() + + +class TestSshKeyFetch: + """Two-machine setups need the key from the other Mac. + + The old Hammerspoon installer did this and the agent installer did not, so + it had to come across before the old one could be deleted — otherwise a + laptop install regresses to "copy this file by hand". + """ + + def test_a_bare_argument_is_taken_as_the_ssh_host(self): + body = SCRIPT.read_text() + assert 'SERVER_HOST="$arg"' in body + + def test_the_url_is_not_derived_from_the_ssh_host(self, tmp_path): + # An alias that works for `ssh ` is not necessarily an address + # curl can reach. Guessing is how a config looks healthy while the + # client silently fails, so a mismatch is warned about, not "fixed". + body = SCRIPT.read_text() + fn = body[body.index("fetch_key_over_ssh() {") :] + fn = fn[: fn.index("\n}\n")] + assert "DEFAULT_SERVER" not in fn + + def test_non_interactive_does_not_block_on_a_prompt(self, tmp_path): + # A piped or CI install must fail with a message, not hang forever on + # a `read` nobody can answer. + rc, out = run_sourced( + "fetch_key_over_ssh 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_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 TestTransportPolicy: + """The installer must not write a config the agent will refuse. + + The agent enforces: plain HTTP to loopback always; to a numeric IP only + with an explicit allowPlaintext; to a hostname never. Writing a config that + fails those checks just moves the failure to first launch, where it reads + as "the agent is broken" rather than "your address is wrong". + """ + + def _write(self, home, server): + return run_sourced(f'write_client_config "{server}" "k"', {"HOME": str(home)}) + + def test_a_hostname_over_plain_http_is_refused(self, tmp_path): + # A MagicDNS name is a hostname. It resolves through something, and + # "the tailnet is trusted" stops being true when it resolves elsewhere. + rc, out = self._write(tmp_path, "http://dans-mac-studio:8911/dictate") + assert rc != 0 + assert "hostname" in out + assert not (tmp_path / ".config/hark/client.json").exists() + + def test_a_numeric_ip_is_allowed_and_records_the_choice(self, tmp_path): + import json + + rc, out = self._write(tmp_path, "http://100.64.66.46:8911/dictate") + assert rc == 0, out + written = json.loads((tmp_path / ".config/hark/client.json").read_text()) + assert written["allowPlaintext"] is True + assert "unencrypted" in out, "the plaintext choice should be stated, not silent" + + def test_loopback_needs_no_opt_in(self, tmp_path): + import json + + rc, out = self._write(tmp_path, "http://127.0.0.1:8911/dictate") + assert rc == 0, out + written = json.loads((tmp_path / ".config/hark/client.json").read_text()) + assert written["allowPlaintext"] is False + + def test_https_to_a_hostname_is_fine(self, tmp_path): + import json + + rc, out = self._write(tmp_path, "https://dans-mac-studio:8911/dictate") + assert rc == 0, out + written = json.loads((tmp_path / ".config/hark/client.json").read_text()) + assert written["allowPlaintext"] is False + + +class TestStatusDoctor: + """Every permission check reads the agent's own status.json. + + Measured from outside, both are wrong: a mic probe from this script tests + the terminal's grant, and TCC.db reports what was true for an earlier + build. Both produced confident false PASSes during bring-up. + """ + + def _status(self, home, **fields): + import json, time + + d = {"pid": 123, "written_epoch": int(time.time()), + "microphone": "authorized", "accessibility": "trusted", + "hotkey": "registered"} + d.update(fields) + p = home / ".config/hark" + p.mkdir(parents=True, exist_ok=True) + (p / "status.json").write_text(json.dumps(d)) + + def test_all_good_passes(self, tmp_path): + self._status(tmp_path) + for check in ("check_mic", "check_accessibility", "check_hotkey_bound"): + rc, out = run_sourced(check, {"HOME": str(tmp_path)}) + assert "PASS" in out, f"{check}: {out}" + + def test_denied_microphone_is_not_a_pass(self, tmp_path): + self._status(tmp_path, microphone="denied") + rc, out = run_sourced("check_mic", {"HOME": str(tmp_path)}) + assert "FAIL" in out and "PASS" not in out + + def test_untrusted_accessibility_is_not_a_pass(self, tmp_path): + self._status(tmp_path, accessibility="not_trusted") + rc, out = run_sourced("check_accessibility", {"HOME": str(tmp_path)}) + assert "FAIL" in out and "PASS" not in out + + def test_an_unbound_hotkey_is_not_a_pass(self, tmp_path): + # This field used to be a hardcoded "registered" literal, which is how + # a dead CGEventTap looked healthy from the outside. + self._status(tmp_path, hotkey="not_registered") + rc, out = run_sourced("check_hotkey_bound", {"HOME": str(tmp_path)}) + assert "FAIL" in out and "PASS" not in out + + def test_a_stale_status_is_not_trusted(self, tmp_path): + # The heartbeat rewrites every 30s, so a stale file means the agent + # died without saying so and every field is a claim about a dead pid. + self._status(tmp_path, written_epoch=1) + rc, out = run_sourced("check_status_freshness", {"HOME": str(tmp_path)}) + assert "FAIL" in out + assert "stale" in out + + def test_a_missing_status_is_not_a_pass(self, tmp_path): + rc, out = run_sourced("check_status_freshness", {"HOME": str(tmp_path)}) + assert "FAIL" in out and "PASS" not in out From f278dcc3bac87c3c0ce2e3b3183b0cb921383f86 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 17:12:32 -0400 Subject: [PATCH 2/4] [client] Stop overstating what allowPlaintext costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning said audio and transcripts "will cross the network unencrypted", which is wrong on the setup this is built for: Tailscale carries the traffic over WireGuard, so the bytes are already encrypted between the two machines. What allowPlaintext actually means is no TLS INSIDE an encrypted tunnel — the real cost is that the server is not authenticated to the client, not that anything is on the wire in clear. Overstating it is not harmless: it reads as "hark is insecure by default" for a setup that is not, and it obscures the actual gap. Also points at the fix rather than just naming the risk. A tailnet with HTTPS enabled can issue a real certificate for its MagicDNS name, and a hostname over https needs no opt-in at all. Solves: reported after the wording misled during the laptop install Tests: the numeric-IP case now asserts the warning does NOT claim "unencrypted" --- install-client.sh | 9 +++++++-- tests/test_install_client.py | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/install-client.sh b/install-client.sh index d298c59..b662869 100755 --- a/install-client.sh +++ b/install-client.sh @@ -135,8 +135,13 @@ write_client_config() { # A numeric IP on a tailnet is a defensible place for plaintext, but it # should be a stated decision rather than a silent default. plaintext="true" - warn "audio and transcripts will cross the network unencrypted to ${host}." - warn "On a tailnet that is defensible; recording it as allowPlaintext." + warn "no TLS to ${host} — recording it as allowPlaintext." + warn "On a tailnet this is narrower than it sounds: WireGuard already" + warn "encrypts the traffic between your machines, so this is 'no TLS" + warn "inside an encrypted tunnel', not 'in the clear on the wire'. It" + warn "still means the server is not authenticated to the client." + warn "For real TLS: tailscale cert , serve it, and point" + warn "\"server\" at https:// — no opt-in needed then." fi mkdir -p "$HARK_CONFIG_DIR" diff --git a/tests/test_install_client.py b/tests/test_install_client.py index fa37ff4..9c86d7a 100644 --- a/tests/test_install_client.py +++ b/tests/test_install_client.py @@ -399,7 +399,10 @@ def test_a_numeric_ip_is_allowed_and_records_the_choice(self, tmp_path): assert rc == 0, out written = json.loads((tmp_path / ".config/hark/client.json").read_text()) assert written["allowPlaintext"] is True - assert "unencrypted" in out, "the plaintext choice should be stated, not silent" + assert "allowPlaintext" in out, "the choice should be stated, not silent" + # The warning must not overclaim: on a tailnet WireGuard already + # encrypts the hop, so "in the clear on the wire" is wrong. + assert "unencrypted" not in out def test_loopback_needs_no_opt_in(self, tmp_path): import json From 8cb38d440d04473326061e52154e52780ecce036 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 17:16:32 -0400 Subject: [PATCH 3/4] [agent] Let ATS reach a tailnet peer, and gate transport ourselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-machine setup could not connect at all: Cannot start load of Task ... since it does not conform to ATS policy finished with error [-1022] ... requires the use of a secure connection NSAllowsLocalNetworking covers .local names and link-local addresses. A Tailscale peer is neither - the tailnet uses CGNAT space, 100.64.0.0/10. The same build worked on the machine running the server, because there that address belongs to the local host and CFNetwork treats it as local. So this was invisible to a single-machine install, invisible to CI, and invisible to every test: it needs two machines and a real tunnel to appear at all. curl reached the server fine throughout, which is what made it look like an agent bug rather than a policy one. ATS's supported exceptions are static domain lists, and the server address here is user configuration - there is nothing to enumerate at build time. So the app declares arbitrary loads and enforces its own policy instead, which is stricter than ATS where it matters: ClientConfig.validateTransport allows plain HTTP to loopback always, to a numeric IP only with an opt-in recorded in the config, and to a HOSTNAME never - where ATS would happily allow a hostname it considered local. The comment says when the exception can be removed rather than leaving it to be discovered: once the server speaks TLS. A tailnet with HTTPS enabled issues a real certificate for its MagicDNS name, and an https hostname needs no exception at all. Solves: hark #2 — laptop could not reach the Studio Tests: 122 pass; the ATS key, its recorded rationale, and the app-side policy that justifies it are all pinned --- swift/Packaging/Info.plist | 31 +++++++++++++++++++++++++++++++ tests/test_install_client.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/swift/Packaging/Info.plist b/swift/Packaging/Info.plist index ebf276b..06afd7d 100644 --- a/swift/Packaging/Info.plist +++ b/swift/Packaging/Info.plist @@ -22,8 +22,39 @@ Hark records your voice while you hold the dictate hotkey, to transcribe it. NSLocalNetworkUsageDescription Hark talks to the transcription server on your local network. + NSAppTransportSecurity + NSAllowsArbitraryLoads + NSAllowsLocalNetworking diff --git a/tests/test_install_client.py b/tests/test_install_client.py index 9c86d7a..dd6ddf2 100644 --- a/tests/test_install_client.py +++ b/tests/test_install_client.py @@ -474,3 +474,39 @@ def test_a_stale_status_is_not_trusted(self, tmp_path): def test_a_missing_status_is_not_a_pass(self, tmp_path): rc, out = run_sourced("check_status_freshness", {"HOME": str(tmp_path)}) assert "FAIL" in out and "PASS" not in out + + +class TestAppTransportSecurity: + """ATS blocked the two-machine setup, and only the two-machine setup. + + NSAllowsLocalNetworking covers .local names and link-local addresses. A + Tailscale peer is neither — the tailnet uses CGNAT space (100.64.0.0/10) — + so a remote peer failed with NSURLErrorDomain -1022 while the identical + build worked on the machine running the server, where that address belongs + to the local host and CFNetwork treats it as local. Invisible to a + single-machine install and to every test that does not cross machines. + """ + + INFO = REPO / "swift" / "Packaging" / "Info.plist" + + def test_arbitrary_loads_is_declared(self): + import plistlib + + ats = plistlib.loads(self.INFO.read_bytes())["NSAppTransportSecurity"] + assert ats.get("NSAllowsArbitraryLoads") is True + + def test_the_reason_is_recorded_next_to_it(self): + # This key looks like a shortcut and will be "cleaned up" by someone + # unless the comment explains that the app enforces a stricter policy + # of its own, and says when it can go. + text = self.INFO.read_text() + assert "-1022" in text + assert "validateTransport" in text + assert "tailscale cert" in text + + def test_the_app_still_gates_transport_itself(self): + # The exception is only defensible because ClientConfig refuses what + # ATS would have allowed — notably a hostname over plain HTTP. + cc = (REPO / "swift" / "Sources" / "HarkCore" / "ClientConfig.swift").read_text() + assert "validateTransport" in cc + assert "allowPlaintext" in cc From c786fb982561caba7b57b7761816c0775f76ca6d Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 17:18:32 -0400 Subject: [PATCH 4/4] =?UTF-8?q?[agent]=20Drop=20NSAllowsLocalNetworking=20?= =?UTF-8?q?=E2=80=94=20it=20was=20suppressing=20the=20ATS=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added NSAllowsArbitraryLoads and the two-machine setup still failed with -1022, with the installed bundle demonstrably carrying the key. The two keys are not additive. Apple: if NSAllowsLocalNetworking is present, the system IGNORES NSAllowsArbitraryLoads on macOS 10.12+ — the more specific key wins and the general one is silently discarded. So the bundle advertised an exception that was inert, and the plist looked correct while every request failed. Keeping only NSAllowsArbitraryLoads. The comment says not to add the other back, since it reads like a harmless narrowing and is anything but. NSLocalNetworkUsageDescription stays — that is the macOS 15+ local network privacy prompt, unrelated to ATS. Solves: hark #2 — laptop still could not reach the Studio after the first ATS fix Tests: 123 pass; a test now pins that the suppressing key is absent, not merely that the exception is present --- swift/Packaging/Info.plist | 9 +++++++-- tests/test_install_client.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/swift/Packaging/Info.plist b/swift/Packaging/Info.plist index 06afd7d..2bd763f 100644 --- a/swift/Packaging/Info.plist +++ b/swift/Packaging/Info.plist @@ -53,10 +53,15 @@ and an https:// hostname needs no exception at all. --> NSAppTransportSecurity + NSAllowsArbitraryLoads - NSAllowsLocalNetworking - diff --git a/tests/test_install_client.py b/tests/test_install_client.py index dd6ddf2..d9dd777 100644 --- a/tests/test_install_client.py +++ b/tests/test_install_client.py @@ -495,6 +495,20 @@ def test_arbitrary_loads_is_declared(self): ats = plistlib.loads(self.INFO.read_bytes())["NSAppTransportSecurity"] assert ats.get("NSAllowsArbitraryLoads") is True + def test_local_networking_is_not_also_declared(self): + """The two keys are not additive — the specific one silently wins. + + Apple: if NSAllowsLocalNetworking is present, the system IGNORES + NSAllowsArbitraryLoads on macOS 10.12+. A bundle can therefore carry + NSAllowsArbitraryLoads=true and still fail every request with -1022, + which is exactly what happened here: the exception was inert and the + installed plist looked correct. + """ + import plistlib + + ats = plistlib.loads(self.INFO.read_bytes())["NSAppTransportSecurity"] + assert "NSAllowsLocalNetworking" not in ats + def test_the_reason_is_recorded_next_to_it(self): # This key looks like a shortcut and will be "cleaned up" by someone # unless the comment explains that the app enforces a stricter policy