Skip to content

Commit bb88017

Browse files
authored
Merge pull request #17 from DRYCodeWorks/dy/swift-server-guards
[server] Guard hark serve, install it, retire the Python server
2 parents fa500d8 + 57fb850 commit bb88017

25 files changed

Lines changed: 488 additions & 1772 deletions

README.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,20 @@ re-renders and reloads.
114114

115115
`./install-server.sh --doctor` re-runs the checks alone, read-only.
116116

117-
The plists are **rendered from templates** in `launchd/`, never edited by
118-
hand, because launchd reads its own XML and cannot see `config.py` — so the
119-
two drift silently. `tests/test_launchd_config_sync.py` renders the templates
120-
and asserts they agree with config, including that the ASR server is never
121-
bound off loopback.
117+
The plists are **rendered by `install-server.sh`**, never edited by hand.
118+
`tests/test_install_server_doctor.py` renders them against a fabricated config
119+
and asserts they agree with it — including that the ASR server is never bound
120+
off loopback.
122121

123-
A wildcard bind is refused by `hark.plists` itself, not only by the test
124-
suite — `install-server.sh` stops rather than installing a plist that listens
125-
on every interface. Any other address is accepted, since the two-machine setup
126-
binds to a private one on purpose.
122+
The server's own plist carries **no address at all**: `hark serve` reads
123+
`~/.config/hark/config.toml` directly, so there is one copy of that fact rather
124+
than two that can disagree. (`uvicorn` needed `--host` baked into the plist,
125+
which is what the old drift guard existed to police.)
126+
127+
A wildcard bind is refused twice: by `install-server.sh` before a plist is
128+
written, and by `hark serve` at startup. The second is the real enforcement;
129+
the first is what turns a launchd crash-loop into a message. Any other address
130+
is accepted, since the two-machine setup binds to a private one on purpose.
127131

128132
The shared secret lives at `~/.config/hark/key` (mode 600), outside the repo.
129133

