Skip to content

Make the app the product, not the CLI - #19

Merged
TerrifiedBug merged 9 commits into
mainfrom
feat/gui-first
Sep 4, 2026
Merged

Make the app the product, not the CLI#19
TerrifiedBug merged 9 commits into
mainfrom
feat/gui-first

Conversation

@TerrifiedBug

@TerrifiedBug TerrifiedBug commented Sep 3, 2026

Copy link
Copy Markdown
Owner

yap shipped as a CLI that happened to draw a menu bar. Onboarding was yap setup, the login item was yap install, a missing grant printed into a log file nobody reads, and the .app existed only so the build could be notarized. This turns that around.

What changes

  • CLI is run and bench. setup, install, start, stop, doctor, record, models are gone, with every run flag the Settings window already covers. Doctor.swift becomes Permissions.swift; Setup/Install/Agent collapse into LaunchAgent.swift; Daemon moves out of Yap.swift into its own file. supportsSessions goes — every registered model can do sessions.
  • The status item goes up before the model loads. A first run is visible for the minutes it spends downloading instead of invisible, and a press during that says press ignored — model not loaded rather than recording into nothing.
  • Onboarding lives in the menu. Rows for Accessibility, the microphone, a hijacked Fn key, and a tap that failed to register. The daemon no longer exits when Accessibility is missing: it raises the prompt once, watches com.apple.accessibility.api, re-checks on every menu open, and starts the tap the moment the grant lands — no relaunch.
  • Settings gains a General pane: launch at login, updates, logs.
  • The hotkey is recorded, not picked. Any modifier held alone (now including the left-hand ones), or a chord like cmd+shift+space, or a lone function key. A chord tap is a .defaultTap that swallows the keystroke; a held modifier stays byte-identical to today's listen-only flagsChanged tap. The spellings are the ones already in people's config files.
  • The transcript pill gains Delete — to the Trash, with Undo, no confirmation. And the pill now answers exactly once, whichever button is clicked.
  • yap can update itself from GitHub Releases, when asked: SHA-256, then a signature check against the team the running code is signed by, then Update to x.y.z · Restart in the menu, disabled while a recording or press is live.

Nothing runs while idle

There is no update schedule. An earlier revision had a 30 s work item and a repeating daily Timer, which is exactly what AGENTS.md forbids — it permits one piece of background work and that is meeting detection. Both are gone, along with the updates.automatic key and its toggle. Check Now in Settings → General is the only thing that reaches the network after the model is on disk.

Measured on the installed daemon sitting idle: 0.0% CPU. Every remaining timer in the tree belongs to something happening — the menu's elapsed counter while a session records, the pill's auto-dismiss while a pill is up, the session stall check, the config watcher's debounce, the dictation suppression tail — plus MeetingDetector, the permitted one.

No new processes

The updater unpacks in process. ZipArchive reads the central directory and inflates with the Compression framework; extraction forks nothing. Two things make that safe for bytes that have not been signature-checked yet:

  • entry names are resolved and checked for containment, so ../../bin/something is refused;
  • symlink entries are refused outright, because the containment check is lexical — a link entry creating staging/link -> /elsewhere would let every later link/… entry pass a text comparison while the write followed the link out of staging. A yap release contains no symlinks (measured: zero, in both bundle and artifact).

AppleDouble sidecars are dropped; build-release.sh already runs xattr -cr before signing, and a bundle extracted without them verifies.

Sources spawns a process in three places, all inherited from main: osascript for notifications, /bin/sh for the on_stop hook, launchctl. The update path spawns none.

Latency

Press acknowledgement is the one change. The pill used to be drawn after capture.start() returned; it is now drawn before. M4, p50 of 10 synthesized Fn presses, from beginDictation entry:

p50 min max
pill on screen (after) 1.9 ms 1.5 6.7
capture.start() returned (what it was before) 69.7 ms 63.2 134.4

Transcription untouched, and measured to confirm it: bench --audio <5 s clip> --iterations 7 on parakeet-tdt-ctc-110m gives 39 ms p50 — exactly the AGENTS.md baseline for that clip length.

Registering the update observer costs 6.5–17 ms cold (SecCodeCopySelf + SecCodeCopySigningInformation), so it happens after warm-up rather than on the launch path — but outside the do/catch, because a build whose model will not load is the one a user most wants to replace.

