Add a cross-platform Rust workspace, tests for both trees, and CI - #1
Open
CelestialBrain wants to merge 11 commits into
Open
Add a cross-platform Rust workspace, tests for both trees, and CI#1CelestialBrain wants to merge 11 commits into
CelestialBrain wants to merge 11 commits into
Conversation
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.
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.
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/appis an emptymain, andthe 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'swire contract is preserved verbatim: endpoint, all eight query parameters,
x-app: vscode, keyterm packing, the 8s keepalive and 3s close grace, and401 → 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-sincresampler standing in for
AVAudioConverter. Three of the six platformconcerns need only one implementation:
cpal,enigoandglobal-hotkeycover audio, injection and hotkeys on all three platforms.
target directly rather than splitting the app into a library and marking a
large internal surface
publicjust to see it.Checkworkflow — 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 generatesSec-WebSocket-Key,UpgradeorConnection. Every connection would havebeen refused, looking exactly like a network failure. Never shipped — the
released app is the Swift build.
Two decisions for you
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.updatedoes this today, inv0.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.
cpal/enigo/global-hotkeyneed (libasound2-dev,libxdo-dev,libxkbcommon-dev,libx11-dev) are a best guess. This PR is the firstthing that will actually tell us.
Verification
cargo test --allcargo clippy --all-targets --all-features -- -D warningscargo fmt --all --checkswift test(on an M-series mac)make bundle SIGN=-+codesign --verify+--checksmokeKnown limits, written down in
docs/PORTING.mdWayland 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.