@@ -441,13 +445,11 @@ install-server.sh transcription side: deps, model, plists, services
441445
install-client.sh builds + installs the agent, plus --doctor
442446
swift/
443447
Sources/hark/ the agent — hotkey, capture, paste, overlay
444-
Sources/HarkCore/ config, client, WAV, sanitise, server
448+
Sources/HarkCore/ config, client, WAV, sanitise, the HTTP server
445449
Packaging/build-app.sh assembles and signs Hark.app
446450
Tests/ SwiftPM suite (48)
447451
config.example.toml shape of ~/.config/hark/config.toml
448-
src/hark/ the Python HTTP service (still the one in use)
449-
launchd/ plist templates, rendered by hark.plists
450-
tests/ pytest suite
452+
tests/ pytest suite — drives the installers as subprocesses
451453
.github/workflows/ci.yml pytest + shellcheck + the signed bundle build
452454
docs/ design specs
453455
```

install-server.sh

Lines changed: 145 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,30 @@ set -euo pipefail
2626

2727
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
2828
CONFIG_DIR="$HOME/.config/hark"
29+
CONFIG_FILE="$CONFIG_DIR/config.toml"
2930
KEY_FILE="$CONFIG_DIR/key"
3031
LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
3132
LABELS=(com.drycodeworks.hark com.drycodeworks.hark-whisper)
3233

33-
# Where the running service lives, kept in step with hark.plists.VENV_DIR —
34-
# tests/test_launchd_config_sync.py asserts the plists point here. The clone is
34+
# Where the running service lives. The clone is
3535
# a place to edit code; a daemon that runs out of it breaks when the checkout
3636
# moves and silently changes behaviour on `git pull`.
37-
INSTALL_DIR="$HOME/.local/share/hark"
38-
VENV_DIR="$INSTALL_DIR/venv"
39-
VENV_PYTHON="$VENV_DIR/bin/python"
37+
APP_DIR="$HOME/Applications"
38+
APP_DST="$APP_DIR/Hark.app"
39+
40+
# Minimal TOML reader for the three scalars the plists need. Deliberately not a
41+
# parser: config.toml is two tables of scalars, and `hark serve` is the thing
42+
# that actually validates it.
43+
config_value() {
44+
local table="$1" key="$2" default="$3"
45+
[[ -f "$CONFIG_FILE" ]] || { printf '%s' "$default"; return; }
46+
awk -v t="[$table]" -v k="$key" '
47+
$0 ~ /^\[/ { in_t = ($0 == t); next }
48+
in_t && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" {
49+
sub(/^[^=]*=[[:space:]]*/, ""); gsub(/"/, ""); sub(/[[:space:]]*(#.*)?$/, "");
50+
print; exit
51+
}' "$CONFIG_FILE" | head -1 | grep . || printf '%s' "$default"
52+
}
4053

4154
MODEL_DIR="$HOME/.local/share/whisper-cpp"
4255
MODEL_NAME="ggml-large-v3-turbo.bin"
@@ -79,31 +92,36 @@ doctor_fail() {
7992
# Asked of the INSTALLED package, not the clone: the installed one is what
8093
# launchd is running, and if the two have drifted then the clone's answer is
8194
# the wrong one to probe with.
95+
# Probed over LOOPBACK, not over the configured bind address.
96+
#
97+
# The server accepts loopback by design — a client on this machine is the same
98+
# trust boundary whichever address it dials — and on a tailnet bind the server's
99+
# own machine cannot reach itself at that address anyway. Probing the bind
100+
# address from here reported the service as down while it was serving the other
101+
# machine perfectly.
82102
hark_url() {
83-
"$VENV_PYTHON" -c \
84-
'from hark import config; print(f"http://{config.HARK_HOST}:{config.HARK_PORT}")'
103+
printf 'http://127.0.0.1:%s' "$(config_value server port 8911)"
85104
}
86105

87106
# ==============================================================================
88107
# Checks
89108
# ==============================================================================
90109

91-
# The plists name an absolute path inside VENV_DIR. If that venv is missing or
92-
# broken, launchd's only account of it is a restart loop and a spawn error in
93-
# /tmp/hark.err — so check it here, first, where the message can say what to do.
110+
# The plist names an absolute path inside the bundle. If it is missing or its
111+
# signature is broken, launchd's only account is a restart loop and a spawn
112+
# error in /tmp/hark.err — so check it here, where the message can say what to do.
94113
check_server_installed() {
95-
if [[ ! -x "$VENV_DIR/bin/uvicorn" ]]; then
96-
doctor_fail "the server is installed at ${VENV_DIR}" \
97-
"re-run ./install-server.sh (it installs the package there; launchd runs that copy, not this clone)"
114+
if [[ ! -x "$APP_DST/Contents/MacOS/hark" ]]; then
115+
doctor_fail "the server is installed at ${APP_DST}" \
116+
"re-run ./install-server.sh (launchd runs that bundle, not this clone)"
98117
return 1
99118
fi
100-
if ! "$VENV_PYTHON" -c 'import hark' >/dev/null 2>&1; then
101-
doctor_fail "the server is installed at ${VENV_DIR}" \
102-
"the venv exists but cannot import hark — re-run ./install-server.sh"
119+
if ! codesign --verify --strict "$APP_DST" 2>/dev/null; then
120+
doctor_fail "the server bundle's signature verifies" \
121+
"rebuild it: ./install-server.sh"
103122
return 1
104123
fi
105-
doctor_pass "the server is installed at ${VENV_DIR}"
106-
return 0
124+
doctor_pass "the server is installed at ${APP_DST}"
107125
}
108126

109127
check_model() {
@@ -223,6 +241,75 @@ run_doctor() {
223241

224242
# Sourcing this file defines the check_* functions and stops here, so the test
225243
# suite can exercise them without running an install. Everything below this
244+
render_plists() {
245+
log "Rendering launchd plists from config..."
246+
mkdir -p "$LAUNCH_AGENTS"
247+
248+
BIND="$(config_value server bind 127.0.0.1)"
249+
# Refused here as well as in `hark serve`. The server exits with an
250+
# explanation, but launchd answers that with a crash loop, so catching it at
251+
# render is the difference between a message and a restart storm.
252+
case "$(printf '%s' "$BIND" | tr -d '[:space:]')" in
253+
0.0.0.0|::|"")
254+
err "server.bind is \"${BIND}\", which listens on every network interface."
255+
err "hark's response is pasted into whatever has focus, so this lets anyone"
256+
err "who can reach this machine choose what gets typed."
257+
err "Use 127.0.0.1, or this machine's private (tailnet/VPN/LAN) address."
258+
return 1
259+
;;
260+
esac
261+
PORT="$(config_value server port 8911)"
262+
WHISPER_PORT="$(config_value whisper port 8910)"
263+
WHISPER_BIN="$(command -v whisper-server || echo /opt/homebrew/bin/whisper-server)"
264+
265+
cat > "$LAUNCH_AGENTS/com.drycodeworks.hark.plist" <<PLIST
266+
<?xml version="1.0" encoding="UTF-8"?>
267+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
268+
<plist version="1.0">
269+
<dict>
270+
<key>Label</key><string>com.drycodeworks.hark</string>
271+
<key>ProgramArguments</key>
272+
<array>
273+
<string>${APP_DST}/Contents/MacOS/hark</string>
274+
<string>serve</string>
275+
</array>
276+
<key>RunAtLoad</key><true/>
277+
<key>KeepAlive</key><true/>
278+
<key>StandardOutPath</key><string>/tmp/hark.log</string>
279+
<key>StandardErrorPath</key><string>/tmp/hark.err</string>
280+
</dict>
281+
</plist>
282+
PLIST
283+
284+
cat > "$LAUNCH_AGENTS/com.drycodeworks.hark-whisper.plist" <<PLIST
285+
<?xml version="1.0" encoding="UTF-8"?>
286+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
287+
<plist version="1.0">
288+
<dict>
289+
<key>Label</key><string>com.drycodeworks.hark-whisper</string>
290+
<key>ProgramArguments</key>
291+
<array>
292+
<string>${WHISPER_BIN}</string>
293+
<string>--model</string>
294+
<string>${MODEL_PATH}</string>
295+
<string>--host</string>
296+
<string>127.0.0.1</string>
297+
<string>--port</string>
298+
<string>${WHISPER_PORT}</string>
299+
<string>--language</string>
300+
<string>en</string>
301+
</array>
302+
<key>RunAtLoad</key><true/>
303+
<key>KeepAlive</key><true/>
304+
<key>StandardOutPath</key><string>/tmp/hark-whisper.log</string>
305+
<key>StandardErrorPath</key><string>/tmp/hark-whisper.err</string>
306+
</dict>
307+
</plist>
308+
PLIST
309+
310+
log "Rendered both plists (hark: ${BIND}:${PORT}, whisper: 127.0.0.1:${WHISPER_PORT})"
311+
}
312+
226313
# line only runs when the script is executed directly.
227314
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
228315
return 0
@@ -282,25 +369,38 @@ else
282369
log "Model saved to ${MODEL_PATH}"
283370
fi
284371

285-
# --- 3. Install the server -----------------------------------------------------
286-
287-
# launchd runs THIS copy, not the clone. Rebuilt from scratch on every run so a
288-
# dependency dropped from pyproject.toml actually leaves, rather than lingering
289-
# in the installed environment and hiding a missing declaration until someone
290-
# installs fresh. It is a few seconds and a 26 MB directory.
291-
log "Installing the server into ${VENV_DIR}..."
292-
mkdir -p "$INSTALL_DIR"
293-
rm -rf "$VENV_DIR"
294-
uv venv --quiet "$VENV_DIR"
295-
uv pip install --quiet --python "$VENV_PYTHON" "$REPO_DIR"
296-
297-
# Prove it before a plist points launchd at it: a venv that cannot import hark
298-
# would otherwise surface as a restart loop with a traceback in /tmp/hark.err.
299-
if ! "$VENV_PYTHON" -c 'import hark' >/dev/null 2>&1; then
300-
err "installed ${VENV_DIR} but it cannot import hark — aborting."
372+
# --- 3. Build and install the server ------------------------------------------
373+
374+
# One signed bundle, two roles: `hark serve` here, `hark agent` on whatever Mac
375+
# you dictate from. install-client.sh installs the same artifact, so a
376+
# single-machine setup ends up with one copy that plays both parts.
377+
if ! command -v swift >/dev/null 2>&1; then
378+
err "swift not found. Install the Xcode command line tools: xcode-select --install"
301379
exit 1
302380
fi
303-
log "Installed. The clone is now only needed to re-install."
381+
382+
log "Building the server..."
383+
(cd "$REPO_DIR/swift" && swift build -c release >/dev/null && bash Packaging/build-app.sh >/dev/null)
384+
385+
log "Installing to ${APP_DST}..."
386+
mkdir -p "$APP_DIR"
387+
# Replaced wholesale: a stale file left inside the bundle invalidates the
388+
# signature, and that surfaces much later as an unexplained TCC re-prompt.
389+
rm -rf "$APP_DST"
390+
cp -R "$REPO_DIR/swift/Packaging/Hark.app" "$APP_DST"
391+
392+
# Prove it runs before a plist points launchd at it. A binary that cannot
393+
# start would otherwise surface as a restart loop with nothing in the log.
394+
# Captured, not piped: `hark` with no arguments prints usage and exits 2 —
395+
# correct behaviour — and under `set -o pipefail` that makes the pipeline fail
396+
# no matter what grep says, so the check condemned a working binary.
397+
usage_out="$("$APP_DST/Contents/MacOS/hark" 2>&1 || true)"
398+
if [[ "$usage_out" != *"usage: hark"* ]]; then
399+
err "installed ${APP_DST} but the binary does not run — aborting."
400+
err "got: ${usage_out}"
401+
exit 1
402+
fi
403+
log "Installed."
304404

305405
# --- 4. Shared secret ---------------------------------------------------------
306406

@@ -309,24 +409,20 @@ chmod 700 "$CONFIG_DIR"
309409
if [[ -s "$KEY_FILE" ]]; then
310410
log "Shared secret already exists (${KEY_FILE}) — leaving it alone."
311411
else
312-
# Generated by config.hark_key() rather than here, so there is exactly one
313-
# implementation of how the key is created and persisted. Regenerating a
314-
# key that already exists would silently 401 every configured client.
315-
log "Generating the shared secret..."
316-
# Run from the INSTALLED package: the key the server will read must be
317-
# written by the same code that will read it.
318-
"$VENV_PYTHON" -c 'from hark import config; config.hark_key()'
319-
log "Wrote ${KEY_FILE}"
412+
# Created by the server on first use (KeyFile.ensure), not here, so there is
413+
# exactly one implementation of how the key is generated and persisted.
414+
# Regenerating a key that already exists would silently 401 every configured
415+
# client, which is why this branch only reports.
416+
log "No shared secret yet — the server will create ${KEY_FILE} on first start."
320417
fi
321418

322419
# --- 5. Render the plists -----------------------------------------------------
420+
#
421+
# Rendered here rather than by a Python module, so the server has no Python at
422+
# all. The wildcard-bind check is enforced by `hark serve` itself at startup —
423+
# it refuses 0.0.0.0 with an explanation — so this does not re-implement it.
323424

324-
log "Rendering launchd plists from config..."
325-
mkdir -p "$LAUNCH_AGENTS"
326-
# From the CLONE, not the installed venv: the templates live in launchd/ and
327-
# are not shipped in the wheel. Rendering is an install-time task, and this
328-
# script is part of the checkout that has them.
329-
(cd "$REPO_DIR" && uv run --quiet python -m hark.plists >/dev/null)
425+
render_plists
330426

331427
# --- 6. Load the services -----------------------------------------------------
332428

launchd/com.drycodeworks.hark-whisper.plist.template

Lines changed: 0 additions & 32 deletions
This file was deleted.

launchd/com.drycodeworks.hark.plist.template

Lines changed: 0 additions & 29 deletions
This file was deleted.

pyproject.toml

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,12 @@
22
name = "hark"
33
version = "0.1.0"
44
requires-python = ">=3.12"
5-
dependencies = [
6-
"fastapi>=0.115",
7-
"uvicorn>=0.32",
8-
"httpx>=0.27",
9-
]
5+
dependencies = []
106

117
[dependency-groups]
12-
dev = ["pytest>=8.3", "pytest-asyncio>=0.24", "respx>=0.21"]
13-
14-
[build-system]
15-
requires = ["hatchling"]
16-
build-backend = "hatchling.build"
17-
18-
[tool.hatch.build.targets.wheel]
19-
packages = ["src/hark"]
8+
dev = ["pytest>=8.3"]
209

2110
[tool.pytest.ini_options]
22-
pythonpath = ["src"]
23-
asyncio_mode = "auto"
11+
# No pythonpath and no asyncio: nothing here imports a package any more. The
12+
# suite drives install-server.sh and install-client.sh as subprocesses, which
13+
# is what a user actually runs.

src/hark/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)