Upgrade path

yap 0.2's login item runs yap run --skip-doctor, an argument 0.3 does not take. Reproduced on a real machine before fixing: last exit code = 64: EX_USAGE, state = spawn scheduled — KeepAlive respawning a job that can never start.

The cask postflight rewrites ProgramArguments with plutil, then bootout + bootstrap rather than kickstart -k, because launchd reads a plist when the job is bootstrapped and not again. Verified against the reproduced loop: the job comes back up and the plist is byte-identical to a clean install. LaunchAgent.refreshIfStale() covers the DMG path — drag the new app in, launch it, login item corrected.

Known gap: someone on 0.2 with launch-at-login who upgrades by dragging a DMG and reboots without ever launching the app gets the loop, until they launch yap once or delete the plist. Homebrew — the documented install — is covered.

Verified

Against the installed app on macOS 26 unless noted. 47 tests, swift build -c release --arch arm64 clean.

  • Launch order — menu bar item up immediately, loading model…idle · hold Fn to dictate.
  • Fn row — set 🌐 to Show Emoji & Symbols → row appears with that text and the state line reads needs setup; set it back → row gone, live, no restart.
  • Missing Accessibility — ad-hoc-signed copy under a different bundle id: prompt raised once, process stays up, Grant Accessibility… row present, no relaunch loop.
  • Microphone — reset then Deny → Grant Microphone… row; reset then Allow → row gone.
  • Chord bindingcmd+shift+space hot-reloaded, menu reads idle · hold ⌘⇧Space to dictate, a synthesized chord drives a full dictation cycle, and a tail-appended tap sees 0 Space key-downs downstream against 1 with Fn bound. That is the swallow, measured.
  • Recorder capture — right ⌥ commits on release as rightOption, left Shift distinct from right, F5 alone accepted with the implicit Fn bit stripped, bare a refused with needs a modifier and nothing written, Escape cancels, ⌃Escape binds, ⌘⇧Space commits on key-down. Real NSEvents built from CGEvents, so the sided modifier bits are genuine.
  • A 0.2-spelled config"hotkey": "rightOption" hot-reloads to idle · hold Right ⌥ to dictate, file untouched.
  • Delete — recorded a session, clicked Delete on the pill → folder in the Trash, Deleted · <name> pill with Undo → folder restored with all six files. Three real clicks inside the 160 ms fade produce exactly one ⌫ trashed and no error.
  • Updater — local feed with a signed 9.9.9 build: our extractor's output is a byte-identical tree to ditto -x -k of the same archive, reports valid on disk and satisfies its Designated Requirement, passes the updater's own SecStaticCodeCheckValidity, and installs — /Applications/yap.app came up 9.9.9, signature valid, no quarantine xattr, staging cleared. Menu item disabled while recording, enabled after. Corrupted .sha256checksum mismatch, nothing staged. Ad-hoc-signed payload → signature mismatch, nothing staged.

Not verified: three click targets inside the Settings window — the recorder field's click-to-arm gesture, the launch-at-login switch, and Show Logs in Finder. Synthetic CGEvent clicks reach AppKit chrome (they close the window) but never reach SwiftUI content in an accessory app that never becomes frontmost; AXFocused stays false even after a title-bar click. Everything behind those three gestures is covered another way: the recorder's capture rules by unit test, LaunchAgent.install() by refreshIfStale on a real upgrade, and both panes visually. They need one human minute.

yap shipped as a CLI that happened to draw a menu bar. Onboarding was
`yap setup`, the login item was `yap install`, a missing grant printed
into a log file nobody reads, and the .app existed only so the build
could be notarized. This turns that around: the .app is the product and
the CLI keeps the two things a terminal is genuinely better at.

- The CLI is `run` and `bench`. `setup`, `install`, `start`, `stop`,
  `doctor`, `record` and `models` are gone, along with every `run` flag
  the Settings window already covers.
- The status item goes up before the model loads, so a first run is
  visible for the minutes it spends downloading instead of invisible.
  A press during that says so rather than recording into nothing.
- Onboarding is in the menu: rows for Accessibility, the microphone and
  a hijacked Fn key, each one click from the right Settings pane. The
  daemon no longer exits when Accessibility is missing — it watches for
  the grant and starts the tap the moment it lands, no relaunch.
