Skip to content

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

Open
CelestialBrain wants to merge 11 commits into
mainfrom
rust-workspace
Open

Add a cross-platform Rust workspace, tests for both trees, and CI#1
CelestialBrain wants to merge 11 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.
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