Repository navigation
windows_port_plan
Status: Milestones 0–1 delivered in v0.5.0; 2–4 outstanding · Target: Windows 11 (x64 + ARM64), Windows 10 22H2 as a best-effort floor · Baseline: VoxCtrl 0.4.0
Everything below the line was written as a proposal against 0.4.0. Milestones 0 and 1 shipped in v0.5.0; the audit is left as written so the reasoning stays readable, with corrections noted here.
Done
-
B1, the hotkey vocabulary.
rdevis gone. The backend is aWH_KEYBOARD_LLhook written againstwindows-sys, keyed by scan code, incrates/voxctrl-hotkeys/src/windows/. The scan-code table and the suppression rules live insrc/win_keys.rs, deliberately outside the platform gate so their tests run on the Linux lane. -
B2, text injection.
SendInputwithKEYEVENTF_UNICODE, in the newvoxctrl-winputcrate, used by both theinjecttarget andvoxctrl-inject. Long text falls back to a clipboard paste that restores the previous clipboard. Both PowerShellSendKeyscall sites are gone. -
B3, the privacy claim.
Backend::WindowsHookis out ofis_private(), an invariant test holdssees_raw_keys() == !is_private()for every backend, and the Hotkeys tab anddocs/privacy.mdnow say what the hook sees. -
CI. The Windows
cargo checkno longer excludes anything and runs--all-targets; there is a Windowscargo testlane. -
Release. The
windows-webgpumatrix row is enabled, publishingVoxCtrl-windows-x86_64-webgpu.exe. The separatewindows-cpurow was merged into it once released sizes showed the WebGPU feature costs nothing (17.1 MB vs. 17 MB at v0.6.3) — same rationale as the single Linux Vulkan build below. - GPU Acceleration. Direct3D 12 acceleration for Moonshine delivered via ONNX Runtime's WebGPU provider. S1-mini sidecar runs via Vulkan / CPU.
- First-run onboarding. The 7-step Setup Wizard is fully operational on Windows.
- Bug Reporting. Built-in telemetry-free, redacted diagnostics reporting implemented on Windows.
- Overlay console window and focus stealing; the placeholder files named like CUDA DLLs; the MCP address advertised on Windows; the Piper installer that reported success while doing nothing.
Corrections to the audit below
-
B4 was wrong.
voxctrl-appwas predicted to need "a batch of ordinary compile errors" fixed. It did not — checked against a real Windows target, it compiled as written. The excluded-crates CI was still hiding everything, and the other three blockers were real; this one was not. -
The CUDA DLLs are in the repo, contrary to a note in the original PR: they
were 50-byte text files with
.dllnames, and the NSIS hook copied them next tovoxctrl.exe. Removed rather than shipped.
Still outstanding — notifications via an AUMID, autostart, the named-pipe delivery type
and its ACL, Piper's Windows auto-install, code signing, and the voxctrl-platform extraction.
VoxCtrl is already written in a portable stack — Rust + Tauri 2 + Svelte + Slint — and it already contains Windows code paths. That makes the port look closer to done than it is. The honest verdict from auditing the tree:
Windows is currently a compile target, not a supported platform. The Windows-specific code that exists has never been built end to end, never been run, and three of its core paths are provably wrong by inspection. Global hotkeys cannot fire at all on Windows today, dictated text is corrupted on the way out, and the Hotkeys tab shows a padlock claiming VoxCtrl does not read the keyboard on a backend that reads all of it.
Two pieces of CI configuration say this out loud:
-
.github/workflows/ci.yml:62— the Windowscargo checkjob runs--workspace --exclude voxctrl-inference --exclude voxctrl-app. The two crates excluded are the speech engine and the entire application. Everything Windows-specific insrc-tauri/has therefore never been type-checked. -
.github/workflows/release.yml:99-113— thewindows-cpurow of the release matrix is commented out, with the note "nobody can test what it produces at the moment".
So the plan below is not "add Windows support to a cross-platform app". It is:
- Make it build. Turn on the excluded crates in CI and fix what falls out.
- Fix the broken seams — hotkeys and text injection, which make the app non-functional rather than merely degraded, and a privacy indicator that currently tells Windows users the opposite of the truth.
- Close the parity gaps — TTS install, notifications, autostart, overlay, IPC, updater.
-
Refactor the seams from scattered
#[cfg]blocks into a small platform trait layer, so parity does not rot again. - Ship it — signed installer, release matrix, docs, and a Windows CI lane that would have caught all of the above.
Estimated effort: ~8–11 engineer-weeks, sequenced into five milestones. Milestones 0–1 (~2 weeks) get to "it launches and dictates correctly on Windows 11"; the remaining three are parity, shipping, and hardening.
The workspace is 13 crates and ~30k lines of Rust. Only a small fraction is
platform-bound. Counting target_os occurrences across the tree gives 104,
with over 70% of them in six files. The portable majority — config, routing model,
gesture engine, text post-processing, LLM client, update version logic, the
whole Svelte UI — needs nothing.
The platform-bound surface is exactly seven seams:
| # | Seam | Linux mechanism | Windows mechanism |
|---|---|---|---|
| 1 | Global hotkeys | XDG portal / XInput2 / evdev | Low-level keyboard hook |
| 2 | Text injection |
wtype / xdotool / clipboard |
SendInput |
| 3 | Overlay window | X11 input-shape, _NET_WM_STATE_ABOVE
|
WS_EX_TRANSPARENT/LAYERED/NOACTIVATE
|
| 4 | Local IPC | Unix socket, D-Bus | Named pipe, (no D-Bus) |
| 5 | Notifications |
notify-rust → libnotify |
WinRT toast w/ registered AUMID |
| 6 | Desktop integration |
.desktop file, XDG dirs |
Start Menu shortcut, registry, known folders |
| 7 | Packaging / update | AppImage + .deb, in-place replace |
NSIS setup.exe, re-run installer |
Everything else is already portable or trivially so. The plan's central
architectural recommendation is to make these seven seams explicit — one
module per seam with a platform-neutral interface — instead of letting
#[cfg(target_os)] spread further through business logic (it is currently in
targets.rs, commands.rs, overlay.rs, pipeline.rs, and stop_key.rs).
Legend: ✅ works ·
The Windows backend translates rdev keys into binding names with:
fn key_name(key: &rdev::Key) -> String {
format!("KEY_{key:?}").to_ascii_uppercase()
}Bindings are stored in evdev vocabulary — that is the canonical naming
across the whole app. crates/voxctrl-routing/src/loader.rs:408 seeds the
default binding as ["KEY_LEFTMETA", "KEY_SPACE"], and the settings UI
generates the same vocabulary from browser key codes
(src/lib/Settings/HotkeysTab.svelte:433, mapBrowserKeyToEvdev).
rdev's Key enum uses a different vocabulary entirely. The Debug names are
MetaLeft, ControlLeft, KeyA, Num1, Escape. So the function produces:
| Physical key |
key_name() emits |
Bindings contain | Match? |
|---|---|---|---|
| Left Super | KEY_METALEFT |
KEY_LEFTMETA |
❌ |
| Left Ctrl | KEY_CONTROLLEFT |
KEY_LEFTCTRL |
❌ |
| A | KEY_KEYA |
KEY_A |
❌ |
| 1 | KEY_NUM1 |
KEY_1 |
❌ |
| Escape | KEY_ESCAPE |
KEY_ESC |
❌ |
| Space | KEY_SPACE |
KEY_SPACE |
✅ |
Space and a handful of others collide by luck. Every modifier and every
letter is wrong, so the shipped default hotkey (Super+Space) can never
activate, and neither can almost anything a user records. The gesture engine,
KeyMatcher, and health reporting are all fine — they are fed garbage.
This is also invisible in testing: ListenerHealth is marked healthy
(windows.rs:26-30) as soon as the thread starts, so the UI reports the hotkey
backend as working while no key ever matches.
The inject/type target on Windows shells out to PowerShell and calls
System.Windows.Forms.SendKeys::SendWait on the transcribed text.
The code base64-encodes the payload — carefully, and with a good comment —
so that no PowerShell metacharacter can escape the string. That defends against
PowerShell parsing. It does nothing about SendKeys' own escape syntax,
which is applied to the decoded string. In SendKeys, + ^ % ~ ( )
{ } [ ] are all metacharacters:
| Dictated | SendKeys types |
|---|---|
50% of users |
50 then Alt-chords the rest |
f(x) = a + b |
f, x, = a, b — parens and + consumed |
array[0] |
array0 |
Ctrl^C |
Ctrl + literal Ctrl chord |
For a dictation app whose primary output is arbitrary prose, this is a
correctness bug, not a polish item. Separately, the design spawns a
powershell.exe process per dictation (~200–500 ms cold), which is a
significant share of the perceived end-to-end latency.
crates/voxctrl-inject/src/lib.rs:94 has the same shape for the clipboard-paste
path, with a comment that says it out loud: "PowerShell fallback while we wire
up windows-rs SendInput".
crates/voxctrl-hotkeys/src/health.rs contains a direct self-contradiction
about Backend::WindowsHook, 126 lines apart:
// health.rs:78 — WindowsHook is grouped with the backends that read the keyboard
pub fn sees_raw_keys(self) -> bool {
matches!(self, Self::X11 | Self::Evdev | Self::WindowsHook)
}
// health.rs:202 — ...and simultaneously with the backends that do not
/// Shortcuts are working without VoxCtrl having any access to input devices.
pub fn is_private(&self) -> bool {
matches!(self.backend(), Backend::Portal | Backend::WindowsHook | Backend::MintDbus)
}Both cannot be true. WH_KEYBOARD_LL is a system-wide hook: it sees every
keystroke on the machine, exactly like the evdev and X11 backends — whose own
tests in this same file say so (health.rs:316 "reading evdev means seeing
every keystroke", health.rs:386 "raw X11 key events are every keystroke"),
and both of which correctly report is_private() == false.
is_private() drives a user-facing privacy claim. docs/development.md:463
documents the intent plainly:
is_private()is true only for the portal and the Windows hook, the two paths where VoxCtrl receives its own shortcuts and no raw keystrokes. The UI states this in plain language, and only when true.
The Windows hook is not such a path, and the claim reaches the user's screen.
src/lib/Settings/HotkeysTab.svelte:658-670 styles the Hotkeys banner green and
prints a 🔒 padlock whenever is_private is true — which, for
backend === "windows_hook", it always is.
Compare the accompanying detail text set in commands.rs. The X11 backend, which
sees exactly what the Windows hook sees, discloses it (commands.rs:974-978):
"…in this mode every keystroke passes through…"
The Windows hook says only (commands.rs:971-973):
"Global shortcuts are active."
So a Windows user is shown a padlock, a green "private" banner, and no disclosure, on a backend that reads every key they press. For a privacy-first application this is the most serious defect in the audit — more so than B1 or B2, which at least fail visibly.
Fix: remove Backend::WindowsHook from is_private(); add a test asserting
sees_raw_keys() == !is_private() for every backend so the two can never diverge
again; and give the Windows hook a detail string modelled on the X11 one, saying
what it sees. Then amend docs/privacy.md and docs/development.md:463 to state
what the Windows backend sees and what VoxCtrl does with it (matches it against
bindings, discards it, never logs or transmits it).
This is a release blocker for any Windows build, and the one-line
is_private() fix should land in Milestone 0, ahead of everything else.
ci.yml:62 excludes voxctrl-app and voxctrl-inference. Everything under
src-tauri/src/ — tray, overlay sidecar spawn, installer, updater, window
management, stop_key, pipeline — is unverified on Windows. Expect a first
build to surface a batch of ordinary compile errors; they are cheap to fix but
must be found before anything else can be planned around.
voxctrl-inference additionally requires the whisper.cpp C++ build under MSVC,
which is where the real build risk sits (§4.4).
| Gap | Evidence | Impact |
|---|---|---|
crates/voxctrl-tts/src/piper.rs:192-224 — download_piper_binary() body is #[cfg(unix)]; on Windows it returns Ok(()) having done nothing |
Piper is the default engine (voxctrl-config/src/lib.rs:308). On Windows it silently no-ops and the user must hand-place piper.exe per docs/windows_build.md
|
|
crates/voxctrl-tts/src/engine.rs:389, inflect/phonemes.rs:42 — both probe espeak-ng on PATH
|
Two TTS engines (eSpeak, Inflect-Micro) are dead on a stock Windows box. Inflect-Micro is a default feature | |
crates/voxctrl-inject/src/lib.rs:109-129 uses notify-rust
|
Unpackaged Windows exes need a Start Menu shortcut carrying a registered AppUserModelID, or toasts are dropped with no error — or attributed to "PowerShell". Tauri's own tauri-plugin-notification is already a dependency and handles this |
|
| ❌ D-Bus targets 🐧 |
targets.rs:524-545 returns an error on non-Linux |
Correct behaviour, but the UI still offers the target type. Needs a Windows counterpart (§4.5) |
❌ pipe target (FIFO) 🐧 |
targets.rs:357-395 opens a path for writing |
Windows has no mkfifo. Should map to a named pipe target |
socket target loses Unix sockets
|
targets.rs:399 — #[cfg(unix)] branch |
TCP still works. Win11 does support AF_UNIX, but Tokio does not expose it; named pipes are the idiomatic replacement |
| ❌ MCP path is hard-coded POSIX |
crates/voxctrl-mcp/src/lib.rs:17 — pub const SOCKET_PATH: &str = "/tmp/voxctrl-mcp.sock"
|
The Windows server correctly uses \\.\pipe\voxctrl-mcp (lib.rs:162), but the exported constant, the README, and docs/architecture.md still advertise the socket path to MCP clients |
| ❌ No autostart | nothing in the tree | A dictation daemon that does not start with the session is not usable day to day. Linux gets this via the .desktop file |
| ❌ No desktop integration |
src-tauri/src/installer.rs:135 setup_desktop_integration() is called only under #[cfg(target_os = "linux")] (lib.rs:286) |
Ties into notifications (AUMID) and autostart |
installer.rs:4-45 detects pacman/apt/dnf/zypper; the first-run wizard and /udev-warning window surface portal/wtype/udev concepts |
On Windows these steps are meaningless and must be replaced with Windows-relevant checks (mic privacy, WebView2, model download) | |
src-tauri/src/overlay_sidecar.rs:50 spawns the sidecar with no CREATE_NO_WINDOW, and src-tauri/src/overlay.rs has no windows_subsystem attribute (unlike main.rs:2) |
A console window will flash or persist next to the overlay | |
overlay.rs:1264-1284 relies on winit set_cursor_hittest(false) on Windows |
winit maps this to WS_EX_TRANSPARENT, which is right, but the window also needs WS_EX_NOACTIVATE so it never steals focus mid-dictation, and WS_EX_LAYERED for reliable transparency |
|
| ❌ Updater assets not published |
crates/voxctrl-update/src/install.rs:136-140 looks for -windows-x86_64.exe
|
The logic is correct and unit-tested; the release matrix simply never produces that asset (release.yml:109). Self-update is dead until the matrix row is re-enabled |
npm run predev is Linux-only
|
package.json — pkill / fuser
|
Dev-experience papercut for Windows contributors |
exec target quoting
|
targets.rs:310-316 splits the template on whitespace and spawns raw_parts[0]
|
On Windows there is no argv; CreateProcess re-joins and each program re-parses. A path with spaces, or an argument with quotes, behaves differently than on Linux |
These should be hidden, not stubbed, on Windows:
- XDG GlobalShortcuts portal, KDE
kglobalshortcutsrcsync (voxctrl-hotkeys/src/portal.rs) - X11/XInput2 backend, evdev backend,
/dev/inputdevice counting - Linux Mint Cinnamon/MATE
gsettingsshortcut integration (src-tauri/src/mint_shortcuts.rs, 645 lines — currently compiled on Windows as dead code) - udev warning window (
src/lib/Diagnostics/UdevWarning.svelte) and theudev-warningTauri window (tauri.conf.json) -
host_env.rsAppImage environment scrubbing -
pkexec-based privileged package installation - AppImage/
.debpackaging and in-place binary replacement
The gesture model is the constraint. VoxCtrl supports hold, toggle,
double_tap, and double_tap_hold (voxctrl-routing/src/models.rs:11). Hold
and double-tap require key-down and key-up transitions, plus timing. That
rules out most of the obvious choices.
| Option | Verdict |
|---|---|
RegisterHotKey (Win32) / the global-hotkey crate |
❌ Delivers a single WM_HOTKEY on press. No release event, no hold, no double-tap. Would reduce Windows to toggle only — the same degradation the Cinnamon backend accepts, and unacceptable as the primary path |
Raw Input (WM_INPUT, RIDEV_INPUTSINK) |
|
WH_KEYBOARD_LL low-level hook |
✅ Recommended. Down/up with timing, and the callback can swallow the keystroke by returning non-zero. This is what essentially every Windows hotkey daemon uses |
| UI Automation / accessibility APIs | ❌ Wrong layer |
Recommended: a low-level keyboard hook, written directly against windows-rs,
replacing rdev.
Reasons to drop rdev (currently pinned at 0.5.3):
- Its author describes it as a pet project; the actively maintained line is the
RustDesk fork
rdevin. - It abstracts away exactly the two things this design needs: the scan code (the stable, layout-independent identity that maps cleanly to evdev names) and suppression (returning non-zero from the hook).
- It brings its own key vocabulary, which is the root of bug B1. Going direct removes the translation layer rather than fixing it.
Implementation requirements — each of these is a real constraint, not a detail:
-
Dedicated thread with a message pump.
SetWindowsHookExW(WH_KEYBOARD_LL, ..)requires the installing thread to run aGetMessage/DispatchMessageloop. The existingvoxctrl-rdevthread already isolates this; keep the shape, replace the body. -
The callback must be fast — under ~1 second, and in practice under ~1 ms.
Windows silently unhooks a procedure that exceeds
LowLevelHooksTimeout(capped at 1000 ms since Windows 10 1709), with no notification to the application. The current callback takes twoMutexlocks and does acrossbeamtry_recvinside the hook. Restructure so the hook does nothing but push(scancode, vk, is_down, time)onto a lock-free queue; all matching, gesture timing, and channel sends move to a worker thread. -
Detect and self-heal the silent unhook. Because Windows gives no callback,
run a watchdog: if no keyboard event has been seen for N seconds while the
session is interactive, re-install the hook and report it through
ListenerHealth. This is the Windows analogue of the existing evdev supervisor invoxctrl-hotkeys/src/linux.rs. -
Map by scan code, not virtual key. Scan codes are physical positions and
map to evdev key names essentially 1:1 — which is precisely the vocabulary
the rest of the app speaks. Build a static
scancode → "KEY_*"table. This also makes non-US layouts behave the way they do on Linux (position-based, asHotkeysTab.sveltealready assumes viaevent.code). -
Accept the two hard limits, and say so in the UI:
- Hooks do not receive input destined for a more-privileged process (UIPI). If the user focuses an elevated window, VoxCtrl's hotkey will not fire there unless VoxCtrl is itself elevated. Detect and surface this.
- Hooks never see the secure desktop (UAC prompt, Ctrl+Alt+Del,
lock screen). Nothing to be done; do not let a gesture get stuck "held"
across one — the watchdog should force-release, mirroring
KeyMatcher::clear().
-
Suppression policy. Return
1from the hook only for keys that complete a binding the user has bound, never for the whole keyboard. -
Health reporting. Add a
Backend::WindowsHookhealth path that reflects reality: hooked / unhooked / blocked-by-UIPI, replacing the current unconditional "healthy" (windows.rs:26-30).
Fallback ladder (mirroring the Linux portal → X11 → evdev structure):
WH_KEYBOARD_LL → RegisterHotKey (toggle-only, advertised as such in the UI,
same way Backend::MintDbus already advertises a reduced gesture set).
| Option | Verdict |
|---|---|
PowerShell SendKeys (status quo) |
❌ Metacharacter corruption (B2) + per-injection process spawn |
SendInput with KEYEVENTF_UNICODE
|
✅ Recommended primary. Types arbitrary Unicode independent of keyboard layout, no clipboard clobbering, works through RDP/Citrix where clipboard redirection is disabled |
| Clipboard + synthesised Ctrl+V | ✅ Recommended fallback. Faster for long text; already the app's Linux last resort. Must save and restore the user's clipboard |
UI Automation TextPattern
|
❌ Read-only by design; cannot insert |
| Text Services Framework (TSF) |
Recommended: SendInput primary, clipboard-paste fallback, both native.
Specifics that matter:
- Send each UTF-16 code unit as its own
INPUTwithKEYEVENTF_UNICODE; surrogate pairs must be sent as two units in oneSendInputbatch or emoji and CJK extension characters break. - Batch the whole string into a single
SendInputcall where possible; per-character calls interleave with real typing. - Known quirk: some terminals (notably older Windows Terminal builds) mishandle
VK_PACKET. Keep the clipboard path selectable per-target for those cases — the routing model already supports per-target overrides. - Threshold-switch to clipboard paste above ~2000 characters, where synthesising keystrokes becomes visibly slow.
- Restore the previous clipboard contents after a paste-based injection.
Prefer the enigo crate (actively maintained, cross-platform, handles the
Unicode and surrogate details) unless a direct windows-rs implementation
proves necessary for suppression interplay with the hook. Using enigo on
both platforms is worth evaluating separately — it could eventually retire the
wtype/xdotool subprocess dependency on Linux too — but that is a follow-up,
not part of this port.
Keep Slint + winit; the sidecar architecture already works cross-platform. Required Windows work:
- Add
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]tosrc-tauri/src/overlay.rs, matchingmain.rs:2, and spawn the sidecar withCREATE_NO_WINDOWinoverlay_sidecar.rs:50. - Set
WS_EX_NOACTIVATEin addition to winit'sset_cursor_hittest(false)(WS_EX_TRANSPARENT) andwith_skip_taskbar(true)— already present atoverlay.rs:1614-1618. WithoutNOACTIVATE, the overlay can take focus and the dictation lands in the wrong window. - Verify per-monitor DPI v2 awareness and multi-monitor placement; the existing positioning logic assumes a single logical coordinate space.
- Replace the X11 unmap/re-map "park offscreen" trick (
overlay.rs:1237-1259) with a plainShowWindow(SW_HIDE)on Windows — the X11 dance exists to work around compositor behaviour that has no Windows analogue.
whisper.cpp / whisper-rs (CPU): builds under MSVC; this is the safe
default and what the Windows release should ship.
Vulkan: whisper-rs-sys produces for Rust FFI) the Vulkan backend silently fails to
register in the ggml backend registry, falling back to CPU while reporting "no
GPU found". The report names whisper-rs 0.16 / whisper-rs-sys 0.15 — the
versions this workspace pins (crates/voxctrl-inference/Cargo.toml). The issue
is open and stale-labelled. Silent CPU fallback under a GPU banner is worse
than no GPU option; the app already has an honest moonshine_gpu_backend()
reporting path, and shipping this would undermine it.
CUDA: works, at the cost of a large runtime. tauri.conf.json already
declares resources/cudart64_12.dll, cublas64_12.dll, cublasLt64_12.dll —
note these files are not in the repo, so a CUDA build will fail bundling
until they are supplied. Treat CUDA as an opt-in second artifact, not the
default.
Recommended GPU story for Windows: ONNX Runtime + DirectML for the Moonshine backend. DirectML accelerates any DirectX 12 GPU — NVIDIA, AMD, Intel, Qualcomm — with no vendor SDK at build time and no heavy runtime to bundle. It is the Windows analogue of what Vulkan is for the Linux AppImage, and it fits the existing feature structure cleanly:
# crates/voxctrl-inference/Cargo.toml
moonshine-directml = ["moonshine", "ort/directml"]Caveat to record in the code comment: Microsoft has DirectML in sustained engineering, with new feature work moving to Windows ML. It is the right choice today; revisit in 12–18 months.
ort already uses download-binaries in this workspace, so no system
onnxruntime.dll is needed — but the ONNX Runtime build fetched must be the one
carrying the DirectML provider.
| Engine | Windows status | Action |
|---|---|---|
| Piper (default) | Add a Windows branch to download_piper_binary(): fetch the Windows release zip (not .tar.gz), extract with a zip reader. Requires adding a zip dependency and generalising extract_piper_archive()
|
|
| Pocket-TTS / Breeze-TTS-2 / VoxCPM2 | These now shell out to the prebuilt audiocpp_cli binary (see audiocpp.rs); download_audiocpp_binary() only knows the Linux release asset today. Add a Windows branch once a verified audio.cpp Windows Vulkan release asset name is confirmed |
|
| Inflect-Micro | ❌ needs espeak-ng for phonemization |
Either bundle an espeak-ng.exe + espeak-ng-data alongside the app (it is GPLv3 — check licence compatibility before bundling), or disable the engine on Windows with a clear message |
| eSpeak-NG | ❌ not on PATH
|
Same decision as above |
SAPI 5 / WinRT SpeechSynthesizer |
— |
Recommended addition: a TtsEngine::WindowsSapi variant. Zero install, every Windows box has voices, gives Windows a guaranteed-working default the way espeak does on Linux |
The MCP server already speaks named pipes correctly
(voxctrl-mcp/src/lib.rs:156-172) and the MCP client target already picks
the right transport per platform (targets.rs:678-694). Three fixes:
- Make
SOCKET_PATHplatform-conditional instead of a hard-coded/tmp/voxctrl-mcp.sock(voxctrl-mcp/src/lib.rs:17), and update the README anddocs/architecture.md:196so MCP clients are told the right address. -
Secure the pipe. The Unix socket is chmod
0600(lib.rs:113) precisely so other local users cannot activate the microphone. A named pipe created with default security is reachable by other users on the machine. Create it with an explicit SECURITY_DESCRIPTOR limited to the current user, and setFILE_FLAG_FIRST_PIPE_INSTANCEso a squatting process cannot pre-create the name. This is a security requirement, not a nicety. - Replace the
dbusandpipedelivery types on Windows with a singlenamed_pipedelivery type. This covers both use cases (broadcast to a listener; write to a FIFO-like channel) with one Windows-native mechanism. Add it toDeliveryType(voxctrl-routing/src/models.rs:100-115), gate it in the target editor UI, and keepdbus/pipevisible only on Linux.
These three are one work item, because on Windows they all hang off the same artifact: a Start Menu shortcut carrying a registered AppUserModelID.
-
Notifications: stop using
notify-ruston Windows.tauri-plugin-notificationis already insrc-tauri/Cargo.tomland already inpackage.json; routeshow_notification()(voxctrl-inject/src/lib.rs:109) through it on Windows. Without a registered AUMID, WinRT toasts are either dropped silently or attributed to PowerShell. - AUMID + shortcut: the NSIS installer creates the Start Menu shortcut; set the AUMID property on it and match it in the app. This makes toasts work and gives correct taskbar grouping.
-
Autostart: add
tauri-plugin-autostart(registryRunkey). Expose it in Settings → General as "Start VoxCtrl when I sign in", and make the Linux side use the same setting via.desktopautostart, so the UI is one control on both platforms. -
Windows counterpart to
setup_desktop_integration(): register file/URI associations if needed, and verify the shortcut exists on launch (repairs a user who moved the install).
The wizard is a strong part of VoxCtrl and it is currently Linux-shaped. On Windows, replace the checks rather than skipping the step:
| Linux step | Windows equivalent |
|---|---|
| Shortcut portal / evdev permissions | Keyboard hook installed? Is a competing hotkey app holding the combo? |
wtype/xdotool present |
(nothing — SendInput is always available) |
| udev warning window | Microphone privacy: Settings → Privacy & security → Microphone — apps get no mic access silently if this is off |
| Package-manager install of deps | WebView2 present (Win11 always; Win10 may need the bootstrapper) |
| espeak-ng install | TTS engine picker defaulting to SAPI or Pocket-TTS |
Model download and hotkey recording steps carry over unchanged.
-
Bundler: NSIS only.
release.yml:336-341already documents that the WiX/MSI bundler fails to harvest thevoxctrl-overlaysidecar. Shipsetup.exe; drop MSI from the advertised artifacts. -
WebView2:
tauri.conf.jsonalready setswebviewInstallMode: embedBootstrapper. Correct for a Win10 floor; on Win11 it is a no-op. - Code signing: unsigned installers hit SmartScreen with an "Unknown Publisher" wall, which for a microphone-and-keyboard-hook app is fatal to adoption. Recommended: Azure Trusted Signing (~$10/month, certificate in Azure's HSM, signs via API, no private key handling, first-class Tauri support). Note that since 2024 EV certificates no longer bypass SmartScreen instantly — reputation accrues the same way as OV, so signing early matters more than certificate tier. Tauri signs the binary before packaging and then signs the installer, which is what we want.
- Architecture: ship x64 first. Add ARM64 once x64 is stable — Rust, Tauri, and WebView2 all support it; whisper.cpp and ONNX Runtime need checking.
-
Self-update:
voxctrl-updatealready classifies a Windows install and picks the-windows-x86_64.exeasset (install.rs:79-88,136-140), andspawn_relaunchalready hands off to the installer (apply.rs:278-292). The logic is written and unit-tested. It needs only the release matrix row re-enabled so the asset exists.
Rather than adding #[cfg(target_os = "windows")] arms next to each of the 104
existing ones, introduce a thin platform layer. This is the difference between a
port that holds and one that drifts.
Proposed: crates/voxctrl-platform
crates/voxctrl-platform/
src/
lib.rs // pub traits + `pub fn current() -> &'static dyn Platform`
injector.rs // trait TextInjector { async fn inject(&self, &str) }
hotkeys.rs // trait KeySource { fn start(..) -> Health }
notifier.rs // trait Notifier
ipc.rs // trait LocalIpc (unix socket | named pipe)
autostart.rs // trait Autostart
integration.rs // trait DesktopIntegration
linux/ windows/
Rules that make this worth doing:
-
No
#[cfg(target_os)]outsidevoxctrl-platformand the two-hotkeysbackends. Everything else takes a trait object.targets.rsin particular should stop knowing aboutwtype,xdotool, and PowerShell — it should ask the platform'sTextInjector. -
Capability reporting, not silent no-ops. Add
Platform::capabilities() -> Capabilities, serialise it to the frontend, and have the Settings/target-editor UI hide what the platform cannot do rather than offering a control that fails at runtime. This directly fixes the current situation where the UI offers D-Bus targets on Windows. -
Move
mint_shortcuts.rs,host_env.rs, and the Linux half ofinstaller.rsbehind#[cfg(target_os = "linux")] moddeclarations insrc-tauri/src/lib.rs:14-26, so ~1000 lines of Linux desktop code stop being compiled into the Windows binary.
Do this incrementally — extract one seam per milestone, starting with
TextInjector (smallest, highest value) — not as a big-bang refactor.
Goal: know exactly what is broken.
-
Fix
is_private()(B3) first. DropBackend::WindowsHookfromhealth.rs:202, and add the invariant testsees_raw_keys() == !is_private()across all backends. One line of code, and it removes a false privacy claim before any Windows binary can carry it. - Remove
--exclude voxctrl-inference --exclude voxctrl-appfromci.yml:62; add MSVC + CMake setup to the Windows job. - Fix every compile error that surfaces in
src-tauri/andvoxctrl-inference. - Add
cargo clippy -D warningson Windows. - Add a
cargo testjob onwindows-latestfor the crates that have tests and no display dependency (voxctrl-config,voxctrl-routing,voxctrl-update,voxctrl-text).
Exit criteria: cargo check --workspace and cargo test are green on
windows-latest in CI, with no crates excluded; the privacy invariant test
passes; docs/development.md and docs/privacy.md no longer claim the Windows
hook is private.
Goal: a developer can dictate into Notepad on Windows 11.
-
Rewrite the hotkey backend. Replace
rdevwith awindows-rsWH_KEYBOARD_LLhook: dedicated message-pump thread, minimal callback, lock-free handoff to a worker, scan-code→evdev name table, suppression, watchdog re-install, honestListenerHealth. (Fixes B1.) -
Write the scan-code table with a round-trip test asserting that every key
HotkeysTab.svelte'smapBrowserKeyToEvdevcan produce is reachable from a scan code — a unit test that would have caught B1 on day one. -
Replace both PowerShell injection paths with
SendInput— the type target (targets.rs:142) and the clipboard-paste helper (voxctrl-inject/src/lib.rs:94). Extract behind aTextInjectortrait. (Fixes B2.) -
Test with adversarial payloads:
50% (a+b) {x} [y] ^C ~z, emoji, CJK, a 5000-character paragraph.
Exit criteria: default Super+Space hold-to-dictate produces byte-exact text in Notepad, Word, Chrome, VS Code, and Windows Terminal.
- Overlay:
windows_subsystem,CREATE_NO_WINDOW,WS_EX_NOACTIVATE, DPI/multi-monitor verification. - Notifications via
tauri-plugin-notification; AUMID on the Start Menu shortcut. - Autostart via
tauri-plugin-autostart, unified with the Linux.desktopautostart under one Settings control. - Named-pipe delivery type; MCP
SOCKET_PATHmade conditional; named-pipe ACL hardening; README +docs/architecture.mdcorrected. - Piper Windows auto-install (zip);
TtsEngine::WindowsSapi; decide and implement the espeak-ng story. - Windows first-run wizard variant (§4.8).
- Capability-gate the target editor and Settings tabs.
-
exectarget: document and test Windows argument quoting. - Cross-platform
predevscript.
Exit criteria: every row of the §3.2 gap table is closed or explicitly and visibly declared unavailable in the UI.
- Re-enable the
windows-cpurow inrelease.yml:109-113; restore the Windows row in the release-body downloads table. - Azure Trusted Signing wired into the release workflow.
- End-to-end self-update test: install 0.4.0, publish 0.4.1, confirm the app detects, downloads, verifies the checksum, and relaunches.
- Rewrite
docs/windows_build.md(it currently documents the SendKeys behaviour as intended) and add a Windows section todocs/installation.md. - Fresh-VM install test on Windows 11 23H2 and 24H2.
Exit criteria: a signed setup.exe on the GitHub release; a clean Windows 11
VM goes from download to working dictation with no manual steps.
- ONNX Runtime + DirectML feature for Moonshine; a second
windows-directmlrelease artifact. - ARM64 build.
- UIPI/elevation detection with a clear explanation in the UI.
- Track whisper.cpp#3750; add a Vulkan Windows build only once it is genuinely fixed, with a startup assertion that the Vulkan backend actually registered.
- Extract the remaining seams into
voxctrl-platform. - Windows-native "open shortcut settings" equivalent for
open_shortcut_settings()(commands.rs:1166), which currently returns an error saying it is Linux-only.
The single most valuable change is structural: every Windows bug found in this audit was invisible because Windows code was excluded from CI. So:
| Lane | Platform | Runs |
|---|---|---|
cargo check --workspace |
ubuntu-22.04 + windows-latest | every PR — no --exclude
|
cargo clippy -D warnings |
both | every PR |
cargo test (portable crates) |
both | every PR |
cargo test (X11-dependent) |
ubuntu under xvfb
|
every PR (as today) |
Frontend svelte-check + vitest |
ubuntu | every PR |
| Build installer | windows-2022 | every PR to master, and releases |
| Smoke test | windows-2022 | launch headless, assert tray + hook install + MCP pipe |
Add these specific regression tests, each of which maps to a bug found above:
- Key-name round trip — every evdev name the frontend can emit is produced by the Windows scan-code mapper for the corresponding scan code. (B1)
- Injection fidelity — a table-driven test over metacharacter-heavy strings, asserting the injector's output byte-for-byte. (B2)
-
Capability coverage — for each
DeliveryType, assert that either the platform implements it orCapabilitiesreports it unavailable, so a new delivery type cannot ship half-ported. (§3.2, D-Bus/pipe) - Named-pipe ACL — assert the MCP pipe rejects a connection from another user context. (§4.6)
-
Privacy invariant — for every
Backend,sees_raw_keys()andis_private()are exact opposites. (B3)
Manual test matrix for each release: Windows 11 23H2 / 24H2 · x64 · Intel + AMD
- NVIDIA · 100% and 150% DPI · single and dual monitor · US and non-US keyboard layout.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Shipping a false privacy claim (B3) | Certain if unfixed | Critical | One-line fix plus an invariant test, in Milestone 0 |
| Antivirus/EDR flags a global keyboard hook | High | High | Code-sign early to build reputation; document the behaviour prominently; never log keystroke content; keep the hook's suppression scope minimal and auditable |
| Silent hook removal on timeout | Medium | High | Minimal callback + watchdog re-install + health reporting (§4.1) |
| whisper.cpp MSVC build friction | Medium | Medium | CPU-only first; DirectML for GPU; keep Vulkan off Windows until #3750 resolves |
| Hotkey conflicts with Windows-reserved combos | High | Low | Extend the existing is_reserved_for_the_desktop() (voxctrl-hotkeys/src/trigger.rs) with a Windows list (Win+L, Ctrl+Alt+Del, Win+G…) |
| SmartScreen suppresses adoption | High | High | Azure Trusted Signing from the first public build |
| Elevated-window dictation fails (UIPI) | Certain | Medium | Detect and explain; document that elevation is required to dictate into elevated apps |
voxctrl-platform refactor destabilises Linux |
Medium | High | Extract one seam per milestone; Linux CI stays green throughout; no behaviour changes in the same commit as a move |
| DirectML enters deeper maintenance mode | Low (near term) | Medium | Isolate behind the ort EP feature flag; Windows ML is the migration path |
State these in the docs and in the UI rather than letting users discover them:
- Dictation into elevated applications requires running VoxCtrl elevated (UIPI). Linux has no equivalent restriction.
- The secure desktop (UAC prompt, lock screen, Ctrl+Alt+Del) is invisible to any user-mode app. Hotkeys cannot fire there.
- D-Bus targets have no Windows counterpart. Named-pipe targets replace them for the same use cases.
-
FIFO (
pipe) targets do not exist on Windows; named pipes replace them. -
Portal-based hotkey privacy. On Linux, the XDG portal means the
compositor owns the grab and VoxCtrl never reads the keyboard — a genuine
privacy property the app advertises via
is_private(). AWH_KEYBOARD_LLhook does see every keystroke, so Windows reportsis_private() == false(see B3 — today it wrongly reportstrue).
That last point deserves emphasis. VoxCtrl's privacy story is a headline feature
of the project, and is_private() is not a diagnostic — it is a promise shown to
the user. The Windows backend cannot make that promise. What it can say, and
what docs/privacy.md should be extended to say, is the next-best true thing:
the hook matches each key against the user's bindings, discards it, and never
logs or transmits it.
| Decision | Recommendation |
|---|---|
| Hotkey backend |
windows-rs WH_KEYBOARD_LL, scan-code based; drop rdev
|
| Hotkey fallback |
RegisterHotKey, toggle-only, advertised as reduced |
| Text injection |
SendInput + KEYEVENTF_UNICODE; clipboard paste as fallback |
| STT GPU | ONNX Runtime DirectML; CUDA opt-in; no Vulkan on Windows yet |
| TTS default | Add WindowsSapi; fix Piper auto-install |
| IPC | Named pipes, ACL-restricted to the current user |
| Notifications |
tauri-plugin-notification + AUMID on Start Menu shortcut |
| Packaging | NSIS setup.exe only; drop MSI |
| Signing | Azure Trusted Signing, from the first public build |
| Architecture | Extract a voxctrl-platform crate, one seam per milestone |
| Privacy |
is_private() must be false for WindowsHook; fix before anything else |
| CI | Remove all Windows --excludes in Milestone 0 |
- ggml-org/whisper.cpp#3750 — Vulkan backend silently fails to register on Windows MSVC static builds
- LowLevelKeyboardProc callback function — Microsoft Learn
- SendInput — windows-rs documentation
- DirectML Execution Provider — ONNX Runtime
- Windows Code Signing — Tauri v2
- Windows Code Signing with Azure Trusted Signing — KeyQ
- enigo — cross-platform input simulation in Rust
- rdev — crates.io and the maintained fork rdevin
- UI Automation TextPattern Overview — Microsoft Learn
- Windows native toast notifications require a registered AUMID