Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,26 @@ jobs:

# Preinstalled on the Ubuntu runner image.
- name: shellcheck
run: shellcheck install-server.sh install-client.sh
run: |
shellcheck install-server.sh install-client.sh install-agent.sh \
client/agent/build-agent.sh

# The agent is macOS-only and needs a real Swift toolchain, so this cannot
# join the portable matrix above. It builds the bundle and asserts the
# signature verifies - an unsigned or broken-signature bundle is exactly the
# state in which TCC grants stop surviving a rebuild.
agent:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7

- name: Build hark.app
run: ./client/agent/build-agent.sh

- name: Verify the bundle signature
run: codesign --verify --strict --verbose=2 build/hark.app

- name: Verify the bundled recorder is present and signed
run: |
test -x build/hark.app/Contents/MacOS/rec
codesign --verify --strict build/hark.app/Contents/MacOS/rec
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ config.toml

# Local migration notes, not part of the project.
LAPTOP-MIGRATION.local.md

# Built agent bundle. client/agent/build-agent.sh writes here; the sources it
# compiles are the tracked artifact, never the .app.
build/
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,38 @@ It:

Safe to re-run at any time; every step checks current state first.

#### Native agent (preview, opt-in)

There is a second client — a native Swift agent that does the same job without
Hammerspoon. It is not the default yet, and installing it changes nothing about
the Hammerspoon path:

```bash
./install-agent.sh # build, install to ~/Applications, load at login
./install-agent.sh --doctor # read-only diagnosis
./install-agent.sh --uninstall
```

Why it exists: Accessibility is currently granted to Hammerspoon — a
general-purpose scriptable Lua runtime — and its config is a symlink into this
repo, so `git pull` changes what that grant covers without re-prompting. A
single-purpose bundle asks for the same permission with far less behind it.
See [issue #2](https://github.com/DRYCodeWorks/hark/issues/2).

**The two clients cannot both hold the hotkey.** `Ctrl+Alt+Space` is a
system-wide registration and exactly one process gets it; whichever starts
first wins and the other reports that it could not register. `install-agent.sh`
quits Hammerspoon for you unless you pass `--keep-hammerspoon`.

Migration is non-destructive in both directions. `~/.hammerspoon/hark-config.lua`
is read into `~/.config/hark/client.json` and never modified, so rolling back is
just `./install-agent.sh --uninstall` and relaunching Hammerspoon.

You will be prompted for Microphone and Accessibility again — TCC keys grants to
a code identity, and the agent is a different one from Hammerspoon. Until a
Developer ID certificate is in place the bundle is ad-hoc signed, which means
those grants survive until the binary changes and you re-grant after a rebuild.

### 3. Configuration

Everything is optional — the defaults are the working single-machine setup.
Expand Down Expand Up @@ -383,15 +415,20 @@ transient.
```
install-server.sh transcription side: deps, model, plists, services
install-client.sh hotkey/mic/paste side, plus --doctor
install-agent.sh native agent install/doctor/uninstall (preview)
client/
init.lua Hammerspoon client
rec.swift AVAudioEngine recorder, built at install time
hark-config.example.lua shape of ~/.hammerspoon/hark-config.lua
agent/
hark-agent.swift native client — hotkey, capture, POST, paste
Info.plist bundle identity + microphone usage string
build-agent.sh assembles and signs hark.app
config.example.toml shape of ~/.config/hark/config.toml
src/hark/ the HTTP service
launchd/ plist templates, rendered by hark.plists
tests/ pytest suite (67) + test_client_record.lua (8)
.github/workflows/ci.yml both suites + shellcheck, on Linux and macOS
tests/ pytest suite + test_client_record.lua (8)
.github/workflows/ci.yml both suites + shellcheck + the agent build
docs/ design spec + implementation plan
```

Expand All @@ -400,7 +437,9 @@ Run the suites locally the way CI does:
```bash
uv run --locked pytest -q
lua tests/test_client_record.lua
shellcheck install-server.sh install-client.sh
shellcheck install-server.sh install-client.sh install-agent.sh \
client/agent/build-agent.sh
./client/agent/build-agent.sh # macOS only
```

## License
Expand Down
57 changes: 57 additions & 0 deletions client/agent/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>hark</string>

<key>CFBundleDisplayName</key>
<string>hark</string>

<!-- TCC KEYS ITS GRANTS TO THIS STRING. Changing it after anyone has
granted Accessibility or Microphone silently orphans those grants:
the old identifier keeps its row in the privacy panes, the new one
has none, and the hotkey just stops working with no error. Treat it
as permanent.

Deliberately not `com.drycodeworks.hark`, which is already the
launchd label of the SERVER (see src/hark/plists.py). They live in
different namespaces and would not actually collide, but sharing a
string between the daemon and the app makes every future `launchctl
list | grep` ambiguous. -->
<key>CFBundleIdentifier</key>
<string>com.drycodeworks.hark-agent</string>

<key>CFBundleExecutable</key>
<string>hark-agent</string>

<key>CFBundlePackageType</key>
<string>APPL</string>

<key>CFBundleShortVersionString</key>
<string>0.1.0</string>

<key>CFBundleVersion</key>
<string>1</string>

<!-- Background agent: no Dock icon, no menu bar, no app switcher entry.
The agent has no windows beyond transient overlays, so a Dock icon
would be a permanent reminder of something you cannot click. -->
<key>LSUIElement</key>
<true/>

<!-- REQUIRED, not decorative. An app that opens the microphone without
this key does not get a prompt with an empty reason - it is killed
by the system the moment it touches the device. This string is what
the consent dialog shows, so it has to explain the ask in the user's
terms rather than the implementation's. -->
<key>NSMicrophoneUsageDescription</key>
<string>hark records your voice while you hold the dictation hotkey, and sends it to your own transcription server. Audio is never stored after the transcript comes back.</string>

<key>LSMinimumSystemVersion</key>
<string>13.0</string>

<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
107 changes: 107 additions & 0 deletions client/agent/build-agent.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# Build client/agent/hark-agent.swift into hark.app.
#
# ./client/agent/build-agent.sh [output-dir]
#
# Output defaults to build/ at the repo root. install-client.sh calls this and
# then copies the bundle into place; run it directly when iterating on the
# agent itself.
#
# The bundle is ad-hoc signed (`codesign -s -`). That is not decoration:
#
# - An UNSIGNED bundle has no stable code identity at all, so TCC re-prompts
# essentially at random and grants do not survive a rebuild.
# - An AD-HOC signature gives it a cdhash identity. Grants survive until the
# binary changes, which is the best available answer during development
# and means one re-grant per rebuild rather than one per launch.
# - A DEVELOPER ID signature makes grants survive rebuilds outright, because
# the identity is then the certificate rather than the hash. That is what
# a release build wants, and it is tracked separately - the certificate is
# gated on Apple Developer enrollment paperwork, not on this script.
#
# When a Developer ID identity is available, pass it:
#
# HARK_SIGN_IDENTITY="Developer ID Application: ... (TEAMID)" \
# ./client/agent/build-agent.sh
#
# Notarization is a separate step against a release artifact; see DRY-723.

set -euo pipefail

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
AGENT_DIR="$REPO_DIR/client/agent"
CLIENT_DIR="$REPO_DIR/client"
OUT_DIR="${1:-$REPO_DIR/build}"
APP="$OUT_DIR/hark.app"

# Ad-hoc unless the caller supplies a real identity.
SIGN_IDENTITY="${HARK_SIGN_IDENTITY:--}"

log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
err() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; }

