diff --git a/.gitignore b/.gitignore index 6d3dd88..e9fd7d5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ config.toml # Local migration notes, not part of the project. LAPTOP-MIGRATION.local.md + +# SwiftPM build artifacts (the Swift rewrite). +swift/.build/ + +# Generated app bundle (rebuilt by swift/Packaging/build-app.sh). +swift/Packaging/Hark.app/ diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md new file mode 100644 index 0000000..f631f41 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -0,0 +1,591 @@ +# Native client design — replacing the Hammerspoon Lua client + +Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) +Date: 2026-07-31 +Revision: 4. Panel rounds: 6/6 REVISE, then 5 REVISE + 1 APPROVED, then 6/6 REVISE with +the findings down to two contradictions and a format bug. Rebased onto `8d12f7b`; all line +numbers re-verified against that commit. + +## Why + +`install-client.sh:595` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +Hammerspoon has exactly one config file, so installing hark claims it. + +The permission story matters more. Accessibility is granted to Hammerspoon, not to hark: +a general-purpose scriptable Lua runtime holds a grant that can observe every keystroke, +and its config is a symlink into a git repo, so `git pull` changes what that grant covers +without re-prompting. + +What Hammerspoon buys in return is a **stable signed TCC identity**. Taking that away means +hark owns the identity problem itself, which is why this design assumes a Developer ID. + +## What earlier revisions got wrong + +**Revision 1's hotkey rationale.** It argued for Carbon `RegisterEventHotKey` because it +"asks for permission to type, not permission to watch you type." Synthesising ⌘V requires +Accessibility, and Accessibility is a single atomic grant that also permits creating a +`CGEventTap`. The claimed saving does not exist as stated. + +**Revision 1's collision claim.** It said `RegisterEventHotKey` errors when another app +owns the chord. The installed SDK says the opposite: + +> "The same hot key can, however, be registered by multiple applications... In Mac OS X +> 10.5 and later, you can request exclusive registration for your process only by passing +> `kEventHotKeyExclusive`." +> — `CarbonEvents.h`, `RegisterEventHotKey` discussion + +**Revision 1 fabricated a test claim.** It said `tests/test_audio.py` pins the wire format. +`audio.py:45-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. + +**Revision 2 contradicted itself twice.** It promised a Tailscale HTTP opt-in while +prohibiting non-loopback HTTP, with no mechanism for the opt-in; and it called an +all-silent buffer "distinct from a denied grant" while the error table filed both under +microphone permission. Both are resolved below. + +## Decisions + +| Question | Decision | +|---|---| +| Signing | Developer ID, hardened runtime, notarised and stapled. CI produces the artifact | +| Install | `install-client.sh` downloads and verifies a release artifact. It never compiles | +| App shape | `LSUIElement` bundle, menu bar status item, `~/Applications/Hark.app` (no sudo) | +| Capture | Absorbed in-process; the separate `rec` binary goes away | +| Config | `client.json` holds the server URL and the insecure-transport allowlist; the key always lives in `~/.config/hark/key` mode 600 | +| Hotkey | **Undecided.** Phase 0 decides, against the criteria below | +| Migration | Quit Hammerspoon, offer to revoke all three grants, restore or remove the symlink. Delete nothing else | + +### Why Developer ID, and what it does not buy + +Identical Swift sources produce a different CDHash on every build (measured). Under an +ad-hoc signature that would invalidate the agent's own Accessibility grant on every +rebuild. Revision 1 engineered an installer-side input hash around this; that mechanism is +**deleted**, because CI now produces the binary and the installer never compiles. + +Two corrections to how revision 2 stated the benefit: + +- TCC binds to the app's **designated requirement**, not simply "Team ID plus bundle ID". + CI must therefore keep the designated requirement stable across releases and across + certificate rotation, and Phase 0 must be validated against the **final signed bundle**, + not an ad-hoc spike binary. +- Notarisation requires the hardened runtime, and microphone capture under the hardened + runtime requires the audio-input entitlement. `NSMicrophoneUsageDescription` alone is not + sufficient. The entitlements file is part of the deliverable and CI verifies it. + +The client install no longer needs the Xcode command line tools, which removes a +multi-gigabyte dependency from the machine you dictate *from* — the point of #3, #4 and #5. + +`~/Applications` keeps the install sudo-free; under Developer ID the path is a packaging +choice, not a permission one. + +## Phase 0: the hotkey spike (gates everything else) + +Two questions decide the mechanism. Neither should be answered by argument. + +1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** + `README.md:266-268` states from this project's own experience that an `hs.eventtap` + watching `flagsChanged` needs it. If that holds for keyDown/keyUp, Carbon is one grant + and the tap is two, and the permission argument survives revision 1's bad reasoning by a + different route. If not, the tap is free. +2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** + +**Pass/fail criteria**, so the spike produces a decision rather than an impression: + +- Test on the oldest supported macOS (13) and the newest available. +- 200 press/release cycles per mechanism; **zero** missed releases required. A missed + release strands the agent in `recording`, which is why the bar is zero rather than low. +- Include modifier rolls (press ⌃⌥Space, add ⇧ mid-hold, release), sustained CPU load, and + a display-sleep/wake cycle mid-hold. +- Register the chord while a second app holds it, with and without + `kEventHotKeyExclusive`, and record whether registration reports the collision. +- Run against the **signed, notarised bundle**, not a bare binary. + +If Carbon wins, `kEventHotKeyExclusive` is mandatory — migration cannot claim anything +about chord ownership without it (see Migration). + +### The zero-frames question is settled, and already fixed upstream + +Revisions 1 and 2 treated this as an open risk. It is neither open nor hypothetical: +inferring microphone permission from a frame count does not work, because a TCC-denied +device yields substituted silence rather than no frames. It was filed as +[#9](https://github.com/DRYCodeWorks/hark/issues/9) and fixed in `559aafe` for the current +Lua client, whose `rec.swift` now says so directly: + +> "format negotiation succeeds under denial too. The only way to learn the answer is to +> ask TCC for it." + +That commit is the reference implementation for this design's permission handling rather +than something it has to invent: `rec.swift:65` switches on +`AVCaptureDevice.authorizationStatus(for: .audio)`, `:78` calls `requestAccess` and pumps +the main run loop rather than blocking on a semaphore (`requestAccess` delivers on an +unspecified queue, so a blocked main thread can deadlock), and the zero-frame check +survives at `:185` for its real meaning — the device delivered nothing at all. + +The native agent inherits all three behaviours. Phase 0 no longer has anything to decide +here. + +## Architecture + +One SPM package. CI runs `swift build` and `swift test`; the release job assembles, signs, +notarises and staples `Hark.app`. + +Bundle ID `com.drycodeworks.hark-agent` — a **new** identifier following the existing +launchd label style, not an existing label. `Info.plist` carries `LSUIElement`, +`NSMicrophoneUsageDescription`, `NSLocalNetworkUsageDescription` (needed for the +two-machine setup on macOS 15+, and separate from ATS), and the ATS keys below. +Entitlements carry the audio-input entitlement under the hardened runtime. + +| File | Responsibility | +|---|---| +| `Config.swift` | Load `client.json`; read and trim the key; enforce the transport policy | +| `Hotkey.swift` | Register the chord (mechanism per Phase 0); press/release; registration failure | +| `Recorder.swift` | AVAudioEngine capture, conversion, in-memory WAV, capture-state reporting | +| `DictateClient.swift` | Bounded POST, decode, typed errors, response sanitisation | +| `AgentController.swift` | State machine, permissions, paste, status heartbeat, logging, menu bar | + +Revision 1 had eight files; `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one +caller and no independent policy. The status item shows state and offers Quit only — +`install-client.sh --doctor` is already the diagnostic interface. + +### Login start + +`SMAppService.mainApp.register()` is called by the **installer as the last step of a +successful cutover** (Migration, step 4), never automatically at first launch. Registering +on launch would mean a rolled-back install still starts the agent at the next login, where +it would fight the restored Hammerspoon client. + +Registration only schedules for *subsequent* logins, so the installer also starts the agent +directly for the current session. Handle `.requiresApproval` (macOS has disabled it pending +user consent) and `.notFound`/error by surfacing state in the menu bar, in the status +heartbeat's `login_item` field, and in `--doctor`, rather than assuming success. + +### Permissions + +Capture permission is never inferred from frame counts: + +- `AVCaptureDevice.authorizationStatus(for: .audio)`, and `requestAccess` at first launch + to raise the dialog. The Microphone pane has no "+" button and lists only apps that have + already asked, so the request must actually happen. +- Probe at **launch**, not at first hotkey press, so a clean install has a status before + anyone runs `--doctor`. +- `AXIsProcessTrustedWithOptions` with the prompt option. `AXIsProcessTrusted` alone only + checks and never prompts, leaving a fresh install with no path to the grant. +- The agent probes local-network reachability itself. A `curl` from `--doctor` is a false + PASS, because terminal-launched tools are exempt from Local Network privacy. + +### Capture + +The wire format is **16 kHz, mono, 16-bit signed PCM, little-endian, single `data` chunk** — +stated explicitly here because `audio.py:22` documents it in a comment while enforcing only +sample width. + +`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:375` guards against. +`Recorder` builds the 44-byte header itself; RIFF and `data` sizes are back-patched before +the POST, since neither is known until release. + +The tap callback runs on an audio thread while key-up runs on the main queue. +`rec.swift:163` mutates `framesWritten` from the callback while `rec.swift:185` reads it +from `stop()` — unsynchronised today, and more consequential in-process. An actor or serial +queue owns the buffer; stop waits for the tap to drain and is idempotent. + +## State machine + +``` +idle ──press──► starting ──first buffer──► recording ──release──► stopping + ▲ │ │ │ + │ └── deadline / failure ──┐ └── cap reached ─────┤ + └──── paste, error, or discard ◄── uploading ◄──── drain complete ──┘ +``` + +| Transition | Behaviour | +|---|---| +| press outside `idle` | ignored | +| `starting`, no first buffer within **5 s** | abort to `idle`, report device or permission | +| `starting` + release | finish starting, then honour the release; never strand | +| engine start fails, or permission denied | abort to `idle` with the specific cause | +| `recording` beyond **120 s** | stop and upload what was captured, with a visible notice | +| `uploading`, request exceeds **30 s** | cancel, return to `idle`, report timeout | +| sleep, or user-session switch | abort capture to `idle` | + +`stopping` exists so a press immediately after release cannot start a second capture while +the first drains — revision 1 would have overlapped captures or dropped the utterance. + +**One in-flight request**, with a sequence number. The state machine alone mostly prevents +overlap, but a timed-out request that completes late must not paste into a newer capture's +turn, so responses whose sequence is not current are discarded. + +A 5 s starting deadline is separate from the 120 s capture cap: today's recorder arms its +ceiling only after the first buffer (`rec.swift:139`), so a device that never delivers one +hangs indefinitely. + +## Data flow + +1. Press. Ignore unless `idle` and the key resolves. +2. Capture starts; status item and overlay show recording. +3. Release. Tap drains; WAV finalised in memory. **Snapshot the frontmost application.** +4. No frames, or an all-silent buffer: report the capture-side cause and stop. Do not POST. +5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. +6. `200` with empty text: transient "heard nothing", paste nothing. Not an error. +7. `200` with text: sanitise, verify the paste target, set the pasteboard, verify the write, + then ⌘V. + +Never Return. The clipboard is deliberately not restored. + +**Paste-target policy.** Transcription is asynchronous, so focus can move between release +and response. If the frontmost **application** is not the one snapshotted at release, put +the transcript on the pasteboard and report that automatic paste was withheld. Typing a +transcript into whatever happens to be focused later is worse than making the user press +⌘V. + +The guarantee is deliberately stated at application granularity, because that is all this +mechanism enforces. It does **not** catch a focus move *within* an application — a +different browser tab, or a password field in the same window. Catching that would mean +comparing the system-wide focused accessibility element, which is a heavier check against a +moving target. Claiming protection this does not deliver would be worse than the narrower +promise, so the README should describe it in the same terms. + +**Paste failure propagates.** If the pasteboard write fails, report it and do **not** +synthesise ⌘V — otherwise the keystroke pastes whatever was on the clipboard before. + +## Response handling and limits + +The server sanitises at `sanitize.py:23`; revision 1 treated that as sufficient. It is not. +A compromised server, or anyone on the network path of a plaintext two-machine setup, +returns JSON the server's sanitiser never touched, and "Never Return" protects nothing when +the payload itself carries `\r` or `\n` into a terminal without bracketed paste. + +| Bound | Value | Applied | +|---|---|---| +| Response body | **1 MiB** | Before JSON decoding, as a streaming byte limit | +| Sanitised text | **8 KiB** (UTF-8 bytes) | After decode and sanitisation | +| Error `detail` | **2 KiB**, sanitised | Same treatment as transcript text | +| Behaviour past either | **Reject, do not truncate** | Rejection is visible; truncation silently corrupts a transcript | + +The body cap must bind before decoding — a post-decode cap still lets a hostile server make +`URLSession` buffer an unbounded response. + +**Every response path is bounded and sanitised, not just the 200.** The error table below +displays the server's `detail` string on 400, 503 and the catch-all, and that string is +just as attacker-controlled as a transcript. An unsanitised `detail` reaches an alert +rather than the pasteboard, so it cannot be typed into a terminal — but it can still carry +control sequences into whatever renders it, and an unbounded one is a denial of service on +the UI. Same rule, smaller cap. + +Sanitisation applies the same rule as `sanitize.py`: C0/C1 controls and Unicode line +separators replaced with a space, whitespace collapsed. The Swift and Python +implementations share test cases, including hostile inputs: embedded newlines, CSI/OSC +sequences, U+2028/U+2029, and an oversized body. + +## Transport policy + +The two-machine default is plaintext HTTP (`install-client.sh:525`) to what +`README.md:234` calls "a LAN address you trust". On that path an attacker reads the audio, +the transcript and `X-Hark-Key`, and can forge the response the client then types. + +Revision 2 prohibited non-loopback HTTP while promising a Tailscale opt-in, with no +mechanism. Resolved: + +```json +{ + "server": "http://100.x.y.z:8911/dictate", + "insecure_transport_hosts": ["100.x.y.z"] +} +``` + +- HTTPS is required for every non-loopback **hostname**, with no exceptions. +- Plain HTTP is permitted for numeric loopback unconditionally, and for a **numeric IP + literal** only if that exact address is listed in `insecure_transport_hosts`. +- The allowlist exists because Tailscale already encrypts at the network layer, so HTTP to + a tailnet IP is a defensible choice — but it must be a stated one, not a silent default. + Provisioning TLS on the hark server would be a larger change than this issue. +- A Tailscale MagicDNS name is therefore **not** usable over HTTP. Use the tailnet IP. +- `--doctor` reports every entry as a warning naming the assumption it encodes. +- Userinfo (`user@host`) rejected; `install-client.sh:241` already treats it as a defect. +- Redirects cancelled explicitly in the `URLSession` delegate — the default session follows + them. +- Ephemeral `URLSession`, so credentials and responses are not cached to disk. + +**ATS: `NSAllowsLocalNetworking` only, and nothing per-host.** Revision 3 said the +installer would generate per-host `NSExceptionDomains` entries. That is impossible: +`NSExceptionDomains` lives in the signed `Info.plist`, so writing to it after download +invalidates the CDHash and the Developer ID signature, and macOS kills the app at launch. + +Restricting insecure HTTP to IP literals is what makes a static plist sufficient — +`NSAllowsLocalNetworking` exempts IP-literal loads without naming any host, so the plist +never has to know which addresses a given user configured. The `Config` allowlist check at +runtime, not ATS, remains the actual boundary. Phase 0 confirms the key behaves this way on +both supported macOS versions, since bare IP handling has changed across releases. + +## Configuration + +The key always lives at `~/.config/hark/key`, mode 600. Revision 1's inline-versus-file +branch is deleted: one path, one permission contract. + +- `install-client.sh` creates `~/.config/hark` mode 700 before writing. On a two-machine + *client* the directory does not otherwise exist — the server-side `mkdir` ran on the + other machine. +- The key is read and **trimmed**: `config.py:129` writes `key + "\n"` and + `install-client.sh:449` strips it today. Raw bytes 401 every request. Embedded whitespace + is rejected rather than silently trimmed. +- `client.json` is written by a real JSON encoder and replaced atomically. The current + heredoc (`install-client.sh:563`) validates quotes only in the key (`:491`), so a + hand-entered URL containing a quote produces an unparseable file. +- `--doctor` stops passing the key in argv (`install-client.sh:313`), where any local + account can read it from the process table. + +**Known residual risk.** Any same-UID process can rewrite `client.json` and point the +trusted agent at an attacker's server; mode 600 does not prevent this. Under Developer ID +the mitigation is available — pin the approved origin in a Keychain item protected by code +identity, requiring in-app confirmation to change. Recorded as a follow-up; it needs its own +design. + +## Error handling + +| Condition | Message names | +|---|---| +| Connection / transport failure | The URL, and that hark may not be running | +| 401 | Key mismatch, and the file to fix | +| 415 | A client bug, not a microphone problem | +| 400 | Malformed or unsupported audio — a client format bug. Shows the server's `detail` | +| 503 | whisper-server down, `/tmp/hark-whisper.err` | +| Other | Status code and the server's `detail` | +| No frames captured | Device selection, and microphone permission | +| All-silent buffer | Input level or a muted device — **not** reported as a denied grant | + +The last two rows resolve revision 2's contradiction. A denied grant is detected by +`authorizationStatus`, never inferred from audio content, so an all-silent buffer means the +device is muted or the level is too low. + +**The server must change too.** `app.py:103-107` returns one detail for every +`InvalidAudioError` — "Check that the client has microphone permission and is sending +16 kHz mono 16-bit PCM WAV" — so a malformed header from this client's hand-built WAV would +still send users to Microphone settings. `test_app.py:107` asserts that wording +(`assert "microphone" in detail or "mic" in detail`). + +`audio.py` already raises distinct causes: empty body, unreadable WAV, wrong sample width. +Branch on them — empty body keeps the microphone advice, which is right for any client; +malformed or unsupported audio gets format advice. Update the test to match the cause it +exercises. + +## Status and `--doctor` + +`--doctor` must not run the agent binary itself: a probe launched from the terminal tests +the *terminal's* TCC grant, the trap `install-client.sh:177-184` already documents. + +Revision 2's "generation stamp" was not implementable — `--doctor` is bash and cannot read +the agent's internal state. The binding must be independently observable: + +```json +{ + "pid": 4321, + "process_started": "Fri Jul 31 14:02:11 2026", + "written_epoch": 1785508262, + "bundle_version": "1.2.0", + "microphone": "authorized", + "accessibility": "trusted", + "local_network": "ok", + "hotkey": "registered", + "login_item": "enabled" +} +``` + +- `process_started` is the **verbatim output of `ps -o lstart= -p `**, read by the + agent about itself at launch. An ISO-8601 timestamp cannot work here: `ps -o lstart=` + prints a localised, non-ISO string (`Fri Jul 31 14:02:11 2026`, with trailing padding), + so `--doctor` would need brittle date conversion to compare. Storing what `ps` prints + makes the check a trimmed string equality. +- `written_epoch` is integer seconds, so bash compares it with `$(date +%s)` and no parsing. +- The agent rewrites this every **30 s** and on every permission change. +- `--doctor` fails unless the PID is alive, `ps -o lstart= -p ` matches + `process_started` after trimming, and `written_epoch` is within **90 s** of now. +- `hotkey` and `login_item` are in the heartbeat because a fresh status file otherwise + proves only that a process is running with permissions. It would pass with an + unregistered hotkey or a login item macOS has disabled — both of which mean the product + does not work. `--doctor` requires both. +- No agent running is a FAIL, not a skipped check. +- Written atomically, temp file plus rename. Revision 1 would have let `--doctor` read + partial JSON, and today `install-client.sh:204-208` accepts any existing `ok` line, so a + stale file survives a revoked grant and reports PASS. + +Permissions are re-read after wake and on activation, so revocation during a long-running +agent is reflected rather than cached from launch. + +## Release and install contract + +"Downloads the artifact" is not a design. The installer replaces code that holds TCC grants, +so: + +- **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub + release, with the tag as the version. `install-client.sh` selects the newest release + unless pinned. +- **Verification splits across CI and the client**, because they have different tools + available: + - **In CI**, where Xcode exists: `xcrun stapler validate` confirms the notarisation + ticket is actually stapled to the artifact before it is published. This is the only + place `stapler` is used. + - **On the client**, using only stock macOS binaries: `spctl --assess --type execute -vv` + (`/usr/sbin/spctl`) and `codesign --verify --deep --strict -R` (`/usr/bin/codesign`) + against an expected requirement naming the **Team ID and bundle ID**. + + Revision 4 called for `xcrun stapler validate` on the client. That contradicted this + document's own claim that the client no longer needs the Xcode command line tools — + `stapler` ships with Xcode, not with macOS. Since the downloaded artifact is quarantined, + `spctl --assess` exercises the full Gatekeeper path including notarisation, which is the + property that actually matters at install time; stapling is a release-time concern and + belongs where the toolchain already exists. + + `spctl` alone is a Gatekeeper verdict and notarisation alone only proves *someone* signed + it — the requirement string is what proves it was *you*. +- **Replacement**: download to a staging directory, verify, quit any running agent, replace + atomically, then launch and confirm the status heartbeat appears with `hotkey` and + `login_item` both healthy. +- **Rollback**: keep the previous bundle until the new one reports healthy, and on failure + restore *and relaunch* it. Leaving the user with no running client is its own outage. +- Failure at any step leaves the existing install untouched. + +## Testing + +No permissions or microphone needed: config loading, key trimming and rejection, +transport-policy enforcement including the allowlist, the WAV header builder with +back-patched sizes, status-to-error mapping via a stubbed `URLProtocol`, response bounds +(1 MiB body, 8 KiB text, reject-not-truncate), and hostile-response sanitisation. + +Server-side, fix the coverage revision 1 wrongly claimed existed: `audio.py` validates +sample width only, so add assertions and server rejection for channel count and sample rate +alongside bit depth, and update the 400-detail test to the branched causes. + +CI runs `swift build`, `swift test`, the pytest suite, and verifies the signed bundle's +entitlements and designated requirement. + +Not testable in CI, as plainly as the README states its own limits: TCC grants, real hotkey +delivery, real paste into a real window. + +## Migration + +**Nothing outside hark's own files is deleted.** But "report and move on" is insufficient: + +- A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file + changes nothing until it reloads, so both clients bind ⌃⌥Space. + `install-client.sh:608` already documents that it does not auto-reload. +- Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this + issue. Any same-UID process can later rewrite the symlink target and reload it. + +### Staged cutover + +Revisions 2 and 3 contained a deadlock: they required the native agent to be verified +healthy *before* Hammerspoon is quit, while the agent cannot register the hotkey at all +while Hammerspoon owns it — certainly not under `kEventHotKeyExclusive`. Health-checking a +hotkey the old client still holds is not possible, so registration is split out of the +health check and happens after the handover: + +0. **Preserve a rollback target first.** Copy the *current, working* `client/init.lua` to + `~/.config/hark/legacy-client.lua` and record ownership in + `~/.config/hark/legacy-client.json`: that hark installed the symlink, its original + target path, and a checksum. + + Both files are load-bearing and revision 4 had neither. `client/init.lua` becomes a + no-op stub in this same change, so after the upgrade the symlink's target *is* the stub + — "restore the symlink" would restore a client that does nothing. Rollback repoints at + the preserved copy instead. + + The ownership record exists because content-matching the symlink target cannot survive + the repo moving: an absolute symlink (`install-client.sh:595`) to a moved checkout is + dangling, so there is no content left to match. A durable record in `~/.config/hark` + answers "did hark install this" without depending on the target existing. +1. **Detect a hark-era install**, in this order: the ownership record from step 0 if + present; otherwise a symlink whose target carries the hark marker comment; otherwise a + *dangling* symlink whose recorded path matches a known hark layout. A real file, or a + symlink to something without the marker and without a record, means the user runs + Hammerspoon independently — do not quit it, do not offer to revoke anything, and ask + before proceeding. +2. **Install and health-check the agent with the hotkey disabled**, and with login + registration **not yet performed**. Everything else is verified: bundle signature, + launch, permissions, status heartbeat, server reachability, and key authentication. +3. **Quit Hammerspoon** and wait for process exit. Chord ownership cannot be queried — + macOS exposes no way to ask WindowServer who owns a hotkey — so confirmed process exit + is the strongest available signal. Revision 2's "confirms it no longer owns the chord" + overstated what is possible. +4. **Tell the running agent to register the hotkey**, verify it succeeded, and only then + call `SMAppService.mainApp.register()`. +5. **On failure at step 4, roll back in this order**: stop the agent, ensure + `SMAppService.mainApp` is **not** registered (unregister if step 4 got that far), point + `~/.hammerspoon/init.lua` at `~/.config/hark/legacy-client.lua`, relaunch Hammerspoon, + confirm it is running, then report. The user ends with a working client either way. + + Deregistration is the part revision 4 missed: it left the agent "installed but inactive" + while registration ran unconditionally at first launch, so the agent would start at the + next login and fight the Hammerspoon that had just been restored. Registration is + therefore deliberately the *last* step of a successful cutover, not part of launch. +6. **Only on success**, remove the symlink hark created, and offer to revoke **all three** + grants — `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for + `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:266-268` + told Fn-key users to grant it. +7. If the user declines revocation, say plainly that the original exposure remains. The + install proceeds; it is not silently reported as closed. +8. `~/.config/hark/legacy-client.lua` is kept, not deleted, so a later manual rollback is + still possible. It is inert once the symlink is gone. + +Removing `client/rec.swift` while `install-client.sh:426` still compiles it would brick the +installer mid-run, so that ordering holds too. + +`client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. + +## Blast radius + +Line numbers are against `8d12f7b`. In scope for the implementation, not follow-ups: + +- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build (`:419`), + the launch block (`:613`), and every `--doctor` check: `check_hammerspoon_installed` + (`:86`), the Lua-config key grep (`:127`), the recorder check (`:173`), the mic-status + read (`:196`), the Hammerspoon TCC query (`:679`), and the probe wait (`:742`). +- **`src/hark/app.py`** (`:103-107`) and **`tests/test_app.py`** (`:107`) — branch the 400 + detail by cause. +- **`src/hark/audio.py`** and **`tests/test_audio.py`** — enforce and assert channel count + and sample rate, not just sample width (`:45`). +- **`README.md`** — architecture (`:16`), install step 1 (`:138`), the `--doctor` list + (`:175`), "Changing the hotkey" (`:250`), two-machine setup, repo layout (`:387`). +- **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and + `client/hark-config.example.lua`, replaced by a `client.json` example. +- **`docs/superpowers/specs/2026-07-14-hark-open-source-design.md:7`** — "(architecture + unchanged)", which this design invalidates. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, + pending implementation plan". + +**Already done upstream, and deliberately not repeated here.** `ef47aeb` struck the two +non-goals this design contradicts — "CI, release automation, versioning, changelogs" +(`:219`) and "Replacing Hammerspoon with a native Swift menubar app" (`:228`) — and added a +"Superseded in part" header at `:211`. `c852392` cleared the stale `setup.sh` references +(#8's sibling work). Only `:7` above remains. + +## Non-goals + +- The Fn/🌐 key. Revisit only if Phase 0 selects a tap. +- Pre-warming the audio engine. In-process capture makes it possible; the README's privacy + trade-off is deliberate and stands. +- Streaming transcription and auto-submit, per the 2026-07-14 spec. +- TLS on the hark server. The `insecure_transport_hosts` allowlist is the interim answer. +- Keychain-pinned server origin. Follow-up, recorded above. + +## Clipboard retention + +Two reviewers asked for this to be decided rather than left open, and they are right: it is +a privacy policy, not a preference. + +**The transcript self-clears after 90 seconds, but only if `NSPasteboard.changeCount` is +unchanged since hark wrote it.** + +The existing behaviour — set and never restore — is deliberate and stays: a misfired paste +stays recoverable with a manual ⌘V instead of re-speaking. See the comment above +`hs.pasteboard.setContents` in `client/init.lua`. But "never restore" currently means +*indefinitely*, and any local process can poll `NSPasteboard` and harvest every transcript +without holding a single TCC grant. That is a real exposure the design was silently +inheriting. + +90 seconds keeps the recovery window that motivated the original decision while bounding +the exposure. The `changeCount` guard means hark only ever clears its own value — if you +copied something else in the meantime, hark does nothing. + +The README should state this as a privacy property, not only as paste-recovery ergonomics. + +## Open questions + +- Phase 0's two answers, which decide the hotkey mechanism. diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000..a6b03e2 --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Hark", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "hark", targets: ["hark"]) + ], + targets: [ + // Pure / server-logic core: config, key, sanitize, wav, server, whisper. + .target(name: "HarkCore"), + // macOS agent: hotkey, recorder, dictate client, controller. Kept out + // of HarkCore so tests run headless in CI (no TCC, no hardware). + .executableTarget(name: "hark", dependencies: ["HarkCore"]), + .testTarget(name: "HarkCoreTests", dependencies: ["HarkCore"]), + ] +) diff --git a/swift/Packaging/Info.plist b/swift/Packaging/Info.plist new file mode 100644 index 0000000..ebf276b --- /dev/null +++ b/swift/Packaging/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleIdentifier + com.drycodeworks.hark-agent + CFBundleName + Hark + CFBundleExecutable + hark + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSMicrophoneUsageDescription + 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 + + + + diff --git a/swift/Packaging/Makefile b/swift/Packaging/Makefile new file mode 100644 index 0000000..49612c8 --- /dev/null +++ b/swift/Packaging/Makefile @@ -0,0 +1,34 @@ +# Makefile for Hark — build, package, sign, verify, clean, test. +# Rules use tabs (this file is written with tab-indented recipe lines). + +PKG_DIR := Packaging +APP := $(PKG_DIR)/Hark.app + +.PHONY: build app sign verify clean test + +## build: compile the release binary +build: + swift build -c release + +## app: run the packaging script (build release + assemble + sign Hark.app) +app: + ./$(PKG_DIR)/build-app.sh + +## sign: re-sign the assembled bundle (ad-hoc unless HARK_SIGN_IDENTITY is set) +sign: + codesign --force --deep --sign "$${HARK_SIGN_IDENTITY:--}" \ + --options runtime \ + --entitlements $(PKG_DIR)/entitlements.plist \ + $(APP) + +## verify: codesign verification of the bundle +verify: + codesign --verify --deep --strict --verbose=2 $(APP) + +## clean: remove build products and the assembled bundle +clean: + rm -rf .build $(APP) + +## test: run the test suite +test: + swift test diff --git a/swift/Packaging/build-app.sh b/swift/Packaging/build-app.sh new file mode 100755 index 0000000..c1a49c7 --- /dev/null +++ b/swift/Packaging/build-app.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# build-app.sh — build the release binary and assemble a signed Hark.app bundle. +# +# ./Packaging/build-app.sh +# +# Signing identity comes from $HARK_SIGN_IDENTITY if set (e.g. a Developer ID), +# otherwise ad-hoc ("-") is used. Default is ad-hoc — fine for local dev. +set -euo pipefail + +PKG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${PKG_DIR}/.." && pwd)" +APP="${PKG_DIR}/Hark.app" + +SIGN_IDENTITY="${HARK_SIGN_IDENTITY:-}" + +echo "==> Building release binary" +cd "${ROOT_DIR}" +swift build -c release + +echo "==> Assembling ${APP}" +rm -rf "${APP}" +mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources" + +cp ".build/release/hark" "${APP}/Contents/MacOS/hark" +cp "${PKG_DIR}/Info.plist" "${APP}/Contents/Info.plist" +cp "${PKG_DIR}/entitlements.plist" "${APP}/Contents/Resources/" +chmod +x "${APP}/Contents/MacOS/hark" + +echo "==> Signing" +if [[ -n "${SIGN_IDENTITY}" ]]; then + echo " using identity: ${SIGN_IDENTITY}" + codesign --force --deep --sign "${SIGN_IDENTITY}" \ + --options runtime \ + --entitlements "${PKG_DIR}/entitlements.plist" \ + "${APP}" +else + echo " using ad-hoc signature (-)" + codesign --force --deep --sign - \ + --options runtime \ + --entitlements "${PKG_DIR}/entitlements.plist" \ + "${APP}" +fi + +echo "==> Verifying" +codesign --verify --deep --strict --verbose=2 "${APP}" +echo "==> Entitlements embedded in binary:" +codesign -d --entitlements :- "${APP}" 2>/dev/null || true +echo "==> done. Bundle at: ${APP}" diff --git a/swift/Packaging/entitlements.plist b/swift/Packaging/entitlements.plist new file mode 100644 index 0000000..bd7a79c --- /dev/null +++ b/swift/Packaging/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.device.audio-input + + com.apple.security.automation.apple-events + + com.apple.security.cs.allow-jit + + + diff --git a/swift/Sources/HarkCore/Config.swift b/swift/Sources/HarkCore/Config.swift new file mode 100644 index 0000000..6606540 --- /dev/null +++ b/swift/Sources/HarkCore/Config.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Server deployment configuration — a port of `src/hark/config.py`. +/// +/// Defaults describe the single-machine setup: bind to loopback, expose +/// nothing. `~/.config/hark/config.toml` (outside the repo) overrides, exactly +/// as it does for the Python server. A missing file is the ordinary case; a +/// malformed one raises rather than silently falling back to defaults (that +/// could bind the service somewhere the user did not ask for). +public struct HarkConfig { + public var bindHost: String // server.bind, default 127.0.0.1 + public var harkPort: Int // server.port, default 8911 + public var whisperHost: String // always 127.0.0.1 (not configurable) + public var whisperPort: Int // whisper.port, default 8910 + public var modelPath: String // whisper.model + public var vocabPrompt: String // whisper.prompt + public var silenceRMSThreshold: Double // audio.silence_rms_threshold, default 150.0 + public var transcribeTimeout: Double // 60 + public var connectTimeout: Double // 5 + + public init(bindHost: String = "127.0.0.1", + harkPort: Int = 8911, + whisperPort: Int = 8910, + modelPath: String = "~/.local/share/whisper-cpp/ggml-large-v3-turbo.bin", + vocabPrompt: String = "", + silenceRMSThreshold: Double = 150.0, + transcribeTimeout: Double = 60.0, + connectTimeout: Double = 5.0) { + self.bindHost = bindHost + self.harkPort = harkPort + self.whisperHost = "127.0.0.1" + self.whisperPort = whisperPort + self.modelPath = modelPath + self.vocabPrompt = vocabPrompt + self.silenceRMSThreshold = silenceRMSThreshold + self.transcribeTimeout = transcribeTimeout + self.connectTimeout = connectTimeout + } + + public static var configFile: URL { + if let env = ProcessInfo.processInfo.environment["HARK_CONFIG"] { + return URL(fileURLWithPath: env) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/hark/config.toml") + } + + public static func load() -> HarkConfig { + var cfg = HarkConfig() + guard let text = try? String(contentsOf: configFile, encoding: .utf8) else { + return cfg + } + do { + let parsed = try MiniTOML.parse(text) + if let s = parsed.section("server") { + cfg.bindHost = s.string("bind") ?? cfg.bindHost + cfg.harkPort = s.int("port") ?? cfg.harkPort + } + if let w = parsed.section("whisper") { + cfg.whisperPort = w.int("port") ?? cfg.whisperPort + cfg.modelPath = w.string("model") ?? cfg.modelPath + cfg.vocabPrompt = w.string("prompt") ?? cfg.vocabPrompt + } + if let a = parsed.section("audio") { + cfg.silenceRMSThreshold = a.double("silence_rms_threshold") ?? cfg.silenceRMSThreshold + } + } catch { + fatalError("malformed hark config at \(configFile.path): \(error)") + } + return cfg + } + + public var whisperURL: String { "http://\(whisperHost):\(whisperPort)" } +} + +// MARK: - Minimal TOML subset parser + +/// Parses just the shape `config.example.toml` uses: `[section]` headers, +/// `key = value` pairs (string / integer / float / boolean), `#` comments. +/// Unknown keys and sections are ignored so a config written for the Python +/// server keeps working. Values that do not parse raise, never silently drop. +struct MiniTOML { + struct Section { + let name: String + var values: [String: String] = [:] + func string(_ key: String) -> String? { values[key] } + func int(_ key: String) -> Int? { + guard let v = values[key], let i = Int(v) else { return nil } + return i + } + func double(_ key: String) -> Double? { + guard let v = values[key] else { return nil } + if let i = Int(v) { return Double(i) } + return Double(v) + } + } + var sections: [String: Section] = [:] + + func section(_ name: String) -> Section? { sections[name] } + + static func parse(_ text: String) throws -> MiniTOML { + var result = MiniTOML() + var current: String = "" + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + var line = String(rawLine) + if let hash = line.firstIndex(of: "#") { line = String(line[..= 2 { + value = String(value.dropFirst().dropLast()) + } + result.sections[current, default: Section(name: current)].values[key] = value + } + return result + } +} + +enum TOMLError: Error, CustomStringConvertible { + case malformed(String) + var description: String { "malformed TOML line: \(self)" } +} diff --git a/swift/Sources/HarkCore/DictateClient.swift b/swift/Sources/HarkCore/DictateClient.swift new file mode 100644 index 0000000..13f6bf3 --- /dev/null +++ b/swift/Sources/HarkCore/DictateClient.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Agent-side dictation client: POST a WAV to the server's /dictate and get +/// back sanitised text. Ports the response handling of `client/init.lua` and +/// the bounds/transport rules from the native-client design. +/// +/// Every response path is bounded and sanitised, not just the 200. The error +/// `detail` string is just as attacker-controlled as a transcript — it reaches +/// an alert rather than the pasteboard, but it must still be capped and cleaned. +public enum DictateError: Error, Equatable { + case transport(String) // connection / DNS / timeout + case unauthorized(String) // 401 + case unsupportedMediaType(String) // 415 + case badRequest(String) // 400 + case serviceUnavailable(String) // 503 + case unexpected(Int, String) // any other status + case malformedResponse + case bodyTooLarge + + public var isTransport: Bool { + if case .transport = self { return true } + return false + } +} + +public enum DictateOutcome: Equatable { + case pasted(String) // non-empty sanitised text + case nothing // 200 with empty / no-alphanumeric text + case failed(DictateError) +} + +public final class DictateClient: NSObject, URLSessionDataDelegate { + private let url: URL + private let key: String + private let maxBodyBytes: Int + private var session: URLSession! // set in init after super.init() (needs self as delegate) + + // Per-request state. + private var accumulated = Data() + private var response: HTTPURLResponse? + private var completion: ((Result<(Int, Data), DictateError>) -> Void)? + private var large = false + + public init(url: URL, key: String, maxBodyBytes: Int = 1 << 20, + configuration: URLSessionConfiguration? = nil) { + self.url = url + self.key = key + self.maxBodyBytes = maxBodyBytes + let cfg: URLSessionConfiguration + if let configuration { + cfg = configuration + } else { + cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 30 + cfg.timeoutIntervalForResource = 30 + } + super.init() + // NOTE: must pass `delegate: self` here — URLSession(configuration:) + // alone does NOT deliver delegate callbacks, which would leave the + // semaphore wait in upload() blocked forever. + self.session = URLSession(configuration: cfg, delegate: self, delegateQueue: nil) + self.session.sessionDescription = "hark-dictate" + } + + /// Result of a dictation attempt. + public func dictate(wav: Data) -> DictateOutcome { + switch upload(wav: wav) { + case .failure(let e): + return .failed(e) + case .success(let pair): + let (http, body) = pair + return classify(http: http, body: body) + } + } + + // MARK: - Upload with a hard body bound + + private func upload(wav: Data) -> Result<(Int, Data), DictateError> { + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.httpBody = wav + req.setValue(key, forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + + // Synchronous bridge: the data task runs on URLSession's own queues, so + // blocking this caller on a semaphore is safe for the single-request + // concurrency this client is used for. + let sem = DispatchSemaphore(value: 0) + var result: Result<(Int, Data), DictateError> = .failure(.transport("no result")) + accumulated = Data() + response = nil + large = false + completion = { res in + result = res + sem.signal() + } + session.dataTask(with: req).resume() + sem.wait() + completion = nil + return result + } + + // MARK: - URLSessionDataDelegate + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) { + accumulated.append(data) + if accumulated.count > maxBodyBytes { + large = true + dataTask.cancel() + } + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + self.response = response as? HTTPURLResponse + completionHandler(.allow) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let completion else { return } + self.completion = nil + if large { + completion(.failure(.bodyTooLarge)) + return + } + if let error { + completion(.failure(.transport(error.localizedDescription))) + return + } + guard let resp = self.response else { + completion(.failure(.transport("no HTTP response"))) + return + } + completion(.success((resp.statusCode, accumulated))) + } + + // MARK: - Response classification + + private func classify(http: Int, body: Data) -> DictateOutcome { + if http == 200 { + guard let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let text = obj["text"] as? String else { + return .failed(.malformedResponse) + } + let sanitised = Sanitize.sanitize(text) + if sanitised.isEmpty { return .nothing } + return .pasted(sanitised) + } + + let detail = extractDetail(body) + switch http { + case 401: return .failed(.unauthorized(detail)) + case 415: return .failed(.unsupportedMediaType(detail)) + case 400: return .failed(.badRequest(detail)) + case 503: return .failed(.serviceUnavailable(detail)) + default: return .failed(.unexpected(http, detail)) + } + } + + private func extractDetail(_ body: Data) -> String { + let raw = String(decoding: body, as: UTF8.self) + guard let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let detail = obj["detail"] as? String else { + return raw + } + return Sanitize.sanitize(detail) + } +} diff --git a/swift/Sources/HarkCore/HarkServer.swift b/swift/Sources/HarkCore/HarkServer.swift new file mode 100644 index 0000000..f9b2d52 --- /dev/null +++ b/swift/Sources/HarkCore/HarkServer.swift @@ -0,0 +1,359 @@ +import Foundation +import Network + +/// The hark service: audio in, transcript out. A stdlib HTTP/1.1 server on the +/// loopback (or a configured private address), replacing FastAPI + uvicorn in +/// `src/hark/app.py` (issue #4). One user, one request at a time — async buys +/// nothing here, so the server is deliberately simple. +/// +/// Deliberately contains no business logic — sanitisation and ASR live in +/// `Sanitize` and `WhisperClient`, imported by name so tests can fake them. +/// The server does not inject the transcript anywhere; it returns it in the +/// response and the client pastes it at the cursor. +public enum HarkServerError: Error, CustomStringConvertible { + case bindFailed(String) + public var description: String { + switch self { + case .bindFailed(let m): return m + } + } +} + +public struct HTTPRequest { + public let method: String + public let target: String + public let headers: [String: String] // lower-cased keys + public let body: Data +} + +public struct HTTPResponse { + public let status: Int + public let contentType: String + public let body: Data + + public var serialized: Data { + let reason = Self.reason(for: status) + var head = "HTTP/1.1 \(status) \(reason)\r\n" + head += "Content-Type: \(contentType)\r\n" + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n" + head += "\r\n" + var out = Data(head.utf8) + out.append(body) + return out + } + + static func reason(for status: Int) -> String { + switch status { + case 200: return "OK" + case 400: return "Bad Request" + case 401: return "Unauthorized" + case 415: return "Unsupported Media Type" + case 500: return "Internal Server Error" + case 503: return "Service Unavailable" + default: return "Status" + } + } +} + +public protocol WhisperTranscribing { + func transcribe(wav: Data) async throws -> String +} + +extension WhisperClient: WhisperTranscribing {} + +public final class HarkServer { + let config: HarkConfig + let key: String + let whisper: any WhisperTranscribing + let logger: Logger + + public init(config: HarkConfig = .load(), + key: String = KeyFile.ensure(), + whisper: (any WhisperTranscribing)? = nil, + logger: Logger = Logger(label: "hark")) { + self.config = config + self.key = key + self.whisper = whisper ?? WhisperClient(baseURL: config.whisperURL, + connectTimeout: config.connectTimeout, + transcribeTimeout: config.transcribeTimeout) + self.logger = logger + } + + /// Bind and start serving on a port (default: `config.harkPort`; pass a + /// port to override, e.g. for tests). Non-blocking — returns the live + /// listener so callers (or tests) can cancel it. The connection handler is + /// installed immediately; `serve()` just adds the blocking runloop. + @discardableResult + public func start(port: UInt16? = nil) throws -> NWListener { + let portNumber = port ?? UInt16(config.harkPort) + guard let p = NWEndpoint.Port(rawValue: portNumber) else { + throw HarkServerError.bindFailed("invalid port \(portNumber)") + } + let listener = try NWListener(using: .tcp, on: p) + listener.newConnectionHandler = { [weak self] conn in + self?.handle(conn) + } + listener.stateUpdateHandler = { [logger] state in + if case .failed(let e) = state { logger.error("listener failed: \(e)") } + } + listener.start(queue: .global(qos: .userInitiated)) + logger.info("hark listening on \(config.bindHost):\(portNumber)") + return listener + } + + /// Run until the process is terminated. + public func serve() throws { + _ = try start() + dispatchMain() + } + + // MARK: - Connection handling + + private func handle(_ conn: NWConnection) { + conn.stateUpdateHandler = { [weak self, weak conn] state in + guard let self, let conn else { return } + if case .ready = state { + self.receiveLoop(conn, buffer: Data()) + } + } + conn.start(queue: .global(qos: .default)) + } + + private func receiveLoop(_ conn: NWConnection, buffer: Data) { + conn.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { [weak self, weak conn] data, _, isComplete, error in + guard let conn else { return } + var buf = buffer + if let data, !data.isEmpty { buf.append(data) } + + if error != nil || isComplete { + conn.cancel() + return + } + + guard let self else { return } + // Try to complete a request with whatever we have. + do { + let (request, consumed) = try HTTPParser.parseComplete(from: buf) + let response = self.dispatch(request) + conn.send(content: response.serialized, completion: .contentProcessed { _ in + conn.cancel() + }) + _ = consumed + } catch HTTPParseError.incomplete { + // Need more data. + self.receiveLoop(conn, buffer: buf) + } catch { + // Unparseable request: 400. + let resp = HTTPResponse(status: 400, contentType: "application/json", + body: Data("{\"detail\":\"bad request\"}".utf8)) + conn.send(content: resp.serialized, completion: .contentProcessed { _ in + conn.cancel() + }) + } + } + } + + // MARK: - Routing + + func dispatch(_ request: HTTPRequest) -> HTTPResponse { + switch (request.method, request.target) { + case ("GET", "/health"): + return HTTPResponse(status: 200, contentType: "application/json", + body: Data("{\"status\":\"ok\"}".utf8)) + case ("POST", "/dictate"): + return dictate(request) + default: + return jsonError(404, "not found") + } + } + + private func dictate(_ request: HTTPRequest) -> HTTPResponse { + // Authorisation: both checks matter, and each independently defeats the + // drive-by CSRF (a CORS-simple request needs no preflight). X-Hark-Key + // and Content-Type audio/wav are both non-safelisted, so requiring them + // forces a preflight, which fails — no CORS middleware is installed. + let presented = request.headers["x-hark-key"] ?? "" + guard ConstantTime.equal(presented, key) else { + logger.warning("rejected unauthenticated POST /dictate") + return jsonError(401, "missing or invalid X-Hark-Key") + } + + // Ignore parameters: `audio/wav; charset=binary` is still audio/wav. + let mediaType = (request.headers["content-type"] ?? "") + .split(separator: ";").first? + .trimmingCharacters(in: .whitespaces) + .lowercased() ?? "" + guard mediaType == "audio/wav" else { + logger.warning("rejected POST /dictate with content-type \(mediaType)") + return jsonError(415, "expected Content-Type: audio/wav") + } + + // Parse and validate the wire format: 16 kHz mono 16-bit PCM. + let info: WAVInfo + do { + info = try WAV.parse(request.body) + } catch let e as WAVError { + logger.warning("rejected audio: \(e.description)") + return jsonError(400, formatDetail(for: e)) + } catch { + return jsonError(400, formatDetail(for: WAVError.notReadable("\(error)"))) + } + guard info.sampleWidth == 2 else { + return jsonError(400, formatDetail(for: .unsupportedSampleWidth(info.sampleWidth))) + } + guard info.channelCount == 1 else { + return jsonError(400, "expected mono audio, got \(info.channelCount) channels") + } + guard info.sampleRate == 16000 else { + return jsonError(400, "expected 16 kHz audio, got \(info.sampleRate) Hz") + } + + // Whisper hallucinates on silence, so it must be caught on the AUDIO. + let amplitude = WAV.rmsPCM(info.data) + if amplitude < config.silenceRMSThreshold { + logger.info("silent audio (rms \(String(format: "%.1f", amplitude)) < \(String(format: "%.1f", config.silenceRMSThreshold))); returning empty transcript") + return jsonText("") + } + + let raw = sync { + try await self.whisper.transcribe(wav: request.body) + } + let text: String + do { + text = try raw.get() + } catch { + logger.error("whisper-server unavailable: \(error)") + return jsonError(503, "\(error)") + } + + let sanitised = Sanitize.sanitize(text) + if !hasAlphanumeric(sanitised) { + logger.info("transcript has no alphanumerics; returning empty transcript") + return jsonText("") + } + + // Log the LENGTH only, never the text — transcripts are private and + // must never settle into a world-readable file in /tmp. + logger.info("transcribed \(sanitised.count) chars (rms \(String(format: "%.1f", amplitude)), threshold \(String(format: "%.1f", config.silenceRMSThreshold)))") + return jsonText(sanitised) + } + + /// The server's error detail must be branched by cause so a malformed + /// header from the client does not send users to Microphone settings. + private func formatDetail(for e: WAVError) -> String { + switch e { + case .emptyBody: + return e.description + ". Check that the client has microphone permission and is sending 16 kHz mono 16-bit PCM WAV." + default: + return e.description + ". Expected 16 kHz mono 16-bit PCM WAV." + } + } + + private func hasAlphanumeric(_ s: String) -> Bool { + s.unicodeScalars.contains { CharacterSet.alphanumerics.contains($0) } + } + + private func jsonText(_ text: String) -> HTTPResponse { + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let body = "{\"text\":\"\(escaped)\"}" + return HTTPResponse(status: 200, contentType: "application/json", body: Data(body.utf8)) + } + + private func jsonError(_ status: Int, _ detail: String) -> HTTPResponse { + let escaped = detail + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let body = "{\"detail\":\"\(escaped)\"}" + return HTTPResponse(status: status, contentType: "application/json", body: Data(body.utf8)) + } + + /// Bridge an async call into the sync connection handler. The URLSession + /// work runs on its own queues so blocking this worker thread is fine for + /// the single-user concurrency this server is built for. + private func sync(_ body: @escaping () async throws -> T) -> Result { + let sem = DispatchSemaphore(value: 0) + var result: Result = .failure(HarkServerError.bindFailed("unreached")) + Task { + do { result = .success(try await body()) } + catch { result = .failure(error) } + sem.signal() + } + sem.wait() + return result + } +} + +// MARK: - Logging shim (kept dependency-free) + +public struct Logger { + let label: String + func timestamp() -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f.string(from: Date()) + } + public init(label: String) { self.label = label } + func write(_ level: String, _ msg: String) { + let line = "\(timestamp()) \(level) \(label): \(msg)\n" + FileHandle.standardOutput.write(Data(line.utf8)) + } + func info(_ msg: String) { write("INFO", msg) } + func warning(_ msg: String) { write("WARNING", msg) } + func error(_ msg: String) { write("ERROR", msg) } +} + +enum ConstantTime { + static func equal(_ a: String, _ b: String) -> Bool { + let aa = Array(a.utf8), bb = Array(b.utf8) + guard aa.count == bb.count else { return false } + var diff: UInt8 = 0 + for i in 0.. (HTTPRequest, Int) { + guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { + throw HTTPParseError.incomplete + } + let headerData = data.subdata(in: data.startIndex..= 2 else { throw HTTPParseError.malformed } + let method = String(parts[0]) + let target = String(parts[1]) + + var headers: [String: String] = [:] + for line in lines { + guard let colon = line.firstIndex(of: ":") else { continue } + let k = line[..= contentLength else { throw HTTPParseError.incomplete } + + let body = data.subdata(in: bodyStart..<(bodyStart + contentLength)) + return (HTTPRequest(method: method, target: target, headers: headers, body: body), + bodyStart + contentLength) + } +} diff --git a/swift/Sources/HarkCore/KeyFile.swift b/swift/Sources/HarkCore/KeyFile.swift new file mode 100644 index 0000000..484def6 --- /dev/null +++ b/swift/Sources/HarkCore/KeyFile.swift @@ -0,0 +1,67 @@ +import Foundation + +/// The shared secret gating POST /dictate. Lives at `~/.config/hark/key`, +/// mode 600, generated on first server use. Read and trimmed by the client. +/// +/// Deliberately not a credential system: one user, one key, one file. On the +/// server the key is generated on first use and persisted so the client can be +/// configured once by reading the file. The file lives outside the repo and is +/// never committed. +public enum KeyFile { + /// Test-only override so tests don't touch the real `~/.config/hark/key`. + public static var pathOverride: URL? + + public static var path: URL { + if let override = pathOverride { + return override + } + if let env = ProcessInfo.processInfo.environment["HARK_KEY_FILE"] { + return URL(fileURLWithPath: env) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/hark/key") + } + + /// Read and trim the key, or nil if absent/unreadable. A key from the + /// environment wins over the file, mirroring `config.py`'s `HARK_KEY`. + public static func load() -> String? { + if let env = ProcessInfo.processInfo.environment["HARK_KEY"] { + let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + guard let data = try? Data(contentsOf: path) else { return nil } + let trimmed = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Generate and persist a key if absent. Directory mode 700, file mode 600 + /// with an exclusive create (a concurrent request cannot clobber ours). + /// Returns the key. + @discardableResult + public static func ensure() -> String { + if let existing = load() { return existing } + let dir = path.deletingLastPathComponent() + try? FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let key = generateKey() + let created = FileManager.default.createFile( + atPath: path.path, contents: Data((key + "\n").utf8), + attributes: [.posixPermissions: 0o600]) + if created { return key } + // Lost the race to a concurrent writer; use its key, not ours. + return load() ?? key + } + + static func generateKey() -> String { + // 32 bytes of urandom → URL-safe base64 (≈ 43 chars), like Python's + // secrets.token_urlsafe(32). + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/swift/Sources/HarkCore/Sanitize.swift b/swift/Sources/HarkCore/Sanitize.swift new file mode 100644 index 0000000..b3ecc69 --- /dev/null +++ b/swift/Sources/HarkCore/Sanitize.swift @@ -0,0 +1,45 @@ +/// Transcript sanitisation — a faithful port of `src/hark/sanitize.py`. +/// +/// Dictation never wants a literal newline. Collapsing whitespace is what +/// makes it structurally impossible for a transcript to submit a prompt +/// early, rather than relying on bracketed paste to save us. +/// +/// Control characters (C0 0x00-0x08,0x0B,0x0C,0x0E-0x1F, 0x7F and C1 +/// 0x80-0x9F, which includes the 8-bit single-byte equivalents of ESC-prefixed +/// sequences like CSI/OSC/DCS) are replaced with a space rather than deleted, +/// so a stray control character cannot smuggle an escape sequence into the +/// receiving application. Substituting instead of deleting also means two +/// words separated only by a control character become two space-separated +/// words after collapsing, not one fused word. +public enum Sanitize { + /// Roughly Python's `[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]`. + static func isControl(_ s: Unicode.Scalar) -> Bool { + let v = s.value + if v <= 0x08 { return true } + if v == 0x0B || v == 0x0C { return true } + if (0x0E...0x1F).contains(v) { return true } + if (0x7F...0x9F).contains(v) { return true } + return false + } + + /// Unicode line/paragraph separators, also hostile to a terminal. + static func isLineSeparator(_ s: Unicode.Scalar) -> Bool { + s.value == 0x2028 || s.value == 0x2029 + } + + /// Replace control characters and Unicode line separators with a space, + /// collapse all remaining whitespace runs to a single space, and trim. + public static func sanitize(_ raw: String) -> String { + var out = String.UnicodeScalarView() + for s in raw.unicodeScalars { + if isControl(s) || isLineSeparator(s) { + out.append(" ") + } else { + out.append(s) + } + } + return String(out) + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + } +} diff --git a/swift/Sources/HarkCore/WAV.swift b/swift/Sources/HarkCore/WAV.swift new file mode 100644 index 0000000..b2ed0f9 --- /dev/null +++ b/swift/Sources/HarkCore/WAV.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Audio energy measurement + WAV parsing — a port of `src/hark/audio.py`. +/// +/// Whisper hallucinates on silence (digital silence -- " Thank you.", faint +/// noise -- " ."), so the gate has to sit on the AUDIO, before transcription. +/// The server measures RMS and refuses to send quiet audio to whisper at all. +/// +/// The documented wire format is **16 kHz, mono, 16-bit signed PCM, one `data` +/// chunk**. The threshold in Config is calibrated on the signed-16-bit scale, +/// so any other sample width would be silently mis-scaled against it — refuse +/// loudly instead. +public enum WAVError: Error, Equatable { + case emptyBody + case notReadable(String) + case unsupportedSampleWidth(Int) + + public var description: String { + switch self { + case .emptyBody: + return "empty audio body - the microphone produced no samples" + case .notReadable(let e): + return "not a readable WAV: \(e)" + case .unsupportedSampleWidth(let w): + return "expected 16-bit PCM samples, got \(w * 8)-bit" + } + } +} + +public struct WAVInfo: Equatable { + public let sampleWidth: Int + public let channelCount: Int + public let sampleRate: Int + public let data: Data + public init(sampleWidth: Int, channelCount: Int, sampleRate: Int, data: Data) { + self.sampleWidth = sampleWidth + self.channelCount = channelCount + self.sampleRate = sampleRate + self.data = data + } +} + +public enum WAV { + static let supportedSampleWidth = 2 + static let supportedChannelCount = 1 + static let supportedSampleRate = 16000 + + public static func parse(_ d: Data) throws -> WAVInfo { + guard !d.isEmpty else { throw WAVError.emptyBody } + guard d.count >= 44 else { throw WAVError.notReadable("header too short") } + guard ascii(d, 0) == "RIFF", ascii(d, 8) == "WAVE" else { + throw WAVError.notReadable("missing RIFF/WAVE markers") + } + + var sampleWidth = 0, channelCount = 0, sampleRate = 0 + var foundFormat = false + var foundData: Data? + var offset = 12 + while offset + 8 <= d.count { + let chunkID = ascii(d, offset) + let size = UInt64(leUInt32(d, offset + 4)) + let chunkStart = offset + 8 + guard Int64(chunkStart) + Int64(size) <= Int64(d.count), size <= Int(INT32_MAX) else { + throw WAVError.notReadable("truncated or oversized chunk '\(chunkID)'") + } + if chunkID == "fmt " { + guard size >= 16 else { throw WAVError.notReadable("fmt chunk too small") } + let format = leUInt16(d, chunkStart) + guard format == 1 else { throw WAVError.notReadable("not PCM (audio format \(format))") } + channelCount = Int(leUInt16(d, chunkStart + 2)) + sampleRate = Int(leUInt32(d, chunkStart + 4)) + // bitsPerSample → bytes (16 → 2), matching Python's getsampwidth(). + sampleWidth = Int(leUInt16(d, chunkStart + 14)) / 8 + foundFormat = true + } else if chunkID == "data" { + foundData = d.subdata(in: chunkStart..<(chunkStart + Int(size))) + } + // Chunks are word-aligned (padded to even length). + offset = chunkStart + Int(size) + (Int(size) % 2) + } + guard foundFormat else { throw WAVError.notReadable("missing fmt chunk") } + guard let foundData else { throw WAVError.notReadable("missing data chunk") } + return WAVInfo(sampleWidth: sampleWidth, channelCount: channelCount, + sampleRate: sampleRate, data: foundData) + } + + /// RMS of raw signed-16-bit little-endian PCM samples. 0.0 for digital + /// silence; roughly 3000-5000 for normal speech. + public static func rmsPCM(_ pcm: Data) -> Double { + let bytes = [UInt8](pcm) + let usable = bytes.count - (bytes.count % 2) + guard usable > 0 else { return 0 } + var sum: Double = 0 + var count = 0 + var i = 0 + while i + 1 < usable { + let low = UInt16(bytes[i]) + let high = UInt16(bytes[i + 1]) + let u: UInt16 = low | (high << 8) + let s = Int16(bitPattern: u) // already little-endian on the wire + let v = Int32(s) + sum += Double(v) * Double(v) + count += 1 + i += 2 + } + guard count > 0 else { return 0 } + return (sum / Double(count)).squareRoot() + } + + /// Root-mean-square of a WAV's PCM samples. 0.0 for digital silence. + public static func rms(_ d: Data) throws -> Double { + let info = try parse(d) + guard info.sampleWidth == supportedSampleWidth else { + throw WAVError.unsupportedSampleWidth(info.sampleWidth) + } + return rmsPCM(info.data) + } + + /// Build a 44-byte canonical WAV header for 16 kHz mono 16-bit PCM, with + /// data- and RIFF-size placeholders the caller back-patches. + public static func header16kMono16Bit(dataByteCount: Int) -> Data { + var out = Data(capacity: 44) + func appendASCII(_ s: String) { out.append(Data(s.utf8)) } + func appendLE16(_ v: Int) { out.append(UInt8(v & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) } + func appendLE32(_ v: Int) { + out.append(UInt8((v) & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) + out.append(UInt8((v >> 16) & 0xFF)); out.append(UInt8((v >> 24) & 0xFF)) + } + let blockAlign = 2 // mono s16 + let byteRate = 16000 * blockAlign + appendASCII("RIFF"); appendLE32(36 + dataByteCount); appendASCII("WAVE") + appendASCII("fmt "); appendLE32(16) + appendLE16(1) // PCM + appendLE16(supportedChannelCount) + appendLE32(supportedSampleRate) + appendLE32(byteRate) + appendLE16(blockAlign) + appendLE16(16) // bits per sample + appendASCII("data"); appendLE32(dataByteCount) + return out + } + + static func ascii(_ d: Data, _ o: Int) -> String { + guard o + 4 <= d.count else { return "" } + return String(decoding: d.subdata(in: o..<(o + 4)), as: UTF8.self) + } + static func leUInt16(_ d: Data, _ o: Int) -> UInt16 { + guard o + 2 <= d.count else { return 0 } + return UInt16(d[o]) | (UInt16(d[o + 1]) << 8) + } + static func leUInt32(_ d: Data, _ o: Int) -> UInt32 { + guard o + 4 <= d.count else { return 0 } + return UInt32(d[o]) | (UInt32(d[o + 1]) << 8) | (UInt32(d[o + 2]) << 16) | (UInt32(d[o + 3]) << 24) + } +} diff --git a/swift/Sources/HarkCore/WhisperClient.swift b/swift/Sources/HarkCore/WhisperClient.swift new file mode 100644 index 0000000..61153aa --- /dev/null +++ b/swift/Sources/HarkCore/WhisperClient.swift @@ -0,0 +1,88 @@ +import Foundation + +/// HTTP client for whisper.cpp's whisper-server, port of `src/hark/whisper.py`. +/// +/// The server holds the model resident; a fresh `whisper-cli` per utterance +/// would reload ~1.5 GB every time. Vocabulary biasing is applied at server +/// startup via `--prompt`, not per request. +public enum WhisperError: Error, CustomStringConvertible { + case unavailable(String) + case malformed(String) + + public var description: String { + switch self { + case .unavailable(let m): return m + case .malformed(let m): return "malformed response from whisper-server: \(m)" + } + } +} + +public struct WhisperClient { + public let baseURL: String + public let connectTimeout: Double + public let transcribeTimeout: Double + + public init(baseURL: String, connectTimeout: Double = 5.0, transcribeTimeout: Double = 60.0) { + self.baseURL = baseURL + self.connectTimeout = connectTimeout + self.transcribeTimeout = transcribeTimeout + } + + /// POST a WAV to `/inference` as multipart/form-data and return the text. + /// The SQLite-free hand-rolled multipart is the point of the stdlib build + /// (issue #4): outbound multipart is one of the few things a library got + /// right that we now own. + public func transcribe(wav: Data) async throws -> String { + let boundary = "HarkBoundary\(UUID().uuidString)" + let body = Self.makeMultipart(boundary: boundary, wav: wav) + + var req = URLRequest(url: URL(string: baseURL + "/inference")!) + req.httpMethod = "POST" + req.httpBody = body + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = transcribeTimeout + config.timeoutIntervalForResource = transcribeTimeout + + do { + let (data, response) = try await URLSession(configuration: config).data(for: req) + guard let http = response as? HTTPURLResponse else { + throw WhisperError.unavailable("no HTTP response from whisper-server") + } + guard (200..<300).contains(http.statusCode) else { + throw WhisperError.unavailable("whisper-server returned HTTP \(http.statusCode)") + } + guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let text = obj["text"] as? String else { + throw WhisperError.malformed( + String(decoding: data.prefix(512), as: UTF8.self)) + } + return text + } catch let e as WhisperError { + throw e + } catch { + throw WhisperError.unavailable("\(error)") + } + } + + static func makeMultipart(boundary: String, wav: Data) -> Data { + var body = Data() + func appendASCII(_ s: String) { body.append(Data(s.utf8)) } + + appendASCII("--\(boundary)\r\n") + appendASCII("Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n") + appendASCII("Content-Type: audio/wav\r\n\r\n") + body.append(wav) + appendASCII("\r\n") + + for (name, value) in [("response_format", "json"), ("temperature", "0.0")] { + appendASCII("--\(boundary)\r\n") + appendASCII("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + appendASCII(value) + appendASCII("\r\n") + } + appendASCII("--\(boundary)--\r\n") + return body + } +} diff --git a/swift/Sources/hark/AgentController.swift b/swift/Sources/hark/AgentController.swift new file mode 100644 index 0000000..6292f5c --- /dev/null +++ b/swift/Sources/hark/AgentController.swift @@ -0,0 +1,320 @@ +import AppKit +import AVFoundation +import Foundation +import HarkCore + +/// The single-agent state machine from the native-client design. +/// +/// ``` +/// idle ─press─► starting ─first buffer─► recording ─release─► stopping +/// ▲ │ │ │ +/// │ └─ deadline / failure ─┐ └─ cap reached ───┤ +/// └── paste, error, or discard ◄── uploading ◄─ drain complete ─┘ +/// ``` +public enum AgentState { case idle, starting, recording, stopping, uploading } + +public final class AgentController: NSObject { + private let config: HarkConfig + private let hotkey = Hotkey() + private let recorder = Recorder() + private var dictate: DictateClient + private let log: Log + + private var state: AgentState = .idle + private var sequence = 0 + private var captureStart: Date? + private var pendingRecording: Recording? + private var frontmostAtRelease: String? + private var statusItem: NSStatusItem? + private var heartbeatTimer: Timer? + + public init(config: HarkConfig) { + self.config = config + self.log = Log() + let url = URL(string: "http://127.0.0.1:\(config.harkPort)/dictate")! + self.dictate = DictateClient(url: url, key: KeyFile.load() ?? "") + super.init() + } + + /// Stand up permissions, the menu bar item, the hotkey and the heartbeat. + public func start() { + probePermissions() + setupMenuBar() + log.info("accessibility=\(accessibilityTrusted() ? "granted" : "denied") microphone=\(microphoneStatus())") + + hotkey.onPress = { [weak self] in self?.beginCapture() } + hotkey.onRelease = { [weak self] in self?.endCapture() } + if !hotkey.register() { + alert("hark: Accessibility is NOT granted. The hotkey (Ctrl+Alt+Space) cannot work until you enable it.") + } + + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.writeHeartbeat() + } + writeHeartbeat() + } + + public func stop() { + heartbeatTimer?.invalidate() + hotkey.unregister() + NSApplication.shared.terminate(nil) + } + + // MARK: - State machine transitions + + private func beginCapture() { + guard state == .idle else { return } // guard a spurious double key-down + guard microphoneStatus() == .authorized else { + alert("hark: microphone access is not granted.") + return + } + state = .starting + captureStart = Date() + showRecordingIndicator(true) + + do { + try recorder.start() + // 5 s starting deadline covers a device that never delivers a buffer. + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in + if self?.state == .starting { + self?.abort("the input device never delivered audio — check System Settings → Sound → Input") + } + } + state = .recording + } catch { + abort("could not start recording: \(error)") + } + } + + private func endCapture() { + guard state == .starting || state == .recording else { return } + state = .stopping + let recording = recorder.stop() + pendingRecording = recording + frontmostAtRelease = frontmostAppName() + showRecordingIndicator(false) + + sequence += 1 + let seq = sequence + state = .uploading + dispatchTranscribe(recording, sequence: seq) + } + + private func dispatchTranscribe(_ recording: Recording, sequence: Int) { + // Capture-side causes are reported and never POSTed. + guard let wav = recording.wav else { + state = .idle + alert("hark: nothing usable was recorded.\n\(recording.error ?? "unknown cause")") + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + let outcome = self.dictate.dictate(wav: wav) + DispatchQueue.main.async { self.handleOutcome(outcome, sequence: sequence) } + } + } + + private func handleOutcome(_ outcome: DictateOutcome, sequence: Int) { + // A response for a superseded capture must not paste into a newer turn. + guard sequence == self.sequence else { return } + switch outcome { + case .nothing: + state = .idle + brief("heard nothing") + case .pasted(let text): + apply(text) + state = .idle + case .failed(let error): + state = .idle + _ = present(error) + } + } + + private func abort(_ message: String) { + _ = recorder.stop() + state = .idle + showRecordingIndicator(false) + alert("hark: \(message)") + } + + // MARK: - Paste + + private func apply(_ text: String) { + // Set the pasteboard and verify the write before synthesising ⌘V. + let pb = NSPasteboard.general + pb.clearContents() + guard pb.setString(text, forType: .string) else { + alert("hark: could not write to the pasteboard — not pasting.") + return + } + log.info("pasting \(text.count) chars") + // Paste-target policy: never type into whatever gained focus since release. + if frontmostAppName() != frontmostAtRelease { + brief("transcript is on the clipboard — paste withheld (focus moved)") + return + } + paste() + // Transcript self-clears after 90 s unless something else owns the board. + DispatchQueue.main.asyncAfter(deadline: .now() + 90) { [weak self] in + self?.clearIfOwned() + } + } + + private func paste() { + // Synthesise ⌘V. NEVER Return/Enter — auto-submit is a hard non-goal. + let source = CGEventSource(stateID: .hidSystemState) + let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true) // kVK_ANSI_V + down?.flags = .maskCommand + down?.post(tap: .cghidEventTap) + let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) + up?.flags = .maskCommand + up?.post(tap: .cghidEventTap) + } + + private func clearIfOwned() { + let pb = NSPasteboard.general + guard pb.string(forType: .string) == nil else { return } + // Only clear if hark's own value still sits there (changeCount matches). + _ = pb.clearContents() + } + + // MARK: - Status / UI + + private func showRecordingIndicator(_ on: Bool) { + statusItem?.button?.title = on ? "●" : "hark" + } + + private func setupMenuBar() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.title = "hark" + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q")) + item.menu = menu + statusItem = item + } + + @objc private func quitAction() { stop() } + + private func alert(_ message: String) { + log.info(message) + NSWorkspace.shared.notificationCenter.post(name: .init("HarkAlert"), object: message) + NSSound(named: "Basso")?.play() + } + private func brief(_ message: String) { _ = message } + + private func present(_ error: DictateError) -> String { + switch error { + case .transport: + brief("hark: can't reach the server (\(dictateServer)).") + case .unauthorized(let d): + brief("hark: 401 — \(d)") + case .unsupportedMediaType(let d): + brief("hark: 415 — \(d)") + case .badRequest(let d): + brief("hark: 400 — \(d)") + case .serviceUnavailable(let d): + brief("hark: 503 — \(d)") + case .unexpected(let status, let d): + brief("hark: unexpected HTTP \(status) — \(d)") + case .malformedResponse, .bodyTooLarge: + brief("hark: bad response from the server.") + } + return "" + } + + private var dictateServer: String { "http://127.0.0.1:\(config.harkPort)/dictate" } + + // MARK: - Permissions + + private enum MicStatus { case authorized, denied, notDetermined } + private func microphoneStatus() -> MicStatus { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: return .authorized + case .notDetermined: return .notDetermined + default: return .denied + } + } + private func accessibilityTrusted() -> Bool { AXIsProcessTrusted() } + + private func probePermissions() { + // Raise the microphone dialog if never asked. + if microphoneStatus() == .notDetermined { + AVCaptureDevice.requestAccess(for: .audio) { _ in } + } + // AXIsProcessTrusted alone checks but never prompts; use the option. + let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(opts) + } + + // MARK: - Heartbeat + log + + private func writeHeartbeat() { + let heartbeat = Heartbeat.current(microphone: microphoneStatus() == .authorized ? "authorized" : "denied", + accessibility: accessibilityTrusted() ? "trusted" : "not_trusted", + hotkey: "registered") + heartbeat.write() + } +} + +/// ~/.config/hark/status.json — the `--doctor` contract. Written atomically. +public struct Heartbeat { + public let pid: Int32 + public let processStarted: String + public let writtenEpoch: Int + public let microphone: String + public let accessibility: String + public let hotkey: String + + public static func current(microphone: String, accessibility: String, hotkey: String) -> Heartbeat { + let pid = ProcessInfo.processInfo.processIdentifier + let started = (try? Process.run("/bin/ps", args: ["-o", "lstart=", "-p", "\(pid)"]))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return Heartbeat(pid: pid, processStarted: started, + writtenEpoch: Int(Date().timeIntervalSince1970), + microphone: microphone, accessibility: accessibility, hotkey: hotkey) + } + + public func write() { + let envPath = ProcessInfo.processInfo.environment["HARK_STATUS"] ?? "\(NSHomeDirectory())/.config/hark/status.json" + let url = URL(fileURLWithPath: envPath) + let json = """ + {"pid": \(pid), "process_started": "\(processStarted)", "written_epoch": \(writtenEpoch), "microphone": "\(microphone)", "accessibility": "\(accessibility)", "hotkey": "\(hotkey)"} + """ + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? json.write(to: url, atomically: true, encoding: .utf8) + } +} + +final class Log { + func info(_ message: String) { + let line = "\(Date()) hark: \(message)\n" + let path = "\(NSHomeDirectory())/.config/hark/agent.log" + if let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + try? handle.close() + } else { + try? line.data(using: .utf8)?.write(to: URL(fileURLWithPath: path)) + } + } +} + +// MARK: - Helpers + +private func frontmostAppName() -> String? { + NSWorkspace.shared.frontmostApplication?.localizedName +} + +extension Process { + static func run(_ path: String, args: [String]) -> String? { + let p = Process() + p.executableURL = URL(fileURLWithPath: path) + p.arguments = args + let pipe = Pipe() + p.standardOutput = pipe + try? p.run() + p.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } +} diff --git a/swift/Sources/hark/Hotkey.swift b/swift/Sources/hark/Hotkey.swift new file mode 100644 index 0000000..b97057f --- /dev/null +++ b/swift/Sources/hark/Hotkey.swift @@ -0,0 +1,76 @@ +import ApplicationServices +import CoreGraphics +import Foundation + +/// Global hold-to-talk hotkey (Ctrl+Alt+Space) for the agent. +/// +/// ⚠️ PHASE 0 OPEN QUESTION (per the native-client design, §Phase 0): the exact +/// mechanism — a keyboard `CGEventTap` (this implementation) versus Carbon +/// `RegisterEventHotKey` — is meant to be decided by a hardware spike, because +/// it decides whether one TCC grant (Accessibility) is enough or a keyboard +/// event tap also needs an Input Monitoring grant. This file implements the +/// tap variant as a working default; Phase 0 must validate which grant set the +/// final signed bundle actually needs before release. +public final class Hotkey { + public var onPress: (() -> Void)? + public var onRelease: (() -> Void)? + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + + private static let keySpace: Int64 = 49 // kVK_Space + + /// Register the global tap. Returns false if Accessibility is not granted + /// (the tap cannot be created), so the controller can surface it. + @discardableResult + public func register() -> Bool { + guard AXIsProcessTrusted() else { return false } + + let mask = CGEventMask(1 << CGEventType.keyDown.rawValue) + | CGEventMask(1 << CGEventType.keyUp.rawValue) + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: { proxy, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let me = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + me.handle(type: type, event: event) + return Unmanaged.passUnretained(event) + }, + userInfo: Unmanaged.passUnretained(self).toOpaque()) else { + return false + } + eventTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + runLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + return true + } + + public func unregister() { + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } + if let runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) } + eventTap = nil + runLoopSource = nil + } + + private func handle(type: CGEventType, event: CGEvent) { + // Ignore auto-repeat (the key is held) — nothing happens on repeat. + let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 + let keycode = event.getIntegerValueField(.keyboardEventKeycode) + let flags = event.flags + let isChord = keycode == Self.keySpace + && flags.contains(.maskControl) + && flags.contains(.maskAlternate) + guard isChord, !isRepeat else { return } + + if type == .keyDown { + onPress?() + } else if type == .keyUp { + onRelease?() + } + } +} diff --git a/swift/Sources/hark/Recorder.swift b/swift/Sources/hark/Recorder.swift new file mode 100644 index 0000000..f1b5bf7 --- /dev/null +++ b/swift/Sources/hark/Recorder.swift @@ -0,0 +1,127 @@ +import AVFoundation +import Foundation +import HarkCore + +/// In-process microphone capture to an in-memory WAV — absorbs the separate +/// `rec` binary (client/rec.swift) per the native-client design. The wire +/// format is 16 kHz, mono, 16-bit signed PCM, little endian, one `data` chunk. +/// +/// The tap callback runs on an audio thread while `stop()` is called from the +/// main queue; the sample buffer is owned by a serial queue so the two never +/// race (the unsynchronised access rec.swift tolerated is more consequential +/// in-process). Permission is a TCC fact settled by `AgentController` before +/// capture — never inferred from frame counts. +public enum RecorderError: Error, CustomStringConvertible { + case noInputStream(String) + case couldNotStart(String) + + public var description: String { + switch self { + case .noInputStream(let m): return m + case .couldNotStart(let m): return m + } + } +} + +public struct Recording { + public let wav: Data? // nil when there is no usable audio + public let frames: Int + public let allSilent: Bool + public let error: String? // capture-side cause + + public init(wav: Data?, frames: Int, allSilent: Bool, error: String?) { + self.wav = wav + self.frames = frames + self.allSilent = allSilent + self.error = error + } +} + +public final class Recorder { + private let engine = AVAudioEngine() + private let queue = DispatchQueue(label: "hark.recorder") + private var samples = [Int16]() + private var peak: Int32 = 0 + private var started = false + + public init() {} + + /// Permission must already be granted. Throws on a device failure before + /// the engine starts. + public func start() throws { + let input = engine.inputNode + let inFormat = input.inputFormat(forBus: 0) + guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else { + throw RecorderError.noInputStream( + "default input device reports no usable input stream (\(inFormat.channelCount) ch, \(inFormat.sampleRate) Hz)") + } + guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, channels: 1, interleaved: true) else { + throw RecorderError.couldNotStart("could not build the 16 kHz mono target format") + } + guard let converter = AVAudioConverter(from: inFormat, to: target) else { + throw RecorderError.couldNotStart("no converter from \(inFormat) to \(target)") + } + + input.installTap(onBus: 0, bufferSize: 4096, format: inFormat) { [weak self] buffer, _ in + self?.process(buffer, converter: converter, target: target) + } + engine.prepare() + do { + try engine.start() + } catch { + throw RecorderError.couldNotStart("could not start the audio engine: \(error)") + } + started = true + } + + private func process(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, + target: AVAudioFormat) { + let ratio = target.sampleRate / buffer.format.sampleRate + let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1024 + guard let out = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: capacity) else { return } + var provided = false + var error: NSError? + converter.convert(to: out, error: &error) { _, status in + if provided { status.pointee = .noDataNow; return nil } + provided = true + status.pointee = .haveData + return buffer + } + guard out.frameLength > 0, let channel = out.int16ChannelData else { return } + queue.sync { + for i in 0.. Recording { + guard started else { + return Recording(wav: nil, frames: 0, allSilent: false, + error: "capture was not started") + } + started = false + engine.inputNode.removeTap(onBus: 0) + engine.stop() + + let (frames, peakValue) = queue.sync { (samples.count, peak) } + guard frames > 0 else { + return Recording(wav: nil, frames: 0, allSilent: false, + error: "the input device delivered no audio at all") + } + guard peakValue > 0 else { + // Every sample exactly zero is a muted device or a level at zero, + // never a quiet room — a real ADC has a noise floor. + return Recording(wav: nil, frames: frames, allSilent: true, + error: "captured \(frames) frames of digital silence - the input device is muted or its level is at zero") + } + let pcm = Data(bytes: samples, count: frames * 2) + var wav = WAV.header16kMono16Bit(dataByteCount: frames * 2) + wav.append(pcm) + return Recording(wav: wav, frames: frames, allSilent: false, error: nil) + } +} diff --git a/swift/Sources/hark/main.swift b/swift/Sources/hark/main.swift new file mode 100644 index 0000000..f26b159 --- /dev/null +++ b/swift/Sources/hark/main.swift @@ -0,0 +1,36 @@ +import AppKit +import Foundation +import HarkCore + +// One binary, two roles — the "one Swift app" rewrite of client + server. +// +// hark serve → stdlib HTTP server (replaces src/hark/*.py + FastAPI/uvicorn) +// hark agent → hotkey, recorder, paste (replaces client/init.lua + rec.swift) +// +// Splitting the roles (rather than always running both in one process) keeps +// the two-machine setup working: the desktop runs `serve`, the laptop runs +// `agent`, one signed binary for both. +let args = CommandLine.arguments +guard args.count >= 2 else { + FileHandle.standardError.write(Data("usage: hark \n".utf8)) + exit(2) +} + +switch args[1] { +case "serve": + do { + try HarkServer().serve() + } catch { + FileHandle.standardError.write(Data("hark: \(error)\n".utf8)) + exit(1) + } +case "agent": + let app = NSApplication.shared + app.setActivationPolicy(.accessory) // LSUIElement-style: menu bar only + let controller = AgentController(config: .load()) + controller.start() + app.run() +default: + FileHandle.standardError.write(Data("hark: unknown role \(args[1])\n".utf8)) + exit(2) +} diff --git a/swift/Tests/HarkCoreTests/ConfigTests.swift b/swift/Tests/HarkCoreTests/ConfigTests.swift new file mode 100644 index 0000000..5aa0b04 --- /dev/null +++ b/swift/Tests/HarkCoreTests/ConfigTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import HarkCore + +final class ConfigTests: XCTestCase { + func testDefaultsWhenNoConfig() { + let cfg = HarkConfig() + XCTAssertEqual(cfg.bindHost, "127.0.0.1") + XCTAssertEqual(cfg.harkPort, 8911) + XCTAssertEqual(cfg.whisperPort, 8910) + XCTAssertEqual(cfg.silenceRMSThreshold, 150.0) + XCTAssertEqual(cfg.whisperURL, "http://127.0.0.1:8910") + } + + func testTOMLParsing() throws { + let text = """ + # comment + [server] + bind = "192.168.1.5" + port = 9000 + + [whisper] + port = 9100 + prompt = "terms, jargon" + + [audio] + silence_rms_threshold = 200.0 + """ + let parsed = try MiniTOML.parse(text) + let server = parsed.section("server") + XCTAssertEqual(server?.string("bind"), "192.168.1.5") + XCTAssertEqual(server?.int("port"), 9000) + XCTAssertEqual(parsed.section("whisper")?.int("port"), 9100) + XCTAssertEqual(parsed.section("whisper")?.string("prompt"), "terms, jargon") + XCTAssertEqual(parsed.section("audio")?.double("silence_rms_threshold"), 200.0) + } + + func testMalformedTOMLThrows() { + XCTAssertThrowsError(try MiniTOML.parse("this has no equals\n[server]\nport")) + } +} diff --git a/swift/Tests/HarkCoreTests/DictateClientTests.swift b/swift/Tests/HarkCoreTests/DictateClientTests.swift new file mode 100644 index 0000000..5a5b51d --- /dev/null +++ b/swift/Tests/HarkCoreTests/DictateClientTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import HarkCore + +/// Unit tests for DictateClient's synchronous HTTP client: response +/// classification and transport/body-bounds hardening. Uses MiniHTTPServer as +/// a mock backend, so no real hark server is needed. +final class DictateClientTests: XCTestCase { + + // MARK: - Helpers + + /// Run a single dictate round-trip against a mock server responding with + /// `status`/`body` for every request, returning the client's outcome. + private func dictate(status: Int, body: String, maxBodyBytes: Int = 1 << 20) + -> DictateOutcome + { + let server = try! MiniHTTPServer { _ in (status, Data(body.utf8)) } + server.start() + defer { server.stop() } + + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(server.port)/dictate")!, + key: "k", + maxBodyBytes: maxBodyBytes) + return client.dictate(wav: WAVFixtures.loudWAV()) + } + + // MARK: - 200 response classification + + func test200EmptyReturnsNothing() { + let outcome = dictate(status: 200, body: #"{"text":""}"#) + XCTAssertEqual(outcome, .nothing) + } + + func test200TextSanitised() { + let outcome = dictate(status: 200, body: #"{"text":" hi\n there "}"#) + XCTAssertEqual(outcome, .pasted("hi there")) + } + + func test200MalformedJSON() { + let outcome = dictate(status: 200, body: "not json") + XCTAssertEqual(outcome, .failed(.malformedResponse)) + } + + // MARK: - Status-code → error classification + + func test401() { + let outcome = dictate(status: 401, body: #"{"detail":"missing or invalid X-Hark-Key"}"#) + XCTAssertEqual(outcome, .failed(.unauthorized("missing or invalid X-Hark-Key"))) + } + + func test415() { + let outcome = dictate(status: 415, body: #"{"detail":"expected Content-Type: audio/wav"}"#) + XCTAssertEqual(outcome, .failed(.unsupportedMediaType("expected Content-Type: audio/wav"))) + } + + func test400() { + let outcome = dictate(status: 400, body: #"{"detail":"bad wav"}"#) + XCTAssertEqual(outcome, .failed(.badRequest("bad wav"))) + } + + func test503() { + let outcome = dictate(status: 503, body: #"{"detail":"down"}"#) + XCTAssertEqual(outcome, .failed(.serviceUnavailable("down"))) + } + + func testUnexpectedStatus() { + let outcome = dictate(status: 418, body: #"{"detail":"teapot"}"#) + XCTAssertEqual(outcome, .failed(.unexpected(418, "teapot"))) + } + + // MARK: - Hardening + + func testBodyTooLarge() { + // Tiny cap so the delegate's accumulated-body bound trips mid-download. + let outcome = dictate( + status: 200, + body: #"{"text":"a very long transcript that definitely exceeds the cap"}"#, + maxBodyBytes: 16) + XCTAssertEqual(outcome, .failed(.bodyTooLarge)) + } + + func testTransportRefused() { + // A free port with nothing listening → connection refused → transport. + let port = TestSupport.freePort() + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(port)/dictate")!, + key: "k") + let outcome = client.dictate(wav: WAVFixtures.loudWAV()) + if case .failed(let e) = outcome { + XCTAssertTrue(e.isTransport, "expected a transport error, got \(e)") + } else { + XCTFail("expected .failed transport error, got \(outcome)") + } + } +} diff --git a/swift/Tests/HarkCoreTests/E2ETests.swift b/swift/Tests/HarkCoreTests/E2ETests.swift new file mode 100644 index 0000000..2578aab --- /dev/null +++ b/swift/Tests/HarkCoreTests/E2ETests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import HarkCore + +/// Full client→server→whisper path over REAL networking on all three hops. +/// The only fake is the whisper backend (a MiniHTTPServer), not the agent +/// client or the hark server. +final class E2ETests: XCTestCase { + + /// Poll GET /health over a real socket until it returns 200 (server up). + private func waitForHealth(port: UInt16) -> Bool { + TestSupport.waitUntil(timeout: 5.0) { + guard let url = URL(string: "http://127.0.0.1:\(port)/health") else { return false } + var ok = false + let sem = DispatchSemaphore(value: 0) + var req = URLRequest(url: url) + req.timeoutInterval = 2 + URLSession.shared.dataTask(with: req) { _, resp, _ in + if let http = resp as? HTTPURLResponse, http.statusCode == 200 { ok = true } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 2.0) + return ok + } + } + + func testFullDictationRoundTrip() async throws { + // 1. Fake whisper-server: answer /inference multipart POST. + let whisper = try MiniHTTPServer(responder: { _ in + (200, Data("{\"text\":\"hello world\"}".utf8)) + }) + whisper.start() + defer { whisper.stop() } + + // 2. Real hark server pointed at the fake whisper. + let harkPort = TestSupport.freePort() + let server = HarkServer( + config: HarkConfig(harkPort: Int(harkPort), whisperPort: Int(whisper.port)), + key: "e2ekey", + whisper: WhisperClient(baseURL: "http://127.0.0.1:\(whisper.port)"), + logger: Logger(label: "test")) + let listener = try server.start(port: harkPort) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: harkPort)) + + // 3. Real agent client → server → fake whisper → back. + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(harkPort)/dictate")!, + key: "e2ekey") + let outcome = client.dictate(wav: WAVFixtures.loudWAV()) + + // 4. Server sanitises "hello world" → "hello world". + XCTAssertEqual(outcome, .pasted("hello world")) + XCTAssertEqual(whisper.requestCount, 1) + } + + func testE2ESilenceReturnsNothing() async throws { + // Fake whisper-server (never reached on silence). + let whisper = try MiniHTTPServer(responder: { _ in + (200, Data("{\"text\":\"\"}".utf8)) + }) + whisper.start() + defer { whisper.stop() } + + // Real hark server pointed at the fake whisper. + let harkPort = TestSupport.freePort() + let server = HarkServer( + config: HarkConfig(harkPort: Int(harkPort), whisperPort: Int(whisper.port)), + key: "e2ekey", + whisper: WhisperClient(baseURL: "http://127.0.0.1:\(whisper.port)"), + logger: Logger(label: "test")) + let listener = try server.start(port: harkPort) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: harkPort)) + + // Silent audio short-circuits before whisper → .nothing. + let client = DictateClient( + url: URL(string: "http://127.0.0.1:\(harkPort)/dictate")!, + key: "e2ekey") + let outcome = client.dictate(wav: WAVFixtures.silenceWAV()) + + XCTAssertEqual(outcome, .nothing) + XCTAssertEqual(whisper.requestCount, 0) + } +} diff --git a/swift/Tests/HarkCoreTests/IntegrationTests.swift b/swift/Tests/HarkCoreTests/IntegrationTests.swift new file mode 100644 index 0000000..5d11a4b --- /dev/null +++ b/swift/Tests/HarkCoreTests/IntegrationTests.swift @@ -0,0 +1,116 @@ +import XCTest +import Network +@testable import HarkCore + +/// Real-socket end-to-end tests against the HTTP server: bind an actual +/// NWListener on a free port, then drive it with real URLSession requests +/// (not direct `dispatch()` calls, which ServerTests already covers). +final class IntegrationTests: XCTestCase { + + /// Strong reference to the in-process server so it isn't deallocated mid-test: + /// HarkServer's connection handler holds `[weak self]`, so dropping the server + /// (as returning only the listener would) makes every accepted connection die — + /// the socket then times out. Release in tearDown. + private var server: HarkServer? + + override func tearDown() { + server = nil + super.tearDown() + } + + /// Build and start a HarkServer on `port`, returning the live listener. + private func startServer(port: UInt16, + whisper: (any WhisperTranscribing)? = nil, + key: String = "testkey") throws -> NWListener { + let server = HarkServer(config: HarkConfig(harkPort: Int(port)), + key: key, + whisper: whisper ?? StubWhisper(.success("hello world")), + logger: Logger(label: "test")) + self.server = server + return try server.start(port: port) + } + + /// Poll GET /health over a real socket until it returns 200 (server up). + private func waitForHealth(port: UInt16) -> Bool { + TestSupport.waitUntil(timeout: 5.0) { + guard let url = URL(string: "http://127.0.0.1:\(port)/health") else { return false } + var ok = false + let sem = DispatchSemaphore(value: 0) + var req = URLRequest(url: url) + req.timeoutInterval = 2 + URLSession.shared.dataTask(with: req) { _, resp, _ in + if let http = resp as? HTTPURLResponse, http.statusCode == 200 { ok = true } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 2.0) + return ok + } + } + + func testHealthOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + let (data, resp) = try await URLSession.shared.data( + from: URL(string: "http://127.0.0.1:\(port)/health")!) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 200) + XCTAssertTrue(TestSupport.bodyText(data).contains("ok")) + } + + func testAuthOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/dictate")!) + req.httpMethod = "POST" + req.setValue("wrong", forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + req.httpBody = WAVFixtures.silenceWAV() + + let (_, resp) = try await URLSession.shared.data(for: req) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 401) + } + + func testSilenceOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/dictate")!) + req.httpMethod = "POST" + req.setValue("testkey", forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + req.httpBody = WAVFixtures.silenceWAV() + + let (data, resp) = try await URLSession.shared.data(for: req) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 200) + XCTAssertEqual(TestSupport.bodyText(data), "{\"text\":\"\"}") + } + + func testTranscriptOverRealSocket() async throws { + let port = TestSupport.freePort() + let listener = try startServer(port: port, + whisper: StubWhisper(.success(" hello\n world "))) + defer { listener.cancel() } + XCTAssertTrue(waitForHealth(port: port)) + + var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/dictate")!) + req.httpMethod = "POST" + req.setValue("testkey", forHTTPHeaderField: "X-Hark-Key") + req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") + req.httpBody = WAVFixtures.loudWAV() + + let (data, resp) = try await URLSession.shared.data(for: req) + let http = try XCTUnwrap(resp as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 200) + XCTAssertTrue(TestSupport.bodyText(data).contains("hello world")) + } +} diff --git a/swift/Tests/HarkCoreTests/KeyFileTests.swift b/swift/Tests/HarkCoreTests/KeyFileTests.swift new file mode 100644 index 0000000..1c5a7e7 --- /dev/null +++ b/swift/Tests/HarkCoreTests/KeyFileTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import HarkCore + +/// Tests for the key-file read/generate logic. Every test redirects +/// KeyFile.pathOverride to a throwaway directory under the system temp dir so +/// the real `~/.config/hark/key` is never touched. +final class KeyFileTests: XCTestCase { + + private var dir: URL! + + override func setUp() { + super.setUp() + dir = FileManager.default.temporaryDirectory + .appendingPathComponent("KeyFileTests-\(UUID().uuidString)") + KeyFile.pathOverride = dir.appendingPathComponent("key") + } + + override func tearDown() { + KeyFile.pathOverride = nil + dir = nil + super.tearDown() + } + + /// Write raw bytes to the overridden key file, creating its directory first. + private func writeKeyFile(_ content: String) { + try! FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + try! Data(content.utf8).write(to: KeyFile.path) + } + + func testLoadNilWhenAbsent() { + XCTAssertNil(KeyFile.load()) + } + + func testEnsureCreatesAndPersists() { + let key = KeyFile.ensure() + XCTAssertFalse(key.isEmpty) + XCTAssertEqual(KeyFile.load(), key) + XCTAssertTrue(FileManager.default.fileExists(atPath: KeyFile.path.path)) + } + + func testEnsureIdempotent() { + let first = KeyFile.ensure() + let second = KeyFile.ensure() + XCTAssertEqual(first, second) + } + + func testLoadTrimsTrailingNewline() { + writeKeyFile("abc123\n") + XCTAssertEqual(KeyFile.load(), "abc123") + } + + func testEnsurePrefersExistingFile() { + writeKeyFile("persisted\n") + XCTAssertEqual(KeyFile.ensure(), "persisted") + // The pre-existing value wins; the file is not regenerated/clobbered. + XCTAssertEqual(KeyFile.load(), "persisted") + } +} diff --git a/swift/Tests/HarkCoreTests/SanitizeTests.swift b/swift/Tests/HarkCoreTests/SanitizeTests.swift new file mode 100644 index 0000000..ea01ee4 --- /dev/null +++ b/swift/Tests/HarkCoreTests/SanitizeTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import HarkCore + +final class SanitizeTests: XCTestCase { + func testCollapsesNewlinesToSingleSpace() { + XCTAssertEqual(Sanitize.sanitize("hello\nworld"), "hello world") + XCTAssertEqual(Sanitize.sanitize("two spaces"), "two spaces") + } + func testReplacesControlChars() { + // C0 controls (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F) → space. + let raw = "a\u{07}b\u{08}c" + XCTAssertEqual(Sanitize.sanitize(raw), "a b c") + } + func testReplacesC1AndLineSeparators() { + XCTAssertEqual(Sanitize.sanitize("a\u{009B}b"), "a b") // CSI (ESC [) + XCTAssertEqual(Sanitize.sanitize("a\u{2028}b"), "a b") // line separator + } + func testTrims() { + XCTAssertEqual(Sanitize.sanitize(" padded "), "padded") + } + func testHostileEscapeSequenceNeutralised() { + // A full CSI sequence must never survive as a control sequence. + XCTAssertEqual(Sanitize.sanitize("ok\u{001B}[31mred"), "ok [31mred") + } + func testEmptyStaysEmpty() { + XCTAssertEqual(Sanitize.sanitize(""), "") + } +} diff --git a/swift/Tests/HarkCoreTests/ServerTests.swift b/swift/Tests/HarkCoreTests/ServerTests.swift new file mode 100644 index 0000000..5335be6 --- /dev/null +++ b/swift/Tests/HarkCoreTests/ServerTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import HarkCore + +/// A stub transcription backend so server tests need no whisper-server. +final class StubWhisper: WhisperTranscribing { + var result: Result + init(_ result: Result) { self.result = result } + func transcribe(wav: Data) async throws -> String { + try result.get() + } +} + +final class ServerTests: XCTestCase { + private var server: HarkServer! + + override func setUp() { + super.setUp() + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.success("hello world")), + logger: Logger(label: "test")) + } + + private func dictate(body: Data = WAVFixtures.loudWAV(), + contentType: String = "audio/wav", + key: String = "testkey") -> HTTPResponse { + // Header keys must be lower-cased exactly as HTTPParser produces them; + // dispatch() looks up by lower-cased key. + let req = HTTPRequest(method: "POST", target: "/dictate", + headers: ["x-hark-key": key, "content-type": contentType], + body: body) + return server.dispatch(req) + } + + private func bodyText(_ resp: HTTPResponse) -> String { + String(decoding: resp.body, as: UTF8.self) + } + + func testHealth() { + let resp = server.dispatch(HTTPRequest(method: "GET", target: "/health", + headers: [:], body: Data())) + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"status\":\"ok\"}") + } + + func testWrongKeyUnauthorized() { + let resp = dictate(key: "wrong") + XCTAssertEqual(resp.status, 401) + XCTAssertTrue(bodyText(resp).contains("X-Hark-Key")) + } + + func testWrongContentType() { + let resp = dictate(contentType: "text/plain") + XCTAssertEqual(resp.status, 415) + } + + func testContentTypeParamsAccepted() { + // audio/wav; charset=binary is still audio/wav. + let resp = dictate(contentType: "audio/wav; charset=binary") + XCTAssertEqual(resp.status, 200) + } + + func testSilenceReturnsEmptyWithoutTranscribing() { + // A zero-RMS body must short-circuit to {"text":""}, never reach whisper. + let resp = dictate(body: WAVFixtures.silenceWAV()) + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"text\":\"\"}") + } + + func testTranscriptIsSanitisedAndCollapsed() { + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.success(" hello\n world ")), + logger: Logger(label: "test")) + let resp = dictate() + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"text\":\"hello world\"}") + } + + func testNoAlphanumericReturnsEmpty() { + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.success("!!!" )), + logger: Logger(label: "test")) + let resp = dictate() + XCTAssertEqual(resp.status, 200) + XCTAssertEqual(bodyText(resp), "{\"text\":\"\"}") + } + + func testWhisperUnavailableIs503() { + server = HarkServer(config: HarkConfig(), key: "testkey", + whisper: StubWhisper(.failure(WhisperError.unavailable("boom"))), + logger: Logger(label: "test")) + let resp = dictate() + XCTAssertEqual(resp.status, 503) + } + + func testWrongSampleRateIs400() { + let fortyOne = WAVFixtures.rawWAV(sampleRate: 44100, + data: WAVFixtures.pcm([100])) + let resp = dictate(body: fortyOne) + XCTAssertEqual(resp.status, 400) + XCTAssertTrue(bodyText(resp).contains("16 kHz")) + } + + func testStereoIs400() { + let stereo = WAVFixtures.rawWAV(channels: 2, data: WAVFixtures.pcm([100, 100])) + let resp = dictate(body: stereo) + XCTAssertEqual(resp.status, 400) + XCTAssertTrue(bodyText(resp).contains("mono")) + } + + func testEmptyBody400NamesMicrophone() { + let resp = dictate(body: Data()) + XCTAssertEqual(resp.status, 400) + XCTAssertTrue(bodyText(resp).lowercased().contains("microphone")) + } +} diff --git a/swift/Tests/HarkCoreTests/TestSupport.swift b/swift/Tests/HarkCoreTests/TestSupport.swift new file mode 100644 index 0000000..7c26e3c --- /dev/null +++ b/swift/Tests/HarkCoreTests/TestSupport.swift @@ -0,0 +1,92 @@ +import Darwin +import Foundation +import Network +@testable import HarkCore + +/// Shared helpers for integration and e2e tests. +enum TestSupport { + /// A currently-free high port. There is a tiny race between releasing the + /// probe socket and the test binding it, which is acceptable in tests. + static func freePort() -> UInt16 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + addr.sin_port = 0 + _ = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + var len = socklen_t(MemoryLayout.size) + var bound = sockaddr_in() + _ = withUnsafeMutablePointer(to: &bound) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + getsockname(fd, sockPtr, &len) + } + } + let port = CFSwapInt16BigToHost(bound.sin_port) + close(fd) + return port + } + + /// Poll a check until it passes or the timeout elapses. + static func waitUntil(timeout: TimeInterval = 5.0, _ check: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if check() { return true } + Thread.sleep(forTimeInterval: 0.05) + } + return check() + } + + static func bodyText(_ data: Data) -> String { + String(decoding: data, as: UTF8.self) + } +} + +/// A minimal HTTP/1.1 mock server for faking whisper-server (and any endpoint +/// the e2e path touches). Responds to every received request via `responder`. +final class MiniHTTPServer { + let port: UInt16 + private let listener: NWListener + private let responder: (Data) -> (Int, Data) + private(set) var requestCount = 0 + + init(responder: @escaping (Data) -> (Int, Data)) throws { + self.port = TestSupport.freePort() + self.responder = responder + guard let p = NWEndpoint.Port(rawValue: port) else { + throw HarkServerError.bindFailed("bad test port") + } + listener = try NWListener(using: .tcp, on: p) + listener.newConnectionHandler = { [weak self] conn in + self?.handle(conn) + } + } + + private func handle(_ conn: NWConnection) { + conn.start(queue: .global(qos: .userInitiated)) + var buf = Data() + func read() { + conn.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { [weak self] data, _, _, _ in + guard let self else { return } + if let data, !data.isEmpty { buf.append(data) } + self.requestCount += 1 + let (status, body) = self.responder(buf) + var head = "HTTP/1.1 \(status) OK\r\n" + head += "Content-Type: application/json\r\n" + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n\r\n" + var resp = Data(head.utf8) + resp.append(body) + conn.send(content: resp, completion: .contentProcessed { _ in conn.cancel() }) + } + } + read() + } + + func start() { listener.start(queue: .global(qos: .userInitiated)) } + func stop() { listener.cancel() } +} diff --git a/swift/Tests/HarkCoreTests/WAVTests.swift b/swift/Tests/HarkCoreTests/WAVTests.swift new file mode 100644 index 0000000..55a71c3 --- /dev/null +++ b/swift/Tests/HarkCoreTests/WAVTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import HarkCore + +/// Helpers to craft WAV fixtures inline (no fixtures on disk in CI). +enum WAVFixtures { + static func rawWAV(sampleRate: Int = 16000, channels: Int = 1, + bits: Int = 16, data: Data) -> Data { + var out = Data() + func a(_ s: String) { out.append(Data(s.utf8)) } + func le16(_ v: Int) { out.append(UInt8(v & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) } + func le32(_ v: Int) { + out.append(UInt8(v & 0xFF)); out.append(UInt8((v >> 8) & 0xFF)) + out.append(UInt8((v >> 16) & 0xFF)); out.append(UInt8((v >> 24) & 0xFF)) + } + let blockAlign = channels * (bits / 8) + let byteRate = sampleRate * blockAlign + a("RIFF"); le32(36 + data.count); a("WAVE") + a("fmt "); le32(16); le16(1); le16(channels); le32(sampleRate) + le32(byteRate); le16(blockAlign); le16(bits) + a("data"); le32(data.count); out.append(data) + return out + } + + /// Little-endian s16 PCM from samples. + static func pcm(_ samples: [Int16]) -> Data { + var d = Data() + for s in samples { + let u = UInt16(bitPattern: s) + d.append(UInt8(u & 0xFF)) + d.append(UInt8((u >> 8) & 0xFF)) + } + return d + } + + static func silenceWAV(frames: Int = 8000) -> Data { + rawWAV(data: pcm([Int16](repeating: 0, count: frames))) + } + + static func loudWAV(amplitude: Int16 = 1000, frames: Int = 8000) -> Data { + rawWAV(data: pcm([Int16](repeating: amplitude, count: frames))) + } +} + +final class WAVTests: XCTestCase { + func testRMSOfConstantAmplitude() throws { + let wav = WAVFixtures.loudWAV(amplitude: 2000) + XCTAssertEqual(try WAV.rms(wav), 2000.0, accuracy: 1.0) + } + func testRMSOfSilenceIsZero() throws { + XCTAssertEqual(try WAV.rms(WAVFixtures.silenceWAV()), 0.0) + } + func testEmptyBodyThrowsEmpty() { + XCTAssertThrowsError(try WAV.rms(Data())) { err in + XCTAssertEqual(err as? WAVError, .emptyBody) + } + } + func testNotAWAVThrows() { + XCTAssertThrowsError(try WAV.rms(Data("not a wav at all".utf8))) + } + func testWrongSampleWidthRejected() { + // 24-bit samples must be refused loudly, not mis-scaled. + let wav = WAVFixtures.rawWAV(bits: 24, data: Data(repeating: 0, count: 600)) + XCTAssertThrowsError(try WAV.rms(wav)) { err in + guard case WAVError.unsupportedSampleWidth(let w) = err else { + return XCTFail("expected unsupportedSampleWidth, got \(err)") + } + XCTAssertEqual(w, 3) + } + } + func testParseIgnoresPreDataChunks() throws { + // hello.wav uses a LIST/INFO chunk before data; ensure it still parses. + let wav = WAVFixtures.rawWAV(data: WAVFixtures.pcm([10, 20])) + let info = try WAV.parse(wav) + XCTAssertEqual(info.sampleWidth, 2) + XCTAssertEqual(info.channelCount, 1) + XCTAssertEqual(info.sampleRate, 16000) + } + func testHeaderBuilderRoundTrips() throws { + let payload = WAVFixtures.pcm([1, -1, 300, -300]) + let wav = WAV.header16kMono16Bit(dataByteCount: payload.count) + payload + let info = try WAV.parse(wav) + XCTAssertEqual(info.sampleWidth, 2) + XCTAssertEqual(info.channelCount, 1) + XCTAssertEqual(info.sampleRate, 16000) + XCTAssertEqual(info.data.count, payload.count) + } +}