- Settings gains a General pane: launch at login, updates, logs.
- The hotkey is recorded rather than picked from five options. Any
  modifier held alone, or a chord like cmd+shift+space, which is
  swallowed so the app underneath never sees it.
- The transcript pill gains Delete, to the Trash, with Undo.
- yap updates itself from GitHub Releases: checksum, then a signature
  check against the team the running code is signed by, then a menu item
  that is never offered mid-recording.

The press now acknowledges itself before the mic opens, which is the
one latency change: pill on screen 1.9 ms after key-down (p50 of 10)
against 69.7 ms before, because it used to wait out Core Audio.
Transcription is untouched — 39 ms p50 on a 5 s clip, on the number.

A 0.2 login item passes --skip-doctor, a flag that no longer exists.
That argument is ignored and the plist rewritten on the first start, or
launch-at-login would have become a relaunch loop on upgrade.
Review found four things worth fixing and one worth defending.

- `resumePending` could run before the transcript handler existed. The
  handlers were installed from their own unstructured Task while
  `loadModel` started immediately; warm-up is always slower than two
  actor hops, so nothing ever observed it, but "always slower" is not
  an ordering guarantee. They are now one chain:
  `installCoordinatorHandlers()` is awaited inside `loadModel` before
  warm-up, and the retry path re-enters it harmlessly.
- A tap that failed while Accessibility read as granted left no row at
  all: `setSetupNeeds` had already run, so the menu looked complete
  while the hotkey did nothing. `evaluateSetup` now builds the whole
  needs set — including a `hotkeyTap` row pointing at the Accessibility
  pane, which is what a stale grant actually needs — and publishes it
  once, after the tap attempt.
- README claimed "then you are offline forever", which stopped being
  true the moment yap started checking for updates. It now says what
  the network is used for and that it can be switched off.
- README listed the hotkey names in camelCase while the recorder writes
  them lower-case. It documents the canonical spelling and notes that
  parsing ignores case and separators, and the test that covers that no
  longer calls it a legacy path, because it never was one.

Recorder capture is now tested directly — right Option commits on
release, F5 alone is accepted, a bare letter is refused with "needs a
modifier" and writes nothing, Escape cancels, a chord commits on
key-down — against real NSEvents built from CGEvents, so the sided
modifier bits are the real ones. 37 tests.
Every button stayed live for the 160 ms of the fade, so a double-click
ran the action twice. On Delete that meant two "Deleted" toasts and a
second trashItem failing on a folder already in the Trash — reported to
the user as an error they did not cause. Clicking Delete and then Open
did both, for the same reason.

The claim is synchronous, in the click, because the moment before the
fade starts is the only point where the two clicks are still
distinguishable. It covers all five actions: a panel is a question, and
a question is answered once.

Three tests, each confirmed to fail with the guard removed (two
deletions instead of one; the second button answering as well) while
the single-click control keeps passing. Then the same thing against the
running app: three real clicks inside the fade on a live transcript
pill produce exactly one `⌫ trashed` and no error.
The acceptance sweep has to come back empty, and a runtime filter for
`--skip-doctor` is a compatibility shim however well it was measured.
It is gone, and the upgrade it existed for is handled where the upgrade
happens.

The failure is real and reproduced on this machine before changing
anything: a 0.2 plist against a 0.3 binary gives `last exit code = 64:
EX_USAGE` and `state = spawn scheduled`, which is KeepAlive relaunching
a job that can never start.

The cask postflight now rewrites `ProgramArguments` with plutil before
restarting, and does it with bootout + bootstrap rather than
`kickstart -k`, because launchd reads a plist when the job is
bootstrapped and not again — a kickstart relaunches the arguments it
already holds in memory, rewritten file or not. Verified against the
reproduced loop: the job comes back up and the plist is byte-identical
to a clean install.

`LaunchAgent.refreshIfStale()` stays. It is not a shim: it maintains our
own file, has no retired argument in it, and covers the DMG path — drag
the new app in, launch it, and the login item is corrected.

