Skip to content

Add a cross-platform Rust workspace, tests for both trees, and CI - #1

Merged
CelestialBrain merged 24 commits into
mainfrom
rust-workspace
Aug 8, 2026
Merged

Add a cross-platform Rust workspace, tests for both trees, and CI#1
CelestialBrain merged 24 commits into
mainfrom
rust-workspace

Conversation

@CelestialBrain

Copy link
Copy Markdown
Contributor

Groundwork for supporting Windows and Linux, plus tests and CI for what
already ships. Four focused commits; each carries its full reasoning in the
commit body.

This does not produce a usable app yet. Crate/app is an empty main, and
the shipping macOS build is untouched. The README deliberately says nothing
that implies Windows or Linux are supported.

What's here

  • Crate/core — the portable half, ported from Swift. The speech backend's
    wire contract is preserved verbatim: endpoint, all eight query parameters,
    x-app: vscode, keyterm packing, the 8s keepalive and 3s close grace, and
    401 → invalidate. Plus credential loading, settings, the transcript buffer,
    quota inspection, diagnostics, and the live-typing diff — which never needed
    an OS, it was portable logic in a file that also posted CGEvents.
  • Crate/platform — the trait set each OS implements, plus a windowed-sinc
    resampler standing in for AVAudioConverter. Three of the six platform
    concerns need only one implementation: cpal, enigo and global-hotkey
    cover audio, injection and hotkeys on all three platforms.
  • Swift tests — the shipping app had none. 23 tests, testing the executable
    target directly rather than splitting the app into a library and marking a
    large internal surface public just to see it.
  • Check workflow — nothing was built or tested until release before this.

Found on the way

The socket tests caught a real bug in the new code: the upgrade request was
built by hand with http::Request::builder(), which never generates
Sec-WebSocket-Key, Upgrade or Connection. Every connection would have
been refused
, looking exactly like a network failure. Never shipped — the
released app is the Swift build.

Two decisions for you

  1. An inherited bug is carried, not fixed. When the lock floor holds back a
    deletion, the live-typing bookkeeping rebuilds its copy from the target's
    prefix rather than the characters actually left on screen, so a later diff
    measures against the wrong baseline. LiveTyper.update does this today, in
    v0.1.0. I pinned it in a test named as a divergence rather than silently
    changing shipped behaviour under cover of a rewrite. Say the word and I'll
    fix it in both trees.
  2. The Linux CI leg is unverified. It has never run, so the apt packages
    cpal/enigo/global-hotkey need (libasound2-dev, libxdo-dev,
    libxkbcommon-dev, libx11-dev) are a best guess. This PR is the first
    thing that will actually tell us.

Verification

Check Result
cargo test --all 80 passed
cargo clippy --all-targets --all-features -- -D warnings clean
cargo fmt --all --check clean
swift test (on an M-series mac) 23 passed
make bundle SIGN=- + codesign --verify + --check smoke exit 0

Known limits, written down in docs/PORTING.md

Wayland refuses both global hotkeys and synthetic input to background clients
by design, so Linux targets X11/XWayland and reports reduced capability rather
than accepting a hotkey that will never fire. iOS and Android cannot do this
app's core gesture at all, at any permission level — a mobile target would be a
keyboard extension, a different product rather than a port.

Almost everything splaude does is an operating-system integration point
rather than interface that could be re-skinned: a global hotkey, finding
the focused field, typing at the cursor, capturing the microphone,
storing the credential. Each is a different API on every platform, so
supporting anything but macOS means writing those bindings again
whatever the language. Swift runs on Windows; AppKit and Carbon do not.

Of the 2,900 lines of Swift only about 470 are portable — the files
importing nothing but Foundation. Crate/core ports those: the speech
backend with its wire contract preserved verbatim, credential loading
and health classification, settings, the transcript buffer, keyterm
packing, quota inspection and diagnostics. The live-typing diff moves
there too. It never needed an OS; it was portable logic sitting in a
file that also posted CGEvents.

Crate/platform holds the trait set each OS implements, plus a
windowed-sinc resampler standing in for AVAudioConverter, which has no
portable equivalent. Filter state carries across buffers, because
resetting it per callback clicks at every boundary. Three of the six
platform concerns turn out to need only one implementation: cpal, enigo
and global-hotkey already cover audio, injection and hotkeys everywhere.

Settings move from UserDefaults to a JSON file, keeping the property
that mattered — the file and the settings window stay two views of one
thing. The hotkey is stored as a portable Alt+Space string rather than a
Carbon keycode, which is a macOS integer meaning nothing elsewhere.
Credential loading gets simpler off macOS: the existing fallback to
~/.claude/.credentials.json is where Claude Code keeps the token on
Windows and Linux, so only macOS needs a secret store.

80 tests. Ten drive the socket loop against a local WebSocket server,
which caught the upgrade request being built by hand — that skips
Sec-WebSocket-Key, Upgrade and Connection, so every connection would
have been refused with what looks like a network failure.

One divergence is carried deliberately rather than fixed under cover of
a rewrite: when the lock floor holds back a deletion the live-typing
bookkeeping rebuilds its copy from the target's prefix rather than from
the characters actually left on screen, so a later diff measures against
the wrong baseline. LiveTyper.update does the same today. A test pins
the behaviour and names it as inherited.

