Skip to content

Let a local app hand Blurt text to insert - #284

Open
hsingh-aai wants to merge 2 commits into
mainfrom
claude/loopback-insert-87f12a
Open

hsingh-aai wants to merge 2 commits into
mainfrom
claude/loopback-insert-87f12a

Conversation

@hsingh-aai

Copy link
Copy Markdown
Contributor

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

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, so a second instance would race the first on the global
pasteboard. 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 the
dependency-free engine, and nothing in Sources/ changed.

Wire format

A SOCK_STREAM Unix domain socket at ~/Library/Application Support/<product>/insert.sock,
newline-delimited UTF-8:

<token>\n     first line of every connection, the token alone
<text>\n      one transcript per line

Inside a transcript, \n means a newline and \\ a backslash — a transcript is often several
lines 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 (nothing
editable 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. The
first 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:

  1. A Unix socket, not a TCP port. Nothing is bound on 127.0.0.1, so there is no port for a
    browser 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 that
    chmod fails, the listener refuses to listen.
  2. Off by default, behind an explicit switch in Settings › Advanced. A fresh install binds
    nothing, and the token is minted when the switch first goes on rather than sitting in defaults
    on every install.
  3. A shared token, sent as the first line, compared in constant time. This is defence in depth
    and is not presented as more: a process running as this user can read it out of UserDefaults
    as 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

priorText and windowTitle are passed as nil, so InsertionSeparator runs with no basis and adds
no 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 touch
the 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 a
follow-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 nothing
in BlurtEngine knows there is a socket and that enum is the roster PersistedSettings.resetAll
sweeps. They carry the host's defaultsPrefix, so a dev build and a release keep separate switches,
and Settings → Reset still clears them (InstallReset removes the whole defaults domain). The one
consequence: a UI-test launch resets the roster and not these, which is why
startLoopbackListener() returns early under UITestMode.

Verification

scripts/check.sh runs clean through every step on this Mac except the app build, which stops
on a diagnostic in App/Blurt/Blurt/Wizard/SearchField.swift that 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 fire
there; 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 findings
  • periphery scan --strict — no unused code
  • jscpd — 0 clones (it skipped in the scripted run; this Mac's Homebrew node is broken, see
    below)

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 test with the 88% engine coverage gate, the TSan and
ASan passes, xcodegen drift, and ios-check.

Beyond the gate, the listener was driven for real against a running build:

  • bad token → ERR unauthorized, then the connection closes (the reply flushes before the cancel —
    an earlier cut dropped it)
  • correct token → OK
  • blank and whitespace-only lines → ERR empty, connection kept
  • a 70 KiB line with no newline → ERR line-too-long, then close
  • the socket comes up as srw------- and is unlinked on shutdown
  • LoopbackInsertWire was compiled from the real source file and exercised over the escape cases
    (\n, \\, an unknown escape, a trailing lone backslash) and the four token cases

Not exercised live: the final KeyInjector.insert against a real Accessibility grant — the build
driven here was unsigned, so it reported ERR insert-failed, which is the correct mapping. That leg
is the engine's existing paste path, called exactly as the dictation pipeline calls it.

Unrelated, but worth knowing

This Mac's Homebrew node is broken (libsimdjson.33.dylib missing), which takes prettier,
markdownlint and design-sync.sh down with it — all three failed the first check.sh run for that
reason alone. brew reinstall node should fix it. The run above used a shim pointing those tools at
the healthy ~/.local/node.

🤖 Generated with Claude Code

hsingh-aai and others added 2 commits November 2, 2026 16:04
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

No deployments
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