Skip to content

Draft: implementation plan for the native Swift client (#2) - #11

Closed
STRML wants to merge 9 commits into
DRYCodeWorks:mainfrom
STRML:native-client-plan
Closed

Draft: implementation plan for the native Swift client (#2)#11
STRML wants to merge 9 commits into
DRYCodeWorks:mainfrom
STRML:native-client-plan

Conversation

@STRML

@STRML STRML commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Draft — implementation plan for #2. Handoff document; another agent executes it.

Stacked on #10 (the design). This branch contains #10's commits plus one plan file. Merge #10 first, or review the single added file: docs/superpowers/plans/2026-08-01-native-client.md.

What this is

Fifteen tasks derived from the revision 6 design, written for an agent with no prior context on this repo. Each task names exact files, an Interfaces block giving the signatures neighbouring tasks depend on, and a TDD step cycle ending in a commit.

Read these three things before starting

1. Task 1 is a spike, not code, and it gates Tasks 8 and 9.

The hotkey mechanism is genuinely undecided. Two questions, both currently answered by argument — which is exactly how the first revision of the design got this wrong:

  • Does a keyboard CGEventTap require Input Monitoring in addition to Accessibility? README.md:266-268 says an hs.eventtap does. If a keyDown/keyUp tap also does, Carbon needs one grant and the tap needs two.
  • Does kEventHotKeyReleased survive 200 hold-to-talk cycles with zero misses, under load, across modifier rolls and a sleep/wake?

Measure both against a signed bundle, not a bare binary — TCC binds to the designated requirement. Record the verdict in the spec before writing Task 8.

2. Three ordering constraints that are easy to lose on a skim.

  • client/legacy/init-v1.lua must be committed in the same change that stubs client/init.lua. It cannot be copied at install time, because by then the live file is the stub and after a repo move the symlink resolves to nothing. Rollback would preserve the broken thing.
  • SMAppService.mainApp.register() is the last step of a successful cutover, never called at launch. A rolled-back install that already registered starts at the next login and fights the Hammerspoon it just restored.
  • Do not delete client/rec.swift before the installer stops compiling it (install-client.sh:426). Deleting it first bricks the installer mid-run.

3. Tasks 2 and 3 are independent and separately shippable.

They touch only src/hark/ and tests/. They branch the 400 detail by cause — so a malformed header stops sending users to Microphone settings — and enforce channel count and sample rate. A stereo 44.1 kHz WAV is accepted today, and its RMS is then mis-scaled against a threshold calibrated for mono 16 kHz. Neither depends on any Swift work.

Verification status of the plan itself

Stated plainly because it affects how much to trust each code block:

  • Python blocks (Tasks 2, 3): written against code read at 8d12f7b. Expected to run as written.
  • Swift blocks (Tasks 4–10): specifications, not compiled code. No Swift in this plan was built or run. Treat the signatures as the contract, fix compile errors in place, and report any that force an interface change rather than silently diverging.
  • Citations: every file:line in the plan was verified mechanically against 8d12f7b.

Status — 2026-08-01

Item State
Plan 15 tasks, no placeholders, self-review passed
Depends on #10 (design, revision 6)
Blocked on Task 1's verdict before Tasks 8 and 9
Repo green on this base 84 pytest, 8 Lua, shellcheck clean

Known gaps, not hidden

  • Keychain-pinned server origin is out of scope. A same-UID process can still rewrite client.json and redirect the trusted agent; mode 600 does not stop it. Recorded as a spec follow-up needing its own design.
  • Nothing proves TCC survives a Developer ID certificate rotation. That only surfaces at the second signed release.
  • The dangling-symlink ownership heuristic is a guess, resolved by requiring explicit user confirmation rather than by finding better evidence — better evidence does not exist once the target is gone.

Test plan

  • Placeholder scan — none
  • Every file:line in the plan resolves to the content it claims, against 8d12f7b
  • Type/signature consistency across tasks checked
  • uv run --locked pytest -q — 84 passed
  • Execution — not started; that is the next agent's job

https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX

STRML added 9 commits August 1, 2026 08:14
Spec for DRYCodeWorks#2. Decisions: an LSUIElement bundle with a menu bar status item,
capture absorbed in-process so rec stops being a child binary, config at
~/.config/hark/client.json with the key read from the server's own key file on
a single-machine install, and Carbon RegisterEventHotKey rather than an event
tap.

The hotkey choice is the load-bearing one. RegisterEventHotKey needs no
Accessibility grant, so the agent asks for permission to type rather than
permission to watch you type. An event-tap agent would need exactly the
all-or-nothing grant that made the Hammerspoon arrangement uncomfortable. It
also makes two currently-silent failures visible: a missing Accessibility grant
and a hotkey chord already owned by another app.

Measured while writing this and folded into the design: identical Swift sources
produce a different CDHash on every build. install-client.sh recompiles
unconditionally today, which is harmless only because TCC attributes to
Hammerspoon. Once the agent is the TCC principal, an unguarded rebuild
invalidates its own Accessibility grant, so the installer has to hash its build
inputs and skip the build when they match.

Nothing on the user's machine gets deleted on migration. client/init.lua becomes
a stub that binds no hotkey, so an existing symlink keeps resolving and the two
clients never fight over the hotkey.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
All six reviewers returned REVISE on revision 1, and two of them independently
killed its central argument.

Revision 1 chose Carbon RegisterEventHotKey on the grounds that it asks for
permission to type rather than permission to watch you type. That is wrong:
synthesising Cmd+V needs Accessibility, and Accessibility is one atomic grant
that also permits creating an event tap. It also claimed the API errors when
another app owns the chord; the SDK header says the opposite and exclusivity
needs kEventHotKeyExclusive. The mechanism is now undecided behind a Phase 0
spike, whose real question is whether a keyboard tap additionally needs Input
Monitoring — README:245-247 suggests from this project's own experience that it
might, which would revive the permission argument by a different route.

Assuming a Developer ID deletes rather than fixes a whole section. CI builds,
signs and notarises; the installer downloads instead of compiling, so it can no
longer mint a new CDHash on every re-run and the input-hash guard goes away. It
also drops the Xcode command line tools from the client machine, which is the
point of the surrounding dependency work.

Security findings that were missing entirely: the client never sanitised the
server's response, so a spoofed server on the plaintext two-machine path could
return a newline and have it typed into a terminal. There was no transport
policy and no ATS declaration, so the client would have captured audio and then
failed every POST. 400 was mapped to "microphone permission" inherited from the
Lua client, but capture failures no longer reach the server and the server
returns 400 for every InvalidAudioError, including a malformed header this
client now builds by hand.

Migration was insufficient rather than merely incomplete. A stub does not unload
Lua already running in Hammerspoon, and leaving its grants in place preserves
exactly the privilege escape this work exists to close.

Also corrected: revision 1 claimed tests/test_audio.py pins the wire format. It
validates sample width only, so a stereo 44.1 kHz WAV passes today.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Round 2 of the panel returned 5 REVISE / 1 APPROVED. Almost every remaining objection was the same shape: the design named a mechanism without saying what it actually does. A length cap with no number is not a security contract, and a generation stamp that bash cannot verify is not a freshness check.

Concrete now: 1 MiB response body enforced before JSON decoding, 8 KiB sanitised text, reject rather than truncate; a 5 s starting deadline separate from a 120 s capture cap, because rec.swift:167 arms its ceiling only after the first buffer; and a status file bound to the agent by PID plus process start time with a 30 s heartbeat, which is something --doctor can check from bash.

The transport section contradicted itself, prohibiting non-loopback HTTP while promising a Tailscale opt-in that had nowhere to live. It now has one: an explicit per-host allowlist in client.json. Tailnets already encrypt at the network layer, so HTTP there is defensible, but it should be a stated choice rather than a silent default.

Two things the panel found in shipped code rather than in the design. app.py:103-107 returns microphone advice for every InvalidAudioError and test_app.py:107 asserts that wording, so a malformed header from this client would send users to the wrong settings pane. And migration cannot claim to confirm Hammerspoon released the hotkey, because macOS exposes no way to ask WindowServer who owns a chord; confirmed process exit is the most that can be said.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Round 3 found two contradictions I introduced. Per-host NSExceptionDomains cannot follow client.json, because that key lives in the signed Info.plist and writing to it after download invalidates the signature. Restricting insecure HTTP to IP literals lets a static NSAllowsLocalNetworking cover it without the plist naming any host.

The migration also deadlocked: it required the agent verified healthy before quitting Hammerspoon, while the agent cannot register the hotkey until Hammerspoon releases it. Registration is now split out of the health check and happens after handover, with rollback that restores the symlink and relaunches Hammerspoon.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Round 3 brought the panel down to two contradictions and a format bug, all of
which I had introduced.

Per-host NSExceptionDomains cannot follow client.json: that key lives in the
signed Info.plist, so writing to it after download invalidates the signature and
macOS kills the app at launch. Restricting insecure HTTP to IP literals lets a
static NSAllowsLocalNetworking cover the case without the plist ever naming a
host. A MagicDNS name is consequently not usable over HTTP; use the tailnet IP.

The migration deadlocked. It required the agent verified healthy before quitting
Hammerspoon, while the agent cannot register the hotkey until Hammerspoon
releases it. Registration is now split out of the health check and happens after
handover, with rollback that restores the symlink and relaunches Hammerspoon.

The status file proposed matching an ISO timestamp against ps -o lstart, which
prints a localised non-ISO string. It now stores what ps prints, verbatim, plus
an epoch integer, so --doctor compares strings and integers rather than parsing
dates. It also carries hotkey and login-item state, since a heartbeat that omits
them passes while the product does not work.

Also settled rather than deferred: response bounds now cover the error detail
string, not just the transcript; the paste-target guarantee is stated at
application granularity because that is all it enforces; and the clipboard
self-clears after 90 seconds when changeCount is unchanged, which keeps the
paste-recovery window the original decision wanted while bounding an exposure
any local process could harvest.

Rebased onto 8d12f7b. That matters more than usual here: the branch was cut from
4e35977 and main has since moved by eleven commits, so every line number in the
document was stale. All of them are re-verified against 8d12f7b.

Two sections changed meaning as a result. The zero-frames concern is no longer
speculative, because DRYCodeWorks#9 was confirmed and fixed in 559aafe, and that commit is
now cited as the reference implementation for the agent's permission handling
rather than something this design has to invent. And two thirds of the
superseded-spec work in the blast radius is already done by ef47aeb and c852392,
so the document now points at what remains instead of repeating it.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
The verification pass returned 4 APPROVED / 2 REVISE, and both holdouts found real contradictions.

Rollback restored the symlink, but this same change turns client/init.lua into a no-op stub, so the restored symlink pointed at a client that does nothing. The installer now preserves the working Lua client to ~/.config/hark/legacy-client.lua before cutover and repoints there on failure. It also writes an ownership record, because content-matching the symlink target cannot survive the repo moving: an absolute symlink to a moved checkout is dangling, and there is no content left to match.

Rollback also left the agent installed while SMAppService.register() ran unconditionally at first launch, so a rolled-back install would start the agent at the next login and fight the Hammerspoon it had just restored. Registration is now the last step of a successful cutover rather than part of launch, and rollback unregisters.

And xcrun stapler was required on the client two hundred lines after this document claimed the client no longer needs the Xcode command line tools. stapler ships with Xcode; the staple check moves to CI, where the toolchain already exists, and the client verifies with stock spctl and codesign. A downloaded artifact is quarantined, so spctl exercises the notarisation path anyway.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Both holdout reviewers found the same two flaws, and both were in revision 5's fix rather than in the original design.

Step 0 copied the working client/init.lua to preserve a rollback target. By the time the updated installer runs, the user has already pulled, so that file is the stub this change introduces, and after a repo move the symlink is dangling and resolves to nothing. The working code survives only in git history and in Hammerspoon's memory, neither of which the installer can read. The last functional client is now committed as client/legacy/init-v1.lua and staged from there.

Ownership detection was circular in time: step 0 wrote a record asserting hark owned the symlink, then step 1 trusted that record to decide whether hark owned it. It would have classified an independently managed Hammerspoon config as hark's own, and no existing install can have a record this change introduces. Detection now runs on pre-existing evidence only, and the record is written after a confirmed successful cutover, for later runs.

The quarantine assumption was also wrong. I argued spctl exercises notarisation because the download is quarantined, but curl plus ditto need not preserve com.apple.quarantine the way a browser does, and without it spctl takes a weaker path. The installer sets the attribute explicitly before assessing.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
The dangling-symlink fallback matches on path shape, which is a guess rather than proof of ownership. Everything it gates is disruptive and not silently reversible: quitting Hammerspoon, replacing a symlink, resetting TCC grants. When it is the only evidence, the installer now stops and asks.

Header corrected to revision 6; it still claimed 4 while the body described revision 5 as superseded. Also states plainly that this document ships no code, since a reviewer read the absence of client/legacy/init-v1.lua from HEAD as a defect rather than as the nature of a design PR.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
Fifteen tasks from the revision 6 design, written for an agent with no context
on this repo.

Task 1 is a spike, not code. It decides the hotkey mechanism by measurement,
and Tasks 8 and 9 are blocked on its verdict. The two questions are whether a
keyboard CGEventTap needs Input Monitoring on top of Accessibility, and whether
kEventHotKeyReleased survives 200 hold-to-talk cycles with zero misses. Both are
currently answered by argument, which is how revision 1 got the mechanism wrong.

Tasks 2 and 3 are server-side and independent of every Swift task, so they can
ship on their own. They branch the 400 detail by cause, so a malformed header
stops telling users to check microphone permission, and they enforce channel
count and sample rate rather than only sample width. A stereo 44.1 kHz WAV is
accepted today and its RMS is then mis-scaled against a threshold calibrated for
mono 16 kHz.

The ordering constraints in Task 13 are the ones most likely to be lost if the
plan is skimmed. client/legacy/init-v1.lua must be committed in the same change
that stubs client/init.lua, because it cannot be copied at install time: by then
the live file is the stub. Login registration must be the last step of a
successful cutover, because a rolled-back install that already registered would
start at the next login and fight the Hammerspoon it restored. And rec.swift
must not be deleted before the installer stops compiling it.

The Python blocks were written against code read at 8d12f7b and should run as
written. The Swift blocks are specifications, not compiled code, and the plan
says so rather than implying they were verified.

Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX
@STRML

STRML commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #12 (the implemented Swift rewrite). Plan now landed in code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant