From 32aa756b9bab28f4c6c9dbd9fa99a22600171acf Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 16:10:51 -0400 Subject: [PATCH] [client] Delete the Hammerspoon client Now that both machines run the native agent, the Lua client goes. The point was never the 505 lines: Accessibility - permission to observe every keystroke - was granted to a general-purpose scriptable Lua runtime whose config was a symlink into this repo, so a `git pull` changed what that grant covered without re-prompting. Leaving the files behind would leave that path installable. Removed: client/init.lua, client/hark-config.example.lua, tests/test_client_record.lua, the Lua toolchain and suite from CI, and the hammerspoon cask from the install path. install-agent.sh becomes install-client.sh, so the documented entry point is unchanged for anyone following the README. Two things the old installer did had to come across first, or a laptop install would have regressed to "copy this file by hand": - fetching the shared secret from the server over SSH, for a two-machine setup, with the host taken as a bare argument - the /health doctor check. Everything local can be healthy while the server is simply unreachable, and from the user's chair that is indistinguishable from a microphone fault. The SSH host is still deliberately not derived from the server URL, nor the URL from the host: an alias that works for `ssh ` is not necessarily an address curl can reach, and guessing is how a config ends up looking healthy while the client silently fails. A mismatch is warned about rather than "fixed". Migration is kept, not dropped. ~/.hammerspoon/hark-config.lua is still read into ~/.config/hark/client.json when the latter is absent, since for anyone mid-upgrade that file is the only place their key lives. The installer does not uninstall Hammerspoon or revoke its grants - the README says to do both by hand, because revoking them is the actual point and quitting the app does not do it. rec's permission message now names hark. It named neither while both clients shipped, because the row to switch on is the RESPONSIBLE process and that depended on which client you were running. Verified: Studio and laptop both dictating on the agent, laptop over a two-machine setup against dans-mac-studio. Solves: GitHub issue #2, part 2 of 2 Tests: 138 pass; new coverage for the deletion, the SSH fetch, non-interactive fallback, and the /health check --- .github/workflows/ci.yml | 17 +- README.md | 252 ++-- client/hark-config.example.lua | 36 - client/init.lua | 543 -------- client/rec.swift | 21 +- install-agent.sh | 533 -------- install-client.sh | 1198 +++++++---------- install-server.sh | 2 +- tests/test_client_record.lua | 231 ---- ...nstall_agent.py => test_install_client.py} | 112 +- 10 files changed, 767 insertions(+), 2178 deletions(-) delete mode 100644 client/hark-config.example.lua delete mode 100644 client/init.lua delete mode 100755 install-agent.sh delete mode 100644 tests/test_client_record.lua rename tests/{test_install_agent.py => test_install_client.py} (82%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bb1d7b..f68924e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,20 +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 steps: @@ -60,8 +46,7 @@ jobs: # Preinstalled on the Ubuntu runner image. - name: shellcheck run: | - shellcheck install-server.sh install-client.sh install-agent.sh \ - client/agent/build-agent.sh + shellcheck install-server.sh install-client.sh client/agent/build-agent.sh # The agent is macOS-only and needs a real Swift toolchain, so this cannot # join the portable matrix above. It builds the bundle and asserts the diff --git a/README.md b/README.md index 5fb171b..c40e770 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,60 +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. compiles `client/agent/` and `client/rec.swift` into `~/Applications/hark.app` + with `swiftc`, 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 `com.drycodeworks.hark-agent` as a LaunchAgent, 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. -#### Native agent (preview, opt-in) +#### Upgrading from the Hammerspoon client -There is a second client — a native Swift agent that does the same job without -Hammerspoon. It is not the default yet, and installing it changes nothing about -the Hammerspoon path: +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). -```bash -./install-agent.sh # build, install to ~/Applications, load at login -./install-agent.sh --doctor # read-only diagnosis -./install-agent.sh --uninstall -``` +`./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). -Why it exists: Accessibility is currently granted to Hammerspoon — a -general-purpose scriptable Lua runtime — and its config is a symlink into this -repo, so `git pull` changes what that grant covers without re-prompting. A -single-purpose bundle asks for the same permission with far less behind it. -See [issue #2](https://github.com/DRYCodeWorks/hark/issues/2). +Afterwards, clean up by hand — the installer deliberately does not: -**The two clients cannot both hold the hotkey.** `Ctrl+Alt+Space` is a -system-wide registration and exactly one process gets it; whichever starts -first wins and the other reports that it could not register. `install-agent.sh` -quits Hammerspoon for you unless you pass `--keep-hammerspoon`. +```bash +brew uninstall --cask hammerspoon +rm ~/.hammerspoon/init.lua +``` -Migration is non-destructive in both directions. `~/.hammerspoon/hark-config.lua` -is read into `~/.config/hark/client.json` and never modified, so rolling back is -just `./install-agent.sh --uninstall` and relaunching Hammerspoon. +**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 Microphone and Accessibility again — TCC keys grants to -a code identity, and the agent is a different one from Hammerspoon. Until a -Developer ID certificate is in place the bundle is ad-hoc signed, which means -those grants survive until the binary changes and you re-grant after a rebuild. +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 @@ -204,53 +206,61 @@ 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. +**Both permission checks read what the agent itself reported**, to +`~/.config/hark/agent-mic-status` and `~/.config/hark/agent-accessibility-status`. +Neither 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, @@ -271,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 @@ -281,22 +291,25 @@ trusted one. ## Changing the hotkey -Edit the last real line of `client/init.lua`: +Edit `registerHotKey()` in `client/agent/hark-agent.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`. -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 @@ -304,19 +317,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`. @@ -331,19 +345,19 @@ 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. +**`~/Library/Logs/hark-agent.log` is the load-bearing diagnostic.** The agent +appends `rec`'s exit code and stderr there on every non-zero exit, plus the +byte count of each request and the *length* — never the content — of each +transcript. 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 @@ -415,20 +429,18 @@ transient. ``` install-server.sh transcription side: deps, model, plists, services install-client.sh hotkey/mic/paste side, plus --doctor -install-agent.sh native agent install/doctor/uninstall (preview) client/ - init.lua Hammerspoon client - rec.swift AVAudioEngine recorder, built at install time - hark-config.example.lua shape of ~/.hammerspoon/hark-config.lua + rec.swift AVAudioEngine recorder, built into the bundle agent/ - hark-agent.swift native client — hotkey, capture, POST, paste + hark-agent.swift the client — hotkey, capture, POST, paste Info.plist bundle identity + microphone usage string + hark-agent.entitlements audio-input, required by the hardened runtime build-agent.sh assembles and signs hark.app config.example.toml shape of ~/.config/hark/config.toml src/hark/ the HTTP service launchd/ plist templates, rendered by hark.plists -tests/ pytest suite + test_client_record.lua (8) -.github/workflows/ci.yml both suites + shellcheck + the agent build +tests/ pytest suite +.github/workflows/ci.yml suite + shellcheck + the signed bundle build docs/ design spec + implementation plan ``` @@ -436,9 +448,7 @@ 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 install-agent.sh \ - client/agent/build-agent.sh +shellcheck install-server.sh install-client.sh client/agent/build-agent.sh ./client/agent/build-agent.sh # macOS only ``` 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 index a5e4f62..8045fac 100644 --- a/client/rec.swift +++ b/client/rec.swift @@ -2,14 +2,14 @@ // // usage: rec [max-seconds] // -// Runs until SIGTERM (the dictate client sends it on key-up), or until +// Runs until SIGTERM (hark-agent 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 +// 3 TCC has not granted microphone access - hark-agent 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 @@ -59,20 +59,21 @@ let maxSeconds = args.count >= 3 ? Double(args[2]) : nil // So a frame count can never see a denial - and neither can the sample-rate // guard below, because format negotiation succeeds under denial too. The only // way to learn the answer is to ask TCC for it. -// Deliberately does NOT name the app to enable. rec is spawned by both -// clients, and TCC attributes the grant to whichever is RESPONSIBLE - so the -// row to switch on says "Hammerspoon" under the Lua client and "hark" under -// the native agent. Naming one sent users to look for a row that was never -// going to be there. + +// Names hark, because hark is now the only thing that spawns rec. TCC +// attributes the grant to the RESPONSIBLE process rather than to rec itself, +// so this is the row that actually exists in the pane. While the Hammerspoon +// client still shipped, that row was named after Hammerspoon and this string +// deliberately named neither. let permissionHelp = "System Settings -> Privacy & Security -> Microphone " - + "-> turn on the app that launched this (hark, or Hammerspoon)" + + "-> turn hark 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 + // Nobody has asked yet, and hark 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. // diff --git a/install-agent.sh b/install-agent.sh deleted file mode 100755 index 3fe9b79..0000000 --- a/install-agent.sh +++ /dev/null @@ -1,533 +0,0 @@ -#!/usr/bin/env bash -# -# hark — native agent setup. -# -# Installs the Swift agent (client/agent/) as ~/Applications/hark.app and -# registers it as a LaunchAgent so it starts at login. This is the eventual -# replacement for install-client.sh's Hammerspoon path. -# -# ./install-agent.sh install or update the agent -# ./install-agent.sh --doctor read-only diagnosis, changes nothing -# ./install-agent.sh --uninstall remove the agent and its LaunchAgent -# -# WHY THIS IS A SEPARATE SCRIPT -# -# The agent and the Hammerspoon client are designed to coexist while you -# migrate, so nothing here touches ~/.hammerspoon or install-client.sh. When -# the Lua client is deleted this script folds back into install-client.sh; see -# GitHub issue #2. -# -# THEY CANNOT BOTH HOLD THE HOTKEY. -# -# Ctrl+Alt+Space is a system-wide registration and exactly one process gets -# it. Whichever of the two starts first wins, and the loser reports that it -# could not register. Coexist means "both installed, one running" - not "both -# listening". This script quits Hammerspoon for you unless --keep-hammerspoon -# is given. -# -# CONFIG MIGRATION -# -# ~/.hammerspoon/hark-config.lua (Lua) becomes ~/.config/hark/client.json. -# The old file is read but never modified, so rolling back to the Hammerspoon -# client is just quitting the agent and relaunching Hammerspoon. - -set -euo pipefail - -REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BUILD_DIR="$REPO_DIR/build" -APP_SRC="$BUILD_DIR/hark.app" -APP_DIR="$HOME/Applications" -APP_DST="$APP_DIR/hark.app" - -HARK_CONFIG_DIR="$HOME/.config/hark" -CLIENT_CONFIG="$HARK_CONFIG_DIR/client.json" -MIC_STATUS="$HARK_CONFIG_DIR/agent-mic-status" -ACCESSIBILITY_STATUS="$HARK_CONFIG_DIR/agent-accessibility-status" -# The code identity the last install was granted against. See -# reset_stale_grants_on_identity_change(). -INSTALLED_CDHASH="$HARK_CONFIG_DIR/.agent-cdhash" -SERVER_KEY="$HARK_CONFIG_DIR/key" - -LEGACY_CONFIG="$HOME/.hammerspoon/hark-config.lua" - -LAUNCH_AGENTS="$HOME/Library/LaunchAgents" -AGENT_LABEL="com.drycodeworks.hark-agent" -AGENT_PLIST="$LAUNCH_AGENTS/$AGENT_LABEL.plist" - -DEFAULT_SERVER="http://127.0.0.1:8911/dictate" - -log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } -warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } -err() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; } - -doctor_failures=0 -doctor_pass() { printf ' \033[1;32mPASS\033[0m %s\n' "$1"; } -doctor_fail() { - printf ' \033[1;31mFAIL\033[0m %s\n' "$1" - printf ' \033[1;33mfix:\033[0m %s\n' "$2" - doctor_failures=$((doctor_failures + 1)) -} - -# ============================================================================== -# Config -# ============================================================================== - -# Extracts a quoted field from the legacy Lua config, e.g. for a line -# ` server = "http://...",` prints `http://...`. -legacy_field() { - local field="$1" - [[ -f "$LEGACY_CONFIG" ]] || return 1 - local value - value="$(grep -E "^[[:space:]]*${field}[[:space:]]*=" "$LEGACY_CONFIG" 2>/dev/null \ - | sed -E 's/^[^"]*"([^"]*)".*/\1/' || true)" - [[ -n "$value" ]] || return 1 - printf '%s' "$value" -} - -# Reads a string field out of client.json without needing jq. Deliberately -# narrow: these two fields are written by this script, so the shape is known. -json_field() { - local field="$1" - [[ -f "$CLIENT_CONFIG" ]] || return 1 - local value - value="$(grep -E "\"${field}\"[[:space:]]*:" "$CLIENT_CONFIG" 2>/dev/null \ - | sed -E 's/.*"'"${field}"'"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' || true)" - [[ -n "$value" ]] || return 1 - printf '%s' "$value" -} - -# Escapes the two characters that can appear in a key or URL and would break -# the JSON we emit. Keys are base64-ish and URLs are plain, so this is a -# guard rather than a general-purpose escaper. -json_escape() { - printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -} - -write_client_config() { - local server="$1" key="$2" - mkdir -p "$HARK_CONFIG_DIR" - # Written 600 BEFORE the secret goes in, so there is no window where the - # key exists in a world-readable file. - : > "$CLIENT_CONFIG" - chmod 600 "$CLIENT_CONFIG" - cat > "$CLIENT_CONFIG" < cat ~/.config/hark/key\n' - exit 1 - fi - - write_client_config "$server" "$key" - log "wrote $CLIENT_CONFIG (600) — server: $server" -} - -# ============================================================================== -# Stale TCC grants -# ============================================================================== -# -# An ad-hoc signature's designated requirement is a bare content hash: -# -# designated => cdhash H"6836bec46e8c7d394cf1ba94421ff18a31674867" -# -# so every rebuild is a new code identity and the Accessibility grant stops -# applying. What macOS does NOT do is tidy up: the old row survives with -# auth_value=2 and System Settings keeps drawing a switched-ON toggle for a -# binary nothing trusts. Observed twice on 2026-08-03, and it is genuinely -# misleading - you go to grant the permission, find it already granted, and -# conclude the problem is somewhere else. -# -# Toggling it off and on by hand works. So does this, without the detour. -# -# Only Accessibility is reset, for two reasons. It is the grant observed to -# break on rebuild, and the microphone path already tells the truth on its own: -# the agent's probe actually runs rec and reports what happened, so a stale -# microphone row cannot produce a false PASS the way a stale Accessibility row -# did. Resetting it anyway would cost a consent dialog for nothing. -# -# A Developer ID signature makes this whole function dead code, because the -# requirement becomes the certificate rather than the hash. -current_cdhash() { - codesign -dvvv "$APP_DST" 2>&1 | sed -n 's/^CDHash=//p' | head -1 -} - -reset_stale_grants_on_identity_change() { - local new_hash old_hash="" - new_hash="$(current_cdhash)" - [[ -n "$new_hash" ]] || return 0 - [[ -f "$INSTALLED_CDHASH" ]] && old_hash="$(cat "$INSTALLED_CDHASH")" - - mkdir -p "$HARK_CONFIG_DIR" - printf '%s' "$new_hash" > "$INSTALLED_CDHASH" - - # First install, or the same binary reinstalled: nothing to invalidate. - [[ -n "$old_hash" && "$old_hash" != "$new_hash" ]] || return 0 - - warn "the agent binary changed (${old_hash:0:12}… -> ${new_hash:0:12}…)." - warn "Ad-hoc signing ties TCC grants to that hash, so the Accessibility grant" - warn "no longer applies — and macOS would still show its toggle switched ON." - if tccutil reset Accessibility "$AGENT_LABEL" >/dev/null 2>&1; then - warn "Cleared the stale entry. You will be asked to grant it again." - else - warn "Could not clear it automatically. Toggle hark OFF and back ON in" - warn "System Settings -> Privacy & Security -> Accessibility." - fi -} - -# ============================================================================== -# LaunchAgent -# ============================================================================== -# -# RunAtLoad only, no KeepAlive. A crashed agent should stay down and be -# noticed, not be silently resurrected into a crash loop that looks like -# "the hotkey is flaky". -# -# ProgramArguments points INSIDE the bundle. That is deliberate and is what -# keeps TCC attributing the microphone and Accessibility grants to -# com.drycodeworks.hark-agent: the executable is covered by the bundle's code -# signature, so its identity resolves to the bundle regardless of who exec'd -# it. `open -a` would work too but gives launchd nothing to supervise. - -write_plist() { - mkdir -p "$LAUNCH_AGENTS" - cat > "$AGENT_PLIST" < - - - - Label - ${AGENT_LABEL} - ProgramArguments - - ${APP_DST}/Contents/MacOS/hark-agent - - RunAtLoad - - ProcessType - Interactive - StandardOutPath - /tmp/hark-agent.out - StandardErrorPath - /tmp/hark-agent.err - - -EOF - log "wrote $AGENT_PLIST" -} - -agent_loaded() { - # Captured into a variable first, NOT piped. `launchctl list | grep -q X` - # under `set -o pipefail` reports every service as not-loaded: grep exits at - # the first match, launchctl takes SIGPIPE, and pipefail propagates it. - local listing - listing="$(launchctl list 2>/dev/null || true)" - printf '%s' "$listing" | grep -q "$AGENT_LABEL" -} - -reload_agent() { - local domain - domain="gui/$(id -u)" - if agent_loaded; then - launchctl bootout "$domain/$AGENT_LABEL" 2>/dev/null || true - # `Bootstrap failed: 5: Input/output error` right after a bootout is - # usually the old instance still tearing down, not a bad plist. - sleep 1 - fi - if ! launchctl bootstrap "$domain" "$AGENT_PLIST" 2>/dev/null; then - sleep 2 - launchctl bootstrap "$domain" "$AGENT_PLIST" 2>/dev/null || { - err "launchctl bootstrap failed for $AGENT_LABEL" - err "try: launchctl bootout $domain/$AGENT_LABEL && launchctl bootstrap $domain $AGENT_PLIST" - return 1 - } - fi - log "loaded $AGENT_LABEL" -} - -# ============================================================================== -# Doctor -# ============================================================================== - -check_app_installed() { - if [[ ! -d "$APP_DST" ]]; then - doctor_fail "hark.app is installed" "run ./install-agent.sh" - return 1 - fi - doctor_pass "hark.app is installed at $APP_DST" -} - -check_signature() { - if [[ ! -d "$APP_DST" ]]; then - doctor_fail "hark.app has a valid signature" "run ./install-agent.sh" - return 1 - fi - if ! codesign --verify --strict "$APP_DST" 2>/dev/null; then - doctor_fail "hark.app has a valid signature" \ - "rebuild it: ./client/agent/build-agent.sh && ./install-agent.sh" - return 1 - fi - local identity - identity="$(codesign -dvv "$APP_DST" 2>&1 | grep -E '^Signature=' | cut -d= -f2- || true)" - doctor_pass "hark.app signature is valid (${identity:-unknown})" -} - -check_config() { - if [[ ! -f "$CLIENT_CONFIG" ]]; then - doctor_fail "$CLIENT_CONFIG exists" "run ./install-agent.sh" - return 1 - fi - local perms - perms="$(stat -f '%OLp' "$CLIENT_CONFIG")" - if [[ "$perms" != "600" ]]; then - doctor_fail "$CLIENT_CONFIG is 600 (it holds a secret)" "chmod 600 $CLIENT_CONFIG" - return 1 - fi - if ! json_field key >/dev/null; then - doctor_fail "$CLIENT_CONFIG has a key" "run ./install-agent.sh" - return 1 - fi - doctor_pass "$CLIENT_CONFIG is present, 600, and has a key" -} - -check_agent_running() { - if ! agent_loaded; then - doctor_fail "the agent is loaded in launchd" "run ./install-agent.sh" - return 1 - fi - if ! pgrep -f "$APP_DST/Contents/MacOS/hark-agent" >/dev/null 2>&1; then - doctor_fail "the agent process is running" \ - "check /tmp/hark-agent.err and ~/Library/Logs/hark-agent.log" - return 1 - fi - doctor_pass "the agent is loaded and running" -} - -# Reads the outcome the AGENT's own startup probe wrote. This is the only -# reliable way to learn whether the agent can reach the microphone: TCC -# attributes a request to the responsible process, and rec runs as the -# agent's child — so running rec from THIS shell would test the terminal's -# grant, a different permission that produces a confidently wrong PASS. -# Never run rec from here to "test" this; read what the agent wrote. -check_mic() { - if [[ ! -f "$MIC_STATUS" ]]; then - doctor_fail "the agent can reach the microphone" \ - "the agent hasn't probed yet — is it running? (./install-agent.sh)" - return 1 - fi - local status detail - status="$(sed -n '1p' "$MIC_STATUS")" - detail="$(sed -n '3p' "$MIC_STATUS" || true)" - case "$status" in - ok) - doctor_pass "the agent can reach the microphone" - ;; - denied) - doctor_fail "the agent can reach the microphone" \ - "System Settings -> Privacy & Security -> Microphone -> turn ON hark" - ;; - *) - doctor_fail "the agent can reach the microphone (probe said: $status)" \ - "${detail:-see ~/Library/Logs/hark-agent.log}" - ;; - esac -} - -# Reads what the agent's own AXIsProcessTrusted() call recorded — NOT TCC.db. -# -# This check used to query TCC.db directly and it produced a confident FALSE -# PASS on 2026-08-03: it reported "Accessibility is granted" while the agent -# was simultaneously alerting on screen that it could not paste. The row in -# TCC.db outlives the grant it describes. An ad-hoc signature's designated -# requirement is a bare `cdhash`, so every rebuild is a new identity — the old -# row survives with auth_value=2, System Settings keeps drawing a switched-ON -# toggle, and the running binary is trusted by nobody. -# -# So the same rule as the microphone applies for the same underlying reason: -# only the process can answer for the process. Reading TCC.db also required -# Full Disk Access, which this script does not necessarily have. -check_accessibility() { - local status_file="$ACCESSIBILITY_STATUS" - if [[ ! -f "$status_file" ]]; then - # An agent older than this check, or one that has not started yet. Not a - # failure, and deliberately not a PASS either. - printf ' \033[1;33mSKIP\033[0m Accessibility (the agent has not reported yet)\n' - printf ' if dictation records but nothing pastes, that is this permission:\n' - printf ' System Settings -> Privacy & Security -> Accessibility -> hark\n' - return 0 - fi - if [[ "$(sed -n '1p' "$status_file")" == "ok" ]]; then - doctor_pass "Accessibility is granted (agent reported at $(sed -n '2p' "$status_file"))" - else - doctor_fail "Accessibility is granted to hark" \ - "System Settings -> Privacy & Security -> Accessibility -> turn ON hark, then restart the agent" - fi -} - -check_hotkey_conflict() { - if pgrep -x Hammerspoon >/dev/null 2>&1; then - doctor_fail "nothing else holds Ctrl+Alt+Space" \ - "Hammerspoon is running and owns the hotkey — quit it (osascript -e 'quit app \"Hammerspoon\"')" - return 1 - fi - doctor_pass "nothing else is holding Ctrl+Alt+Space" -} - -run_doctor() { - printf '\nhark agent diagnostics\n\n' - check_app_installed || true - check_signature || true - check_config || true - check_agent_running || true - check_hotkey_conflict || true - check_mic || true - check_accessibility || true - printf '\n' - if [[ "$doctor_failures" -gt 0 ]]; then - err "$doctor_failures check(s) failed" - return 1 - fi - log "all checks passed" -} - -# ============================================================================== -# Uninstall -# ============================================================================== - -run_uninstall() { - if agent_loaded; then - launchctl bootout "gui/$(id -u)/$AGENT_LABEL" 2>/dev/null || true - log "unloaded $AGENT_LABEL" - fi - rm -f "$AGENT_PLIST" - rm -rf "$APP_DST" - log "removed $APP_DST and $AGENT_PLIST" - # client.json is deliberately left in place: it holds the shared secret and - # is what a reinstall (or the Hammerspoon client) would want back. - log "left $CLIENT_CONFIG alone — delete it by hand if you meant to." -} - -# ============================================================================== -# Main -# ============================================================================== - -# Sourcing this file defines the helpers and check_* functions and stops here, -# so the test suite can exercise them without running an install. Everything -# below this line only runs when the script is executed directly. -if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then - return 0 -fi - -KEEP_HAMMERSPOON=0 -MODE="install" -for arg in "$@"; do - case "$arg" in - --doctor) MODE="doctor" ;; - --uninstall) MODE="uninstall" ;; - --keep-hammerspoon) KEEP_HAMMERSPOON=1 ;; - -h|--help) - sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' - exit 0 - ;; - *) - err "unknown argument: $arg (try --help)" - exit 2 - ;; - esac -done - -case "$MODE" in - doctor) run_doctor; exit $? ;; - uninstall) run_uninstall; exit 0 ;; -esac - -log "building the agent" -"$REPO_DIR/client/agent/build-agent.sh" "$BUILD_DIR" - -log "installing to $APP_DST" -mkdir -p "$APP_DIR" -# Replaced wholesale rather than copied over: a stale file left inside the -# bundle invalidates the signature, and the failure surfaces much later as an -# unexplained TCC re-prompt. -rm -rf "$APP_DST" -cp -R "$APP_SRC" "$APP_DST" - -# Must run AFTER the copy (it hashes the installed bundle) and BEFORE the agent -# restarts, so the agent's prompt lands on a cleared entry rather than a stale -# one that claims to be granted already. -reset_stale_grants_on_identity_change - -resolve_config - -if [[ "$KEEP_HAMMERSPOON" -eq 0 ]] && pgrep -x Hammerspoon >/dev/null 2>&1; then - warn "Hammerspoon is running and owns Ctrl+Alt+Space — quitting it so the agent can register." - warn "Pass --keep-hammerspoon to leave it alone (the agent will then fail to bind the hotkey)." - osascript -e 'quit app "Hammerspoon"' 2>/dev/null || true - sleep 1 -fi - -# Cleared BEFORE the agent restarts, so the wait below observes THIS run's -# probe rather than instantly succeeding on the previous run's file. -rm -f "$MIC_STATUS" - -write_plist -reload_agent - -# Wait for the agent's microphone probe to report, rather than sleeping a -# fixed interval. The probe cannot finish until the user has answered the -# consent dialog, so any fixed wait either races a human or pads every -# already-granted re-run. A 3s sleep here reported a spurious FAIL on the -# first install, with the prompt still on screen. -printf '==> waiting for the microphone probe (answer the prompt if one appears)' -probe_started_at="$(date +%s)" -while [[ ! -f "$MIC_STATUS" ]]; do - if [[ $(($(date +%s) - probe_started_at)) -ge 45 ]]; then - printf '\n' - warn "the probe did not report within 45s — the doctor below may be stale" - break - fi - printf '.' - sleep 1 -done -printf '\n' - -printf '\n' -if run_doctor; then - printf '\n' - log "setup complete — hold Ctrl+Alt+Space and speak." -else - printf '\n' - warn "setup finished with failing checks — see the fixes above." - warn "Both permission prompts only appear once the agent asks, so re-run" - warn "./install-agent.sh --doctor after granting them." - exit 1 -fi diff --git a/install-client.sh b/install-client.sh index 5e3ec05..6580c2d 100755 --- a/install-client.sh +++ b/install-client.sh @@ -2,795 +2,639 @@ # # 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 client/agent/ into +# ~/Applications/hark.app and registers it 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" +BUILD_DIR="$REPO_DIR/build" +APP_SRC="$BUILD_DIR/hark.app" +APP_DIR="$HOME/Applications" +APP_DST="$APP_DIR/hark.app" + +HARK_CONFIG_DIR="$HOME/.config/hark" +CLIENT_CONFIG="$HARK_CONFIG_DIR/client.json" +MIC_STATUS="$HARK_CONFIG_DIR/agent-mic-status" +ACCESSIBILITY_STATUS="$HARK_CONFIG_DIR/agent-accessibility-status" +# The code identity the last install was granted against. See +# reset_stale_grants_on_identity_change(). +INSTALLED_CDHASH="$HARK_CONFIG_DIR/.agent-cdhash" +SERVER_KEY="$HARK_CONFIG_DIR/key" + +LEGACY_CONFIG="$HOME/.hammerspoon/hark-config.lua" + +LAUNCH_AGENTS="$HOME/Library/LaunchAgents" +AGENT_LABEL="com.drycodeworks.hark-agent" +AGENT_PLIST="$LAUNCH_AGENTS/$AGENT_LABEL.plist" + +DEFAULT_SERVER="http://127.0.0.1:8911/dictate" + +# 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 - fi - doctor_fail "Hammerspoon is running" "open -a Hammerspoon (or re-run ./install-client.sh, which does this for you)" - return 1 +write_client_config() { + local server="$1" key="$2" + mkdir -p "$HARK_CONFIG_DIR" + # Written 600 BEFORE the secret goes in, so there is no window where the + # key exists in a world-readable file. + : > "$CLIENT_CONFIG" + chmod 600 "$CLIENT_CONFIG" + cat > "$CLIENT_CONFIG" <&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 - 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}" + log "fetching the shared secret from ${SERVER_HOST} over SSH..." >&2 + local err_file fetched rc=0 + err_file="$(mktemp)" + + 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 - return 1 +reset_stale_grants_on_identity_change() { + local new_hash old_hash="" + new_hash="$(current_cdhash)" + [[ -n "$new_hash" ]] || return 0 + [[ -f "$INSTALLED_CDHASH" ]] && old_hash="$(cat "$INSTALLED_CDHASH")" + + mkdir -p "$HARK_CONFIG_DIR" + printf '%s' "$new_hash" > "$INSTALLED_CDHASH" + + # First install, or the same binary reinstalled: nothing to invalidate. + [[ -n "$old_hash" && "$old_hash" != "$new_hash" ]] || return 0 + + warn "the agent binary changed (${old_hash:0:12}… -> ${new_hash:0:12}…)." + warn "Ad-hoc signing ties TCC grants to that hash, so the Accessibility grant" + warn "no longer applies — and macOS would still show its toggle switched ON." + if tccutil reset Accessibility "$AGENT_LABEL" >/dev/null 2>&1; then + warn "Cleared the stale entry. You will be asked to grant it again." + else + warn "Could not clear it automatically. Toggle hark OFF and back ON in" + warn "System Settings -> Privacy & Security -> Accessibility." fi - local value - value="$(grep -E "^[[:space:]]*${field}[[:space:]]*=" "$CONFIG_FILE" 2>/dev/null \ - | sed -E 's/^[^"]*"([^"]*)".*/\1/' || true)" - if [[ -z "$value" ]]; then +} + +# ============================================================================== +# 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 - printf '%s' "$value" + doctor_pass "hark.app is installed at $APP_DST" } -# 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_signature() { + if [[ ! -d "$APP_DST" ]]; then + doctor_fail "hark.app has a valid signature" "run ./install-client.sh" + return 1 fi - if [[ -x "$RECORDER_BIN" ]]; then - printf '%s' "$RECORDER_BIN" - return 0 + if ! codesign --verify --strict "$APP_DST" 2>/dev/null; then + doctor_fail "hark.app has a valid signature" \ + "rebuild it: ./client/agent/build-agent.sh && ./install-client.sh" + return 1 fi - return 1 + local identity + identity="$(codesign -dvv "$APP_DST" 2>&1 | grep -E '^Signature=' | cut -d= -f2- || true)" + doctor_pass "hark.app signature is valid (${identity:-unknown})" } -check_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_config() { + if [[ ! -f "$CLIENT_CONFIG" ]]; then + doctor_fail "$CLIENT_CONFIG exists" "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 + local perms + perms="$(stat -f '%OLp' "$CLIENT_CONFIG")" + if [[ "$perms" != "600" ]]; then + doctor_fail "$CLIENT_CONFIG is 600 (it holds a secret)" "chmod 600 $CLIENT_CONFIG" + return 1 + fi + if ! json_field key >/dev/null; then + doctor_fail "$CLIENT_CONFIG has a key" "run ./install-client.sh" + return 1 + fi + doctor_pass "$CLIENT_CONFIG is present, 600, and has a key" } -# 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. -# -# 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)" +check_agent_running() { + if ! agent_loaded; then + doctor_fail "the agent is loaded in launchd" "run ./install-client.sh" return 1 fi + if ! pgrep -f "$APP_DST/Contents/MacOS/hark-agent" >/dev/null 2>&1; then + doctor_fail "the agent process is running" \ + "check /tmp/hark-agent.err and ~/Library/Logs/hark-agent.log" + return 1 + fi + doctor_pass "the agent is loaded and running" +} - local status - status="$(head -n 1 "$status_file" 2>/dev/null || true)" +# Reads the outcome the AGENT's own startup probe wrote. This is the only +# reliable way to learn whether the agent can reach the microphone: TCC +# attributes a request to the responsible process, and rec runs as the +# agent's child — so running rec from THIS shell would test the terminal's +# grant, a different permission that produces a confidently wrong PASS. +# Never run rec from here to "test" this; read what the agent wrote. +check_mic() { + if [[ ! -f "$MIC_STATUS" ]]; then + doctor_fail "the agent can reach the microphone" \ + "the agent hasn't probed yet — is it running? (./install-client.sh)" + return 1 + fi + local status detail + status="$(sed -n '1p' "$MIC_STATUS")" + detail="$(sed -n '3p' "$MIC_STATUS" || true)" case "$status" in ok) - doctor_pass "Hammerspoon can reach the microphone" - return 0 + doctor_pass "the agent can reach the microphone" ;; 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 "the agent can reach the microphone" \ + "System Settings -> Privacy & Security -> Microphone -> turn ON hark" ;; *) - 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 + doctor_fail "the agent can reach the microphone (probe said: $status)" \ + "${detail:-see ~/Library/Logs/hark-agent.log}" ;; 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 +# Reads what the agent's own AXIsProcessTrusted() call recorded — NOT TCC.db. +# +# This check used to query TCC.db directly and it produced a confident FALSE +# PASS on 2026-08-03: it reported "Accessibility is granted" while the agent +# was simultaneously alerting on screen that it could not paste. The row in +# TCC.db outlives the grant it describes. An ad-hoc signature's designated +# requirement is a bare `cdhash`, so every rebuild is a new identity — the old +# row survives with auth_value=2, System Settings keeps drawing a switched-ON +# toggle, and the running binary is trusted by nobody. +# +# So the same rule as the microphone applies for the same underlying reason: +# only the process can answer for the process. Reading TCC.db also required +# Full Disk Access, which this script does not necessarily have. +check_accessibility() { + local status_file="$ACCESSIBILITY_STATUS" + if [[ ! -f "$status_file" ]]; then + # An agent older than this check, or one that has not started yet. Not a + # failure, and deliberately not a PASS either. + printf ' \033[1;33mSKIP\033[0m Accessibility (the agent has not reported yet)\n' + printf ' if dictation records but nothing pastes, that is this permission:\n' + printf ' System Settings -> Privacy & Security -> Accessibility -> hark\n' + return 0 + fi + if [[ "$(sed -n '1p' "$status_file")" == "ok" ]]; then + doctor_pass "Accessibility is granted (agent reported at $(sed -n '2p' "$status_file"))" + else + doctor_fail "Accessibility is granted to hark" \ + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark, then restart the agent" fi +} - # 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." +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_hotkey_conflict || 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 - - 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)." +case "$MODE" in + doctor) run_doctor; exit $? ;; + uninstall) run_uninstall; exit 0 ;; +esac -# --- 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 "building the agent" +"$REPO_DIR/client/agent/build-agent.sh" "$BUILD_DIR" -# 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 +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" -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)." +# 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 -# --- 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" +resolve_config -# --- 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 - -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 "$MIC_STATUS" + +write_plist +reload_agent + +# Wait for the agent's microphone probe to report, rather than sleeping a +# fixed interval. The probe cannot finish until the user has answered the +# consent dialog, so any fixed wait either races a human or pads every +# already-granted re-run. A 3s sleep here reported a spurious FAIL on the +# first install, with the prompt still on screen. +printf '==> waiting for the microphone probe (answer the prompt if one appears)' +probe_started_at="$(date +%s)" +while [[ ! -f "$MIC_STATUS" ]]; do + if [[ $(($(date +%s) - probe_started_at)) -ge 45 ]]; then + printf '\n' + warn "the probe did not report within 45s — the doctor below may be stale" break fi - 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/install-server.sh b/install-server.sh index d2636bb..f9e1c21 100755 --- a/install-server.sh +++ b/install-server.sh @@ -238,7 +238,7 @@ fi # ============================================================================== if [[ "$(uname -s)" != "Darwin" ]]; then - err "hark is macOS-only (launchd, AVAudioEngine, Hammerspoon, TCC)." + err "hark is macOS-only (launchd, AVAudioEngine, TCC)." 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_agent.py b/tests/test_install_client.py similarity index 82% rename from tests/test_install_agent.py rename to tests/test_install_client.py index 4700b07..ba3414d 100644 --- a/tests/test_install_agent.py +++ b/tests/test_install_client.py @@ -1,4 +1,4 @@ -"""Guard install-agent.sh and the agent bundle's metadata. +"""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 @@ -6,7 +6,7 @@ that reports a denied microphone as PASS sends the user looking somewhere else entirely. None of those announce themselves, so they get asserted here. -The scripted checks are exercised by sourcing install-agent.sh, which stops at +The scripted checks are exercised by sourcing install-client.sh, which stops at its source guard with every function defined and nothing installed. """ @@ -19,7 +19,7 @@ import pytest REPO = Path(__file__).resolve().parent.parent -SCRIPT = REPO / "install-agent.sh" +SCRIPT = REPO / "install-client.sh" INFO_PLIST = REPO / "client" / "agent" / "Info.plist" AGENT_SWIFT = REPO / "client" / "agent" / "hark-agent.swift" BUILD_SCRIPT = REPO / "client" / "agent" / "build-agent.sh" @@ -35,7 +35,7 @@ def run_sourced(body: str, env_overrides: dict[str, str] | None = None) -> tuple[int, str]: - """Source install-agent.sh, then run `body` with its functions available.""" + """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], @@ -146,15 +146,17 @@ def test_hardened_runtime_stays_on(self): assert "--options runtime" in BUILD_SCRIPT.read_text() -def test_rec_permission_help_does_not_name_a_single_app(): - # rec is spawned by both clients and TCC attributes the grant to whichever - # is responsible, so the row to enable reads "Hammerspoon" under the Lua - # client and "hark" under the agent. Naming one sends half the users - # looking for a row that was never going to be there. +def test_rec_permission_help_names_the_responsible_app(): + # TCC attributes the grant to the RESPONSIBLE process, not to rec, so the + # row a user has to switch on is named after whatever spawned it. hark is + # now the only thing that does, so naming it is finally correct — while + # the Hammerspoon client still shipped, this string named neither, because + # the right answer depended on which client you were running. rec = (REPO / "client" / "rec.swift").read_text() start = rec.index("let permissionHelp") help_text = rec[start : rec.index("\n\n", start)] - assert "hark" in help_text and "Hammerspoon" in help_text + assert "hark" in help_text + assert "Hammerspoon" not in help_text def test_launchagent_label_matches_the_bundle_id(): @@ -352,6 +354,96 @@ def test_a_missing_report_is_not_a_pass(self, tmp_path): assert "SKIP" in out +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-agent.sh" not in ci + assert "install-client.sh" in ci + assert "client/agent/build-agent.sh" 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