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