Crate/app is still an empty main. Nothing here produces a usable app
yet, and the shipping macOS build is untouched.
The shipping app had none. Every test in the repo was on the Rust port,
which does not run yet, so the half people actually install was the
untested half.

Tests the executable target directly rather than splitting the app into
a library first. SwiftPM has supported that since 5.5, and the
alternative means moving files into a new module and marking a large
internal surface public purely to make it visible — a real refactor of
shipping code in exchange for being able to test it.

23 tests over the three most logic-dense pure functions. Transcript
bookkeeping, which when wrong silently drops or duplicates words, the
failure hardest to notice and hardest to reconstruct afterwards. Keyterm
packing, the one place user input is rewritten before going on the wire,
where a dropped term is a word the recogniser keeps mangling with no
indication why. And credential-expiry classification, including both
sides of the ten-minute warning boundary — classify already took `now`
for exactly this reason.

They deliberately mirror the Rust suite, so the port is cross-checked
against the original rather than only against itself.
The only workflow ran on a tag, so nothing was built or tested until the
moment it shipped.

Runs formatting, clippy and the Rust tests on all three platforms, and
the Swift build, tests, bundle and headless smoke check on macOS.
Assembling the bundle is new coverage: it is the thing users install,
and a broken one previously surfaced during a release rather than before
one. Signing there falls back to ad-hoc, since no identity exists on a
runner.

The matrix does not fail fast. One platform breaking while the others
pass is the interesting case, and cancelling the rest hides which.

The Linux leg needs ALSA for cpal, libxdo for enigo, and the X11 and xkb
headers for the hotkey listener, none of which ship on the runner image.
That leg is unverified — it has not run yet, so the package list is a
best guess until it does.
The repo had a README and nothing else, so a v0.1.0 tag existed with no
changelog, and a Cargo.toml now sits beside Package.swift with nothing
saying what it is.

CHANGELOG.md starts from the tag and records the port, the tests, the
workflow, and the known issues — including the inherited live-typing
divergence, which ships today and is a decision to make rather than
something to quietly change.

docs/PORTING.md carries the port's architecture, the per-OS backend
matrix, and an explicit done/not-done split. It is also where the two
hard limits are written down: Wayland refuses both global hotkeys and
synthetic input to background clients by design, so Linux targets X11
and reports reduced capability rather than accepting a hotkey that will
never fire; and iOS and Android cannot do this app's core gesture at
all, at any permission level, so a mobile target would be a keyboard
extension — a different product, not a port.

The README gets a layout table and nothing more. The port produces no
usable binary yet, and the front door must not read as though Windows
and Linux are supported.
The Linux leg was written blind — none of the apt packages cpal, enigo
and global-hotkey need are on the runner image, so the list was a guess
until something ran it. It ran, and the guess held.

Replaces that caveat in the changelog with the one that actually
matters: the workspace builds and passes everywhere, but Crate/app is
still empty, so there is nothing to run on Windows or Linux yet.
Records the decision rather than leaving it to be rediscovered: macOS,
Windows and Linux from this workspace, with a phone or web client
deferred.

Worth stating the reason in the repo, because the blocker is not the
platforms. splaude authenticates by reading the Claude Code credential
already on the machine, and there is none on a phone or in a browser, so
the model does not extend. Speech-to-text off the desktop means a
provider key of your own, or routing many devices through one credential
on an undocumented internal endpoint — which is a much larger bet than a
local tool, and not a foundation for a synced service.

Syncing would also turn this into something that keeps dictation history
on a server, which it currently does not.
Audio capture, the push-to-talk hotkey, text injection, the focus guard
and launch at login. Three of them needed one implementation each,
because cpal, global-hotkey and enigo already cover every platform; only
the focus guard and autostart carry per-OS code.

Push-to-talk is a hold, not a tap, so the hotkey listener is only useful
if it reports both edges. It owns two threads: one holding the manager,
which on Windows is a hidden window pinned to its creating thread and
needs a message pump, and one draining the event channel. A rebind
unregisters before registering, since the id derives from the chord and
registering first would double-fire; a failed rebind restores the old
binding rather than leaving the user with no hotkey. The two crates turn
out to disagree about keyboard-types — core is on 0.8, global-hotkey on
0.7 — so the two Code types are genuinely distinct and the mapping
bridges them by their shared W3C name, erroring on a key the older
version does not know rather than silently substituting one.

Text injection carries the sharpest hazard. Push-to-talk means the
hotkey's modifier is physically held while this types, and Option+Delete
on macOS or Ctrl+Backspace on Windows eats a whole word rather than a
character - so a naive injector deletes a sentence a word at a time.
macOS is clean: a private CGEventSource detaches synthetic events from
hardware state, which is what LiveTyper.swift meant by clearing flags.
Windows and X11 expose no per-event modifier mask at all, so the only
expressible defence is asserting a real key-up for each modifier before
every event. That has two visible consequences, documented at the call
site: a hotkey layer polling key state may see the chord end early, and
a lone Alt key-up can activate a Windows menu bar. Both are better than
eating the words.

The focus guard refuses to claim confidence it does not have. Windows
answers through GetGUIThreadInfo and the window class, which only sees
the classic control set — WPF, UWP, Electron and every browser render
into one opaque HWND that says nothing about what is under the caret, so
those are Unknown, never a guess. ComboBox and the terminal hosts are
deliberately unclassified for the same reason. macOS and Linux report
unsupported: AX needs FFI nobody has written, and X11 has no notion of a
focused widget while Wayland refuses the question outright.

Autostart answers the only question that is answerable off macOS. A Run
value or an autostart .desktop is just a path some installer wrote, with
no identity attached, so "enabled" means the entry names the executable
running right now — an entry left by an old install location launches
nothing, and reporting it as enabled leaves a checked box and no app at
login.
Until now the workspace was a library with nothing attached: a tested
core, a tested platform layer, and an empty main. This is the part that
makes it an app.

A take owns three things that have to start and stop together — the
microphone, the speech socket, and the task turning transcript events
into keystrokes. Any one of them outliving the others leaves the mic
open or the socket half-closed, so they are created and torn down as a
unit, and finishing sends CloseStream rather than dropping the socket so
the server can flush a trailing utterance.

The injector gets its own thread. It sleeps between every keystroke, so
at the default interval a sentence is tens of milliseconds of blocking,
and doing that on the thread reading the socket would stall the audio
and the transcript behind the typing. Enigo is also not portable across
threads on every backend, so it stays on the thread that built it and is
spoken to through a channel. It is constructed at startup rather than at
first use, so a missing permission is a readable error instead of a
thread that silently does nothing.

Both safety checks fail open, which is the whole reason they are safe to
enable by default. The focus guard holds only when the platform is
certain the surface is not text; Unknown proceeds, because refusing every
take wherever the platform cannot introspect would make the app useless
there. The anchor holds only when it knows where the take began and can
see focus has moved since.

--check is the surface that can be verified without a desktop: it opens
no window and no microphone, reports credential, capability and settings
state, and exits 0 even when the credential is missing, so it works over
SSH and in CI. Run against this machine it finds the real Claude Code
credential, which is the first confirmation that the file path in
TokenStore is the whole story on Windows.

Only one take runs at a time. A second key-down before the first
finished is a repeat or a stuck modifier, not a request for two sockets.
The docs described Crate/app as an empty main, which stopped being true
this batch. They now say there is a binary — and, more importantly, draw
the line between what has been confirmed and what has only been
compiled.

Confirmed: splaude --check runs on Windows and reads a real Claude Code
credential out of ~/.claude/.credentials.json.

Not confirmed: the dictation loop itself, in any form. No hotkey has
been pressed, no socket opened against the endpoint, no microphone
captured — the development machine exposes no capture device over its
remote session. A green suite and a clean clippy say the code compiles
and its extractable logic is correct; they say nothing about whether
holding a key types words into another application.

Three specifics are written down rather than left to be rediscovered:
the injector's modifier defence and the two side effects it carries, the
macOS main-thread requirement that makes the current hotkey design
unusable there, and that Crate/app has no tests of its own.

The README gains one clause and no more. It is the front door, and
"builds a binary whose dictation path is not yet verified" is the most
it can honestly claim.
The hotkey listener used to own two threads and a win32 message pump,
not because it wanted them but because the app had no event loop to
borrow. That satisfied Windows, where the manager is a hidden HWND
pinned to its creating thread, and broke macOS, where the manager must
be built on the main thread and a library cannot commandeer it. So the
Mac build was blocked on an app-shaped problem wearing a platform-shaped
disguise.

main.rs now runs a tao event loop on the main thread and builds the
manager there. hotkey.rs owns nothing: no threads, no GetMessageW, no
PostThreadMessageW, no wake(). tao's loop pumps thread-wide messages, so
global-hotkey's window procedure is dispatched for free, and on macOS
Carbon's handler lands on the run loop NSApplication already provides.
The public API is unchanged; the type is now !Send on Windows, which is
the type system stating the precondition rather than a comment doing it.

The behaviour that had to survive the rewrite, and did: both edges;
Released delivered unconditionally while Pressed is filtered by
registration id, because rebinding mid-hold changes the id and a
swallowed release strands a take with the microphone live; rebind
unregistering before registering and restoring the old chord if the new
one is refused; the unregister on shutdown; and the keyboard-types
bridge, which still crosses by W3C name because core is on 0.8 and
global-hotkey on 0.7.

The tray is the first consumer of all that, and the reason it is in this
commit rather than a later one: Quit is the first thing in the process
that ever sets ControlFlow::Exit, which makes the LoopDestroyed arm
reachable for the first time. Until now that unregister was dead code —
a leaked registration holds the chord hostage from every other app.

The icon is generated rather than shipped: a mic rasterised at 32x32
with supersampling, near-white idle and systemRed while recording, over
a dark halo because Windows has no template image and a near-white mark
is invisible on a light taskbar. Red is set only once a take actually
starts; an icon that goes red for a take that failed to open would be
lying.

One thing came out of reading tray-icon rather than assuming it:
TrayIconEvent::send falls back to an unbounded channel when no handler
is installed, and Move fires per pointer motion across the icon. For a
process meant to sit there all day that is a slow leak, so a discarding
handler is installed even though nothing here wants click events.

Linux gets no tray and no GTK. tray-icon there is a hard GTK3 and
libappindicator dependency, build and run time, and still renders
nothing on a desktop with no appindicator host — which stock GNOME is.
The manifest excludes it by target with the reasoning recorded in place,
the dependency tree for the Linux target confirms it is absent, and the
no-tray configuration was compiled by forcing every gate off rather than
merely inspected.

Windows 11 files new tray icons into the hidden-icons overflow, where a
user who just started the app will not find them — which is exactly what
happened. So the app now says where the icon went and how to pin it.
That line is the point of the feature; the icon is what it points at.

Nobody has seen any of this. No tray icon has appeared, no menu opened,
no hotkey pressed. It compiles, it is gated, and its pure parts are
tested.
The event loop added for the macOS hotkey fix pulls GTK3 and D-Bus on
Linux, and the runner image has neither, so the ubuntu leg died in the
build script for libdbus-sys before it compiled a line of splaude.

Worth recording where those came from, because it is the opposite of
what the manifest comment implies: tray-icon is excluded on Linux
precisely to keep GTK off it, and then tao brought GTK anyway. The
exclusion still avoids libappindicator, but the heavy dependency it was
written to avoid is already there via the event loop.

That is a design question, not a CI question, so this commit only
unblocks the runner. The question it raises: Linux may not need the
event loop at all. global-hotkey spins its own thread on X11 — the pump
is a Windows requirement and the main run loop a macOS one — so a
cfg-gated loop would keep both GTK and D-Bus off Linux entirely rather
than installing headers to compile something that platform does not use.
Three rounds of feedback from an actual taskbar, plus the five menu
items that were already implemented and simply had no caller.

The icon is now the shipped mark rather than a placeholder glyph: the
rounded square, the terracotta face, the white mic, the shallow
top-to-bottom lift. Proportions come from Script/makeicon.swift, which
renders the .icns, so the two builds draw the same thing — with two
deliberate departures, both because that file targets a Dock icon and
this is a 16-pixel tray icon.

The glyph is 600/1024 rather than 430/1024. At the shipped proportion
the mic rendered as a featureless white block: cradle, stem and base all
merged. Growing it is the only lever that survives the downscale.

The inset is 8/1024 rather than 100/1024, because the platforms disagree
about who owns the padding. A Dock icon brings its own — macOS lays the
bitmap out edge to edge and the art insets itself. Windows does the
reverse, spacing tray icons for you and expecting a filled bitmap, so
carrying the Dock margin across drew splaude at about four fifths of its
neighbours. It read as a small mark rather than a small icon, which is
exactly how it was reported.

The face now doubles as the level meter, filling bottom-up from a dark
red to the recording red across five steps. The Swift build swaps
between two symbols; putting the level in the face instead means a
silent take reads as recording first and quiet second, so a dead
microphone is visible at a glance rather than after a take that produced
nothing. That matters more here than on macOS, because the audio path
has never run against real hardware — the meter is the cheapest probe
anyone has for whether the mic opened at all. Quantising to five steps
keeps a per-buffer level from causing a per-buffer rasterisation, and
the audio thread dedups again before it sends.

The menu gains what the Mac menu already had. None of it is new
capability: Health::headline, QuotaWatch, autostart::set,
diagnostic::path and the transcript text were all built and unreferenced
— the work was wiring, not implementing. Credential health is polled off
the main thread because reading a secret store can block, and re-polled
when a take fails so a dead credential surfaces at the failure instead
of up to five minutes later. It renders nothing at all when there is
nothing to say. Launch at login reconciles the machine to the stored
intent at startup, which also repairs an entry left pointing at a moved
install, and only persists the intent after the registry write actually
succeeded — a checked box over a failed write is a promise the app will
not keep.

arboard is added for the transcript copy, gated to non-Linux beside
tray-icon and built without default features so the image crate stays
out. Shelling to clip.exe was the alternative; it mangles non-ASCII and
flashes a console.

Seen working on Windows: the icon, its tooltip, and the menu. Not seen:
the meter moving, the clipboard receiving text, Reveal Log landing on
the right file, or the checkbox surviving a restart. And still nothing
of the dictation path itself — no microphone has opened, no socket has
carried audio, no keystroke has been injected by this build.
The macOS hotkey problem was written up here as an open design fault —
global-hotkey wanting its manager on the main thread, the listener
spawning its own instead. That was fixed by the event loop two commits
ago, and a doc describing a resolved blocker as active is worse than one
that is merely behind: it sends the next person to solve a solved
problem.

So it is recorded as resolved, with the shape of the mistake kept: it
was never a platform problem. The listener spawned threads because the
app had no event loop to borrow, and the fix belonged in the app, not in
the platform layer.

Also corrects two claims that stopped being true. Crate/app is no longer
untested — it has 17. And the interface is no longer unwritten; the tray
exists, is drawn from makeicon.swift's own proportions, and has been
seen rendering on Windows along with its tooltip and menu.

The Linux dependency finding is written down rather than left in a
commit message, because it inverts what the manifest implies. tray-icon
is excluded on Linux specifically to keep GTK out, and then tao brings
GTK and D-Bus anyway — so the exclusion buys less than it appears to,
and whether Linux needs the event loop at all is genuinely open, since
global-hotkey spins its own thread on X11.

What has not moved: no microphone has opened, no socket has carried
audio, no keystroke has been injected. Everything confirmed this batch
is chrome — icon, tooltip, menu. The verified/not-verified split in
PORTING.md is the part of these docs worth keeping honest, and it still
says so.
Reported: dictating through Microsoft Remote Desktop from a Mac to a PC
produces a continuous run of `a` instead of words, one per character.

