@@ -26,17 +26,30 @@ set -euo pipefail
2626
2727REPO_DIR=" $( cd " $( dirname " ${BASH_SOURCE[0]} " ) " && pwd) "
2828CONFIG_DIR=" $HOME /.config/hark"
29+ CONFIG_FILE=" $CONFIG_DIR /config.toml"
2930KEY_FILE=" $CONFIG_DIR /key"
3031LAUNCH_AGENTS=" $HOME /Library/LaunchAgents"
3132LABELS=(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
4154MODEL_DIR=" $HOME /.local/share/whisper-cpp"
4255MODEL_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.
82102hark_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.
94113check_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
109127check_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.
227314if [[ " ${BASH_SOURCE[0]} " != " $0 " ]]; then
228315 return 0
@@ -282,25 +369,38 @@ else
282369 log " Model saved to ${MODEL_PATH} "
283370fi
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
302380fi
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"
309409if [[ -s " $KEY_FILE " ]]; then
310410 log " Shared secret already exists (${KEY_FILE} ) — leaving it alone."
311411else
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."
320417fi
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
0 commit comments