Repository navigation
Let a local app hand Blurt text to insert - #284
Open
hsingh-aai wants to merge 2 commits into
Open
hsingh-aai wants to merge 2 commits into
hsingh-aai wants to merge 2 commits into
Conversation
Another process on this Mac can hand Blurt a finished transcript over a Unix domain socket, and Blurt inserts it through the very injector a dictation lands through. The motivating client is a pocket recorder that transcribes on its own and relays the text over USB; today a script on the Mac re-implements the careful part — the leading-space decision, the focus pinning — with the clipboard and a synthesized Cmd-V. No audio crosses this seam. `AppCoordinator` hands the listener the pipeline's own injector, not a second one: the spacing memory, the pasteboard save/restore and the chain that serializes two pastes are all `KeyInjector`'s per-instance state. Off by default behind a Settings switch, authenticated with a token the client sends as its first line, and bound to a socket under ~/Library (0700) chmodded 0600 rather than a TCP port — a surface that types into whatever the user has focused is aimed at their terminal and their password manager, so it is not a default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
swift-format's DoNotUseSemicolons flagged the default arm of the unescape switch. The Xcode 27 swift-format on the machine this was written on does not report it; CI's does, which is the one that counts. The project file drifted for a reason worth recording: XcodeGen names the local package reference (`path: ../..`) after the directory holding the repo, and in a git worktree that is the worktree's name, not `blurt`. A project generated inside `.claude/worktrees/<name>` therefore never matches the one CI regenerates. This one was generated from a throwaway worktree at a path ending in `blurt`, so the only entries that differ from main are the four new Loopback sources and their group. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets another process on this Mac hand Blurt a finished transcript, which Blurt inserts through the
same path a dictation lands through. The motivating client is a pocket recorder that transcribes on
its own and relays the text over USB; today a Python script on the Mac re-implements the careful
part — the leading-space decision, the focus pinning — with the clipboard and a synthesized ⌘V.
This stops it having to.
No audio crosses this seam. Blurt is not a transcriber here; it is the thing that knows how to put
words where you are typing.
The seam
AppCoordinatorhands the listener the pipeline's own injector, not a second one. The spacingmemory, the pasteboard save/restore and the chain that serializes two pastes are all
KeyInjector's per-instance state, so a second instance would race the first on the globalpasteboard. A handed-over line therefore inherits the focus pinning, the editable-target check, the
AX-opaque-app allowance, and the serialization against a Mac-side dictation landing at the same
moment.
Everything lives in
App/Blurt/Blurt/Loopback/. Nothing about a socket belongs in thedependency-free engine, and nothing in
Sources/changed.Wire format
A
SOCK_STREAMUnix domain socket at~/Library/Application Support/<product>/insert.sock,newline-delimited UTF-8:
Inside a transcript,
\nmeans a newline and\\a backslash — a transcript is often severallines and the bare newline is the frame. Any other backslash pair passes through untouched.
Blurt answers one line per line received:
OK(authenticated, or pasted),COPIED(nothingeditable was focused, so the text is on the clipboard —
KeyInjector's own behaviour, not a loss),or
ERR <reason>—unauthorized,not-utf8,line-too-long,empty,insert-failed. Thefirst three also close the connection, after the reply has flushed.
The next read is armed only once the current line has been inserted, which is both the ordering
guarantee and the backpressure.
Security posture
An always-on local surface that types into whatever the user has focused is aimed at their
terminal, their password manager and their messages. Three layers, weakest claim last:
127.0.0.1, so there is no port for abrowser page or another machine to reach. The socket is a file and the kernel gates it: it sits
under
~/Library(0700 on macOS) and the node is chmodded 0600 the moment it is ready — if thatchmod fails, the listener refuses to listen.
nothing, and the token is minted when the switch first goes on rather than sitting in defaults
on every install.
and is not presented as more: a process running as this user can read it out of
UserDefaultsas easily as Blurt can. What it buys is that a client has to be configured on purpose, so
nothing reaches the injector by stumbling onto a socket, and revoking access is a button
(Regenerate restarts the listener) rather than a reinstall.
What none of it defends against is code already running as this user that wants in — on macOS that
is a wide door regardless, since the same process could request Accessibility itself. The switch is
what keeps the door shut for everyone who has no use for the feature.
Also bounded: 64 KiB per line before its newline arrives, 4 concurrent connections.
One deliberate gap
priorTextandwindowTitleare passed as nil, soInsertionSeparatorruns with no basis and addsno leading space between consecutive handed-over transcripts. The Accessibility reads that fill
them are
FocusCapture's and internal to the engine, and the brief for this change was not to touchthe engine — reimplementing them in the shell would mean a second copy of the checked CF downcasts
and of the password-field guard, which is exactly the duplication this change exists to remove.
Making
FocusCapture.captureFieldContext()reachable from the host would close it. Worth afollow-up if two device sentences running together is annoying in practice; the wrong fix is to have
the sender guess at a leading space, which is the thing being retired here.
Settings
Advanced grows a "Local insertion" section: the switch, and — once on — a Socket row (path +
Copy Path) and a Token row (Copy, Regenerate). The token is never drawn, only copied: a settings
window is a screen-shared window.
Its two defaults keys are the app's rather than cases in the engine's
DefaultsKey, since nothingin
BlurtEngineknows there is a socket and that enum is the rosterPersistedSettings.resetAllsweeps. They carry the host's
defaultsPrefix, so a dev build and a release keep separate switches,and Settings → Reset still clears them (
InstallResetremoves the whole defaults domain). The oneconsequence: a UI-test launch resets the roster and not these, which is why
startLoopbackListener()returns early underUITestMode.Verification
scripts/check.shruns clean through every step on this Mac except the app build, which stopson a diagnostic in
App/Blurt/Blurt/Wizard/SearchField.swiftthat this branch does not touch:Xcode 27.1 added "Storing a closure in '@entry var focusHistorySearch' may invalidate dependents…",
and the app target compiles warnings-as-errors. CI builds on
macos-26, so it should not firethere; worth a separate fix before anyone's local Xcode catches up. The two checks that sit behind
that build were run by hand with the warning demoted, and both are clean:
swiftlint analyze --strict— no findingsperiphery scan --strict— no unused codejscpd— 0 clones (it skipped in the scripted run; this Mac's Homebrewnodeis broken, seebelow)
Everything else green: dependency and sound-catalog guards, shell portability, the settled-decision
invariants,
swift-format,swiftlint, actionlint, zizmor, prettier, markdownlint, shellcheck,shfmt, ruff, pytest, release-lib tests,
swift testwith the 88% engine coverage gate, the TSan andASan passes, xcodegen drift, and
ios-check.Beyond the gate, the listener was driven for real against a running build:
ERR unauthorized, then the connection closes (the reply flushes before the cancel —an earlier cut dropped it)
OKERR empty, connection keptERR line-too-long, then closesrw-------and is unlinked on shutdownLoopbackInsertWirewas compiled from the real source file and exercised over the escape cases(
\n,\\, an unknown escape, a trailing lone backslash) and the four token casesNot exercised live: the final
KeyInjector.insertagainst a real Accessibility grant — the builddriven here was unsigned, so it reported
ERR insert-failed, which is the correct mapping. That legis the engine's existing paste path, called exactly as the dictation pipeline calls it.
Unrelated, but worth knowing
This Mac's Homebrew
nodeis broken (libsimdjson.33.dylibmissing), which takes prettier,markdownlint and
design-sync.shdown with it — all three failed the firstcheck.shrun for thatreason alone.
brew reinstall nodeshould fix it. The run above used a shim pointing those tools atthe healthy
~/.local/node.🤖 Generated with Claude Code