LiveTyper posts each chunk as a CGEvent on virtualKey 0 carrying a
unicode payload. That is deliberate and it is what makes typing
layout-independent — the character rides in the payload, so Dvorak and
AZERTY land the same text as ANSI. Native apps read the payload.

But kVK_ANSI_A is 0, which Setting.swift already says in the comment
explaining why the hotkey default tests for presence rather than
truthiness. So every keystroke splaude posts is, at the keycode layer,
the A key with a note attached describing what it really is. A remote
desktop client never reads the note: it re-encodes keyboard input into
scancodes for the wire, sees keycode 0, and sends `a`. The app depends
on this property in the other direction too — the Return watcher
distinguishes its own output from a real keypress precisely because
LiveTyper's characters all arrive as virtual key 0.

No new delivery path was needed. Buffering and pasting at the end is
already what liveTyping=false does, and TextInserter's paste rides
kVK_ANSI_V, a real keycode that survives translation. So this only
chooses the existing path per take: when the frontmost app is one that
re-encodes by keycode, isTypingLive comes up false.

Matching is on bundle identifier rather than app name, because names are
localised. The shipped list covers Microsoft Remote Desktop, Citrix,
VMware Fusion, Parallels, Screen Sharing, Apple Remote Desktop,
TeamViewer, AnyDesk and RustDesk; identifiers that could not be
established with confidence were left out rather than guessed, since a
wrong string silently never matches and looks exactly like the bug still
being present. A test asserts the shipped list is well formed for the
same reason.

It composes like keyterm — pasteOnlyApp is the built-in list plus the
user's, so adding a client never displaces the defaults:

    defaults write com.bygelo.splaude pasteOnlyApp -array com.example.client

Engaging is logged and shown in the menu. Live typing stopping silently
would be indistinguishable from it being broken, which is the failure
mode this app is careful about everywhere else.

Verified on an M-series Mac: builds clean, 32 tests pass.
The Swift side of this bug was fixed two commits ago; this is the same
fix for the port, which inherited the fault exactly.

enigo's text() delivers characters as a unicode payload on keycode 0 —
KEYEVENTF_UNICODE with VIRTUAL_KEY(0) on Windows, a CGEvent on
virtualKey 0 on macOS. That is what makes typing layout-independent and
it is correct for native apps, which read the payload. A remote desktop
client re-encodes keyboard input into scancodes for the wire, reads the
keycode, and transmits whatever key 0 is.

Detection came first: focus::executable() names the process behind the
foreground window, and Setting::keycode_app composes a built-in list of
clients with the user's own, the way keyterm does. A take aimed at one
of them stops live-typing.

That alone fixed nothing, which the work that added it said plainly.
Falling back to the buffered path still delivered through text(), so
fifty characters went out through the same broken mechanism in one burst
instead of a rewrite loop — still fifty wrong characters. The Swift app
does not have this problem because liveTyping=false routes through
TextInserter, which pastes with a real kVK_ANSI_V; the port had no paste
path at all, only type_text and backspace.

So Injector::paste puts the text on the clipboard and sends the chord as
real keycodes, which is the whole point — a keycode survives
re-encoding where a payload does not.

Two details in that chord are load-bearing and neither is obvious.

Modifiers are cleared once before the chord opens and never inside it.
Clearing before is required, because push-to-talk means a physical
modifier is down and the OS resolves synthetic events against global
keyboard state, so a held Alt would make our Ctrl+V into Ctrl+Alt+V.
Clearing inside would be fatal: the modifier list contains Control, the
very key the chord holds, so the per-event pattern the rest of the file
uses would release Ctrl between the press and the V and deliver a bare
v — losing the take while paste still returned Ok.

The V is a raw keycode, not Key::Unicode('v'). enigo resolves a unicode
key through the active layout and, on a layout with no v — Russian,
Greek, Hebrew — falls back to entering it as unicode text. That is the
keycode-0 payload this path exists to escape, so the fix would have
quietly become the bug again for exactly the users least likely to be
tested against.

The clipboard is snapshotted and restored after a settle, matching the
Swift restoreDelay. arboard is built without default features, so the
snapshot is text only: if the clipboard held an image the restore is
skipped and the dictated text stays there. That is stated rather than
papered over — the old contents are already gone by then, and leaving
the user's words beats leaving it empty.

A failed paste falls back to typing and logs. Wrong text beats no text.

Not proven fixed: nobody has dictated into an RDP window with this
build. The likeliest remaining hole is clipboard redirection being
disabled in the session, where Ctrl+V lands on a remote clipboard that
never received the text, paste returns Ok, and the fallback never fires.
Two dictations went wrong today, and both traced to the same place: the
injector releases held modifiers before every synthetic event, because
push-to-talk means one is down and Ctrl+Backspace deletes a word.

Releasing Alt also stops Windows matching the registered chord. The
still-held Space stopped being consumed by RegisterHotKey and started
auto-repeating into the take: a sentence came back shot through with
spaces, breaking words apart — "pressing" arrived as "pr essing". The
recogniser's own transcript was clean, so the damage was entirely on
this side of the wire.

The obvious fix is to keep the binding's own modifier down while
clearing the others, and it works: the chord keeps matching and the
bound key stays suppressed. But it leaves Alt held while the app types,
and Alt+Backspace is undo in a great many applications — so every
correction the live-typing diff made wiped the sentence before it.
Sentences replacing each other rather than accumulating.

