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