if ! command -v swiftc >/dev/null 2>&1; then
err "swiftc not found. Install the Xcode command line tools: xcode-select --install"
exit 1
fi

log "building into $APP"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS"

# The agent itself.
swiftc -O -o "$APP/Contents/MacOS/hark-agent" "$AGENT_DIR/hark-agent.swift"

# rec ships INSIDE the bundle. Two reasons: the app is self-contained, and a
# nested binary is covered by the bundle's own signature, so signing does not
# become a two-artifact problem later.
swiftc -O -o "$APP/Contents/MacOS/rec" "$CLIENT_DIR/rec.swift"

cp "$AGENT_DIR/Info.plist" "$APP/Contents/Info.plist"

# Marks the directory as a bundle for Launch Services. Without it the app can
# still be exec'd but `open` and login-item registration misbehave.
printf 'APPL????' > "$APP/Contents/PkgInfo"

log "signing with identity: $SIGN_IDENTITY"
# --options runtime enables the hardened runtime, which notarization requires
# and which is harmless ad-hoc. --force so a rebuild replaces the previous
# signature rather than failing on it. --deep is deliberately NOT used: it is
# deprecated and signs nested code with the wrong flags; the explicit rec sign
# below is the supported way to cover a nested binary.
#
# --entitlements is NOT optional under the hardened runtime. Microphone access
# requires com.apple.security.device.audio-input, and without it TCC refuses to
# PROMPT rather than denying after a prompt - so the app never appears in the
# Microphone pane and the code sees an instant .denied it cannot distinguish
# from a real refusal. See the entitlements file for the tccd log line.
#
# Both binaries get it: rec is the process that opens the device, and the app
# is the responsible process tccd checks the entitlement on.
ENTITLEMENTS="$AGENT_DIR/hark-agent.entitlements"

codesign --force --options runtime --timestamp=none \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" "$APP/Contents/MacOS/rec"
codesign --force --options runtime --timestamp=none \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" "$APP"

log "verifying"
codesign --verify --strict --verbose=2 "$APP" 2>&1 | sed 's/^/ /'

# Assert the entitlement actually made it into the signature. A signature that
# verifies is not evidence of this: the app signs, launches and runs perfectly
# without it, and only fails when it reaches for the microphone - at which
# point the failure names a permission problem rather than a build problem.
if ! codesign -d --entitlements - --xml "$APP" 2>/dev/null \
| plutil -convert xml1 -o - - 2>/dev/null \
| grep -q "com.apple.security.device.audio-input"; then
err "the signed bundle is missing com.apple.security.device.audio-input"
err "TCC will refuse to prompt and the microphone will read as denied."
exit 1
fi
log "entitlement present: com.apple.security.device.audio-input"

log "built $APP"
26 changes: 26 additions & 0 deletions client/agent/hark-agent.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- REQUIRED, and its absence is silent in the worst way.

Under the hardened runtime (which build-agent.sh enables, and which
notarization will require) microphone access needs this entitlement.
Without it TCC does not deny the request after asking - it refuses to
ASK. tccd logs:

Prompting policy for hardened runtime; service: kTCCServiceMicrophone
requires entitlement com.apple.security.device.audio-input but it is
missing for responsible={com.drycodeworks.hark-agent}
Policy disallows prompt for ...; access to kTCCServiceMicrophone denied

What the user sees is an app that never appears in System Settings ->
Privacy & Security -> Microphone at all, because that pane lists only
apps that have successfully requested. What the code sees is
AVCaptureDevice.authorizationStatus returning .denied instantly, which
is indistinguishable from a real user denial. Neither end names the
entitlement; only the unified log does. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
Loading
Loading