Both were observed, not predicted. There is no third option and no safe
modifier to substitute: Ctrl+Backspace deletes a word, Shift uppercases
everything, Win turns each keystroke into a shortcut. A binding with no
modifier has nothing to release and nothing to inherit, which is why
Windows now defaults to a bare function key and macOS keeps the chord
the Swift build ships. Setting.swift already went out of its way to
permit a bare function key; this is that allowance turning out to be
load-bearing on a platform it was not written for.

The modifier exclusion stays. It is what makes a modified binding work
where one is wanted, and a user who picks one on Windows gets the chord
held rather than leaking — they just inherit the Alt+Backspace problem,
which is theirs to weigh.

The tests now assert the property rather than the value: on Windows the
default must carry no modifier, and the injector must spare nothing for
it. Pinning the literal string would have to be rewritten every time the
key changes and would say nothing about why it is safe.
Short dictations came back as fragments. A sentence produced "When I
looked"; another produced "change the".

The WebSocket handshake to api.anthropic.com takes 1.1 to 1.5 seconds,
which is longer than a short take. The microphone opens immediately and
its audio queues locally, so when the socket finally comes up the whole
recording floods out in a few milliseconds and CloseStream follows right
behind it. The recogniser is handed several seconds of speech and asked
to finalise at once.

The first attempt held CloseStream for as long as the audio had outrun
real time, on the theory that the server just needed wall clock. It was
measured, it engaged — the log shows it waiting the full 1749 ms — and
the transcript was still a fragment. Idle time does not substitute:
endpointing_ms and utterance_end_ms are windows over stream time, and a
burst gives the server seconds of work with none of it elapsed.

So the audio is paced instead, metered out at roughly the rate it was
recorded, never leaving the server more than a quarter second of unheard
sound. The seconds move into the flush, where the server is decoding,
rather than into a silence where it is not. The close hold survives as
the tail of that same accounting rather than as a second mitigation —
its magnitude is now a consequence of the pacing lead, not an
independent guess.

The accounting only counts audio that arrived faster than it could have
been spoken. The naive version — deadline at socket-open plus total
duration, or a plain leaky bucket — predicts a permanent debt equal to
the handshake and would have added that delay to the end of every long
take, including all the ones that already worked.

A take that never fell behind is untouched: a live microphone hands over
32 ms every 32 ms, the debt oscillates around zero, no timer is ever
armed. Interims keep arriving during a paced flush, so live typing still
works while it drains. Total added delay per take is bounded; past the
bound it degrades to flushing at full speed rather than stalling.

Not proven fixed. The socket tests drive a local server that performs no
recognition, so they pin when bytes leave this process and nothing about
what Deepgram does with them. The log line naming how far the audio
outran real time is there to make the next real take self-diagnosing. If
a paced flush still truncates, the next thing to suspect is that the
server decodes only from the point the socket opened — in which case the
fix is on the capture side, not in this loop.
splaude works on Windows now, and there is still no way to install it.
Using it means cargo run from a git checkout — which is why enabling
"Launch at login" registered a path inside target/debug, an unoptimised
binary in a build directory that stops existing after a cargo clean.

A tag now also builds release binaries for Windows and Linux and
attaches them to the same release as the macOS bundle. One workflow
rather than two, because gh release create can only be called once per
tag: a second workflow would either race this one or upload into a
release it does not own, and then the notes could not describe every
download. The version resolution moved into its own job so the zip and
the tarball cannot claim different versions.

Windows ships as a bare .exe — one self-contained file, and unzipping a
single-file archive is friction for nothing. Linux ships as a tarball
because a plain download loses the executable bit and tar carries it.

No macOS Rust binary. An unbundled binary has no TCC identity, so
Accessibility and Microphone would attach to whichever terminal launched
it, and the Rust build reads the credential from the file rather than the
Keychain — it would be a strictly worse second macOS download next to
the app that already works. check.yml still builds and tests the crate
there, so the port cannot rot on macOS.

Each binary runs --check on its own runner before packaging. That is
what --check has been for since it was written: no window, no
microphone, no event loop, exits 0 with no credential present. A failed
leg skips publishing rather than cutting a partial release.

Symbols are stripped through RUSTFLAGS on the job rather than a release
profile, so a local cargo build --release still gives you a backtrace.

Version skew warns rather than fails: the tag names the file, but
--version reports CARGO_PKG_VERSION, and a workflow_dispatch rebuild of
an older version is a legitimate reason for those to differ.

Never executed. The YAML parses, the needs graph resolves, and the
release-notes script was run locally against fake checksums to confirm
the heredoc survives block-scalar de-indentation — but this file only
runs on a tag, so the first real proof is the first release.

Still missing: the Windows executable has no embedded icon. That needs a
build.rs and a build-dependency in the app manifest.
Eight things, all of which the Swift build has had since v0.1.0.

Tap to latch: a press released inside 400 ms starts a take and leaves it
running, a second tap stops it, and holding still works. The threshold is
Hotkey.swift's, which is the one value that has shipped and been lived
with. The old one-take-at-a-time guard became a state rather than
gaining a neighbour, and the arm that used to just refuse a second socket
now does double duty — it also pins the press instant to the *first*
press, because auto-repeat would otherwise re-arm it and every long hold
would measure a few milliseconds and latch instead of ending.

