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