Known gap, stated rather than papered over: someone on 0.2 with launch
at login who upgrades by dragging a DMG and reboots without ever
launching the app gets the loop, until they launch yap once or delete
~/Library/LaunchAgents/com.terrifiedbug.yap.plist. Homebrew — the
documented way to install — is covered.
I had the recorder writing `rightoption` where yap 0.2 wrote
`rightOption`. That was my invention and it was wrong twice over: the
plan's own acceptance line says recording right ⌥ writes
`"rightOption"`, and `HotkeyMonitor.Key` was a String enum whose
`rawValue` the Settings window wrote straight into the file — so every
existing config with a right-hand modifier already contains that exact
spelling.

Two spellings for one binding is what made the second look like a
compatibility path. There is one now, and it is the incumbent. Nothing
migrates, nothing is rewritten, and case-insensitive parsing stays
exactly what `Key.init(name:)` always did — it is for the file people
edit by hand, not for a retired API.

Verified live: a config saying `"hotkey": "rightOption"` hot-reloads
into `idle · hold Right ⌥ to dictate`, and the file is left untouched.
"No new processes" is the rule, so the updater stops being an exception
to it. `ZipArchive` reads the central directory and inflates entries
with the Compression framework — stored and deflated entries,
directories, POSIX modes, and a refusal for anything else.

Two things make that safe enough to hold bytes that have not been
signature-checked yet:

- Entry names are resolved and checked for containment, so an archive
  naming `../../bin/something` is refused rather than written.
- Symbolic links are refused outright. The containment check is
  lexical, so a link entry creating `staging/link -> /elsewhere` would
  let every later `link/...` entry pass a text comparison while the
  write followed the link out of staging — before verification. A yap
  release contains no symlinks (measured: zero, in both the bundle and
  the artifact), so the class is removed rather than raced, and it
  fails loudly the day a release embeds a framework.

AppleDouble sidecars are dropped. `build-release.sh` already runs
`xattr -cr` before signing, and a bundle extracted without them
verifies — checked, not assumed.

Proof, on a real Developer ID-signed 9.9.9 build served from a local
feed: our own extractor's output is a byte-identical tree to `ditto -x
-k` of the same archive, reports "valid on disk" and "satisfies its
Designated Requirement", passes the updater's own
SecStaticCodeCheckValidity, and installs — /Applications/yap.app came
up as 9.9.9 with a valid signature and no quarantine xattr.

Sources now spawn a process in three places, all of them inherited from
main: osascript for notifications, /bin/sh for the on_stop hook, and
launchctl. The update path spawns none.
"Nothing runs while idle" names timers directly and permits exactly one
piece of background work, which is meeting detection. A 30-second work
item plus a repeating daily Timer, armed at launch because the setting
defaulted on, was neither of those things. It is gone, along with the
`updates.automatic` key, the Settings toggle, and the launch and
config-reload callsites that armed it.

What is left is the whole of the feature minus the schedule: "Check
Now" in Settings → General does one request, verifies, stages, and puts
"Update to x.y.z · Restart" in the menu. yap makes no network request
of its own after the model is on disk unless someone clicks that.

Measured after the cut, on the installed daemon sitting idle: 0.0% CPU
over a minute. Every remaining timer in the tree belongs to something
happening — the menu's elapsed counter while a session records, the
pill's auto-dismiss while a pill is up, the session stall check while
recording, the config watcher's debounce, the dictation suppression
tail — plus MeetingDetector, which is the permitted one.

An existing config keeps its `updates` block: unknown keys are left
alone by design, and reading one is not worth rewriting someone's file.
Two corrections to the previous commit.

Registering the observer in `start()` looked free and was not: touching
`Updater.shared` constructs it, and its init asks the code-signing
machinery who signed us. Measured cold, which is how it runs at launch:
6.5-17 ms, ahead of the status item that is supposed to be on screen
inside a second. It moves after warm-up, where the scheduling used to
live. Nothing is lost by waiting — `observe` replays the current state
to a new subscriber.

Then the more important half: it went after warm-up, not inside the
success branch. A build whose model will not load is the one a user
most wants to replace, and the Restart item only ever appears for a
daemon that is watching the updater — so a failed warm-up would have
left Settings able to stage an update that the menu could never offer.

`observeUpdates` also guards on already having a subscription: "Retry
Model Download" re-enters `loadModel`, and a second registration would
leave the first in the observer map firing forever.
@TerrifiedBug
TerrifiedBug merged commit 853a041 into main Sep 4, 2026
1 check passed
@TerrifiedBug
TerrifiedBug deleted the feat/gui-first branch September 4, 2026 07:30
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