Return ends the take, through a low-level keyboard hook that observes
and always calls the next hook. Swallowing Return would mean pressing it
stops the dictation and sends nothing, which is the opposite of what
someone pressing Return wants.

Distinguishing our own keystrokes from a real Return did not work the
way this commit's brief assumed. The Swift build matches on keycode,
because LiveTyper posts every character as a unicode payload on virtual
key 0 — AppDelegate.swift says so where it relies on it. enigo's Windows
path special-cases '\n' and posts a real VK_RETURN click before the
payload, so a transcript with a newline in it would type a keystroke
indistinguishable from the user submitting. The watcher keys off a
dwExtraInfo marker this app stamps instead — deliberately not
LLKHF_INJECTED, which is set for every synthetic event on the desktop
and would also ignore the Return of someone driving their keyboard
through PowerToys or an on-screen keyboard, who is a real user
submitting by any reasonable reading. The watcher also stands itself
down if the hotkey is Enter, since the hook sees the key-down before
WM_HOTKEY arrives and the take would stop itself.

Test Paste types a known string through the same injector, carve-out and
fallback a real take uses, with no microphone, socket or credential. It
is worth more than its size: injection is the least verified part of
this app and has produced every user-visible bug so far, and it turns
"I held the key and nothing happened" into a diagnosis.

A start and stop tone, synthesised through the output device cpal
already gives us, so no new dependency. Played at the end of Take::start
so a take that failed to open never sounds.

A byte-order mark no longer silently discards every setting. Notepad and
PowerShell's Out-File both write one, serde_json rejects it as "expected
value at line 1 column 1", and the whole file reverted to defaults over
three bytes the user cannot see. Hand-editing is a supported path — that
is what the module header is about — so the file has to survive the
editors people actually have.

Malformed settings are now loud and non-destructive instead of silent.
The broken text is the user's edit and it is what they need to see, so
nothing rewrites it: the parse error is named in the log, in --check and
in the menu, a reload refuses outright and keeps what is already
running, and toggling launch-at-login no longer saves defaults over a
file it could not read — which would have destroyed a keyterm list as a
side effect of clicking a checkbox.

Quota has an answer rather than a log line. The README's central claim
is that dictation does not spend Claude quota and the evidence is the
handshake headers, which until now only ever reached the log. Never
having asked and having asked and seen nothing are kept as separate
readings, because the second is evidence and the first is silence.

Edit Settings opens the file, writing the current state out first if it
does not exist, so the editor never opens nothing. Reload re-reads it,
rebinds the hotkey, reconciles launch-at-login against the machine and
recomputes whether the Return watcher would collide with the new
binding. It refuses mid-take rather than deferring: a deferred reload
has to fire from whichever of four paths ends a take, and every one
would have to be right for the binding not to move under a held key.

Reload is the first caller HotkeyListener::rebind has ever had, and it
gave the injector one too — inject.rs documented that a runtime rebind
must reach it, and there was no command for it until now, so the
held-modifier carve-out would have kept sparing the old chord.

Two rebind failure modes are logged and not recoverable: an unregister
that fails while the new registration succeeds leaves the old chord dead
for every other app, and a new binding that is refused whose restore
also fails leaves no hotkey and no way back but a restart. Neither is
new and neither is reachable from an ordinary edit.

Found and not fixed here: on Windows splaude will physically press
Return if a transcript ever contains a newline. That is enigo's typing
path, not the watcher, and it deserves its own change.

None of this has been exercised by a person. --check is verified and
stays headless; every tray item, both gestures, the hook, the tone and a
live rebind need a human at a keyboard.
Six commits had landed since the docs last matched the code, and the
gap was not cosmetic: the README still opened by calling splaude a macOS
app, PORTING still led with "the dictation loop has never run", and the
CHANGELOG had no entry for anything a Windows user can now download and
press.

The README gains a Windows install and a Windows settings section — the
JSON file, where it lives, how the menu opens and reloads it, and the
keycode-app list — because until now the only place any of that was
written down was a Rust doc comment.

PORTING's verification section is rewritten around what use taught
rather than what the suite proves. The point worth keeping is not that
Windows dictates; it is that every user-visible bug this port has had
came from someone holding a key, and not one of them was reachable from
217 green tests. A held modifier leaking spaces, Alt+Backspace undoing
each correction, a remote desktop turning a take into a run of a, a
byte-order mark discarding the settings file, a short take losing its
tail — all found by hand, all fixed. The suite is regression cover, not
evidence.

So the unproven list is now specific enough to act on: Linux and macOS
have never been launched, and on Windows both tap gestures, the Return
hook, Test Paste, the tone, the quota line and Reload Settings have
never been exercised by a person.

Test counts corrected throughout — 97 / 87 / 33 in the Rust workspace,
32 in the Swift suite. The settings window stays under "not done" with
its reason stated plainly: every candidate toolkit wants the main-thread
loop tao already owns, which makes it a migration rather than a feature.
splaude.exe showed the default Rust icon in Explorer, the taskbar and
Alt-Tab, which is the last place the Windows build looked unfinished.

The obvious fix is to commit an .ico. The reason not to is sitting in
this repo already: Script/makeicon.swift exists because a committed
image and a code renderer drift, and nobody notices until the mark is
wrong in one place. So the drawing moves out of tray.rs into a
std-only icon.rs, tray.rs keeps only what touches tray-icon, and
build.rs include!s the same module to render the .ico at build time.
One renderer, two consumers, no third copy to keep in step.

The container is written by hand — an ICONDIR, an ICONDIRENTRY per
size, and bare BGRA DIB payloads — so no image crate reaches the build
graph. Only the half that could not reasonably be hand-rolled is paid
for: getting an .ico into RT_ICON/RT_GROUP_ICON inside a PE means
emitting a COFF .res or finding rc.exe across MSVC, GNU and cross
builds, and that search is the entire substance of winresource. It is
a build-dependency, gated on cfg(windows), and never on the runtime
graph.

Each of the six sizes is drawn from scratch rather than scaled from one
bitmap, so each gets its own supersampling — which is what keeps the
mic legible at 16px, the size that made GLYPH depart from the macOS
proportion in the first place.

Two things the brief had wrong, both corrected in place:

include! rejects //! inner doc comments (E0753) — an inner attribute
cannot come out of a macro expansion. icon.rs opens with // and says
why, so nobody tidies it later and breaks the Windows build.

More importantly, `cargo tree --target x86_64-unknown-linux-gnu` does
not prove what this project has been using it to prove. Cargo matches
target-specific *build*-dependencies against the host, not --target, so
a host-gated build-dep stays visible from a Windows box. The guarantee
was re-derived rather than assumed: -e normal shows no winresource at
all, and on the Linux leg host and target are both linux, so
cfg(windows) is simply false.

winresource writes a VERSIONINFO block whether asked or not, defaulted
from CARGO_PKG_*, which would have labelled the binary splaude-app —
the crate name, not the [[bin]] name and not the only name a user ever
sees. Set explicitly.

Verified by reading the PE: six RT_ICON entries at exactly the sizes
the DIB math predicts, one 90-byte RT_GROUP_ICON, and the extracted
bitmap is the terracotta plate with a white mic. Not verified: that the
shell picks the entry I expect at every DPI, and that the CI runner
image has a resource compiler. If it does not, the Windows leg now goes
red rather than quietly shipping the default icon.

Tests: 218. The eleven geometry tests moved with the code; the new one
asserts every size in the container draws something opaque with white
mic pixels, because an all-transparent render embeds an icon Explorer
draws as nothing — indistinguishable from the bug being fixed.
The Linux and macOS legs went red on the icon commit: build.rs names
winresource unconditionally, but the dependency was declared under
[target.'cfg(windows)'.build-dependencies], and Cargo resolves a build
dependency's cfg against the host. So on a Linux leg the crate is
absent while the script still refers to it, and the script fails to
compile long before its target check can decide to do nothing.

The same host-versus-target trap the last commit documented, in a
second place. The guard was right; the manifest was not. A build
script's cfg answers for the machine doing the building — the only
place that can answer for the machine that will run the binary is
CARGO_CFG_TARGET_OS, inside the script, which is where the guard
already is.

So the dependency becomes unconditional and the script keeps the guard.
That also fixes a case the gate broke outright rather than merely
mis-declared: building for Windows from Linux, where a host gate drops
the icon and says nothing.

The cost is a build-only crate compiled on hosts that never call it. It
does not reach the runtime graph, the shipped binary, or the Linux
dependency question the tray is gated over — `cargo tree -e normal`
shows no winresource at all.
The Linux and macOS legs failed on clippy::drop_non_drop at the drop
that releases the Return watcher before the take is finished.

Clippy is right. Off Windows there is no low-level keyboard hook, so
submit::Watch is an uninhabited enum, Option<Watch> is statically None,
and dropping it does nothing at all. The lint has found a genuine no-op.

Kept anyway, with the reason written down. The ordering is the whole
content of that line: on Windows the hook sits in the OS input path for
every keystroke on the desktop, and Take::finish waits on a microphone
and a socket, so releasing the hook afterwards would leave it live for
however long that wait costs. Writing the drop only where it currently
bites would leave the next platform to grow a watcher inheriting a
silent use-after-take instead of a compile error.

Worth recording how this escaped: the local gate runs clippy on Windows
only, so a lint that fires solely on another target cannot be caught
before CI by construction. Running clippy for a Linux target from here
is not a fix — ring's build script needs a Linux C toolchain — so CI is
the gate for non-Windows lints, and a green local run is not a
checkpoint.
The Rust job linted and tested but never executed splaude. `release.yml`
does execute it — `--check` is a hard step there with no fallback — so the
first time the binary ran on Linux would have been after a tag was pushed
and every artifact was already built. That is the most expensive possible
moment to learn it panics.

Nothing suggests it does. On Linux `Capability::detect` reads environment
variables, `focus::executable` answers `None` rather than reaching for a
display, and the credential lookup is a file read. But that is a claim from
reading the source, and this port has now twice shipped a local green that
said nothing about another platform.

Debug rather than release: the failure being guarded against is a platform
one, and none of it turns on the optimization level.

Rehearsed the Windows release leg locally first — build with the workflow's
`-C strip=symbols`, `--version` parsing to 0.2.0, `--check` exit 0, package
and sha256. Also confirmed stripping does not take the icon with it: six
RT_ICON entries, one RT_GROUP_ICON and the VERSIONINFO all survive, at the
byte sizes the DIB math predicts.
@CelestialBrain
CelestialBrain merged commit 215aec7 into main Aug 8, 2026
4 checks passed
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