Deepgram Voice Keyboard: Virtual Keyboard Implementation Analysis
November 25, 2025 ยท View on GitHub
This document analyzes how Deepgram's voice-keyboard-linux achieves reliable text input on Wayland without depending on ydotool.
Executive Summary
The Deepgram approach works by:
- Direct kernel-level access via
/dev/uinput- bypassing userspace input tools entirely - Privilege escalation with immediate drop - runs as root only long enough to create the virtual device, then drops to user privileges
- Pure Rust implementation - no external dependencies for the keyboard simulation itself
This is fundamentally different from ydotool which requires a persistent daemon and has various reliability issues on Wayland.
Core Architecture
The Key Files
| File | Purpose |
|---|---|
virtual_keyboard.rs | Creates and manages the uinput virtual keyboard device |
input_event.rs | Defines Linux input event structures and key codes |
main.rs | Handles privilege escalation/dropping |
run.sh | Wrapper script for proper privilege handling |
How It Works: Step by Step
Step 1: Privilege Escalation
The application must run as root to access /dev/uinput. The run.sh script handles this:
# From run.sh
sudo -E ./target/debug/voice-keyboard "$@"
The -E flag preserves environment variables (critical for audio access later).
Step 2: Capture Original User Info
Before doing anything privileged, the code captures the original user's UID/GID:
// From main.rs:29-52
impl OriginalUser {
fn capture() -> Self {
// If running under sudo, get the original user info
let uid = if let Ok(sudo_uid) = env::var("SUDO_UID") {
Uid::from_raw(sudo_uid.parse().unwrap_or_else(|_| getuid().as_raw()))
} else {
getuid()
};
// ... similar for gid
}
}
Step 3: Create Virtual Keyboard (As Root)
This is the critical part that requires root. The code opens /dev/uinput and creates a virtual keyboard device:
// From virtual_keyboard.rs:36-103
impl RealKeyboardHardware {
pub fn new(device_name: &str) -> Result<Self> {
// Open uinput device - REQUIRES ROOT
let fd = open(
"/dev/uinput",
OFlag::O_WRONLY | OFlag::O_NONBLOCK,
Mode::empty(),
).context("Failed to open /dev/uinput")?;
// Enable key events via ioctl
unsafe {
ui_set_evbit(fd, EV_KEY as u64)?;
}
// Enable all key codes (1-255)
for keycode in get_all_keycodes() {
unsafe {
ui_set_keybit(fd, keycode as u64)?;
}
}
// Set up device info (name, vendor, product IDs)
let mut uidev = UInputUserDev::default();
uidev.name[..copy_len].copy_from_slice(&name_bytes[..copy_len]);
uidev.id.bustype = 0x03; // USB
uidev.id.vendor = 0x1234;
uidev.id.product = 0x5678;
// Write device info and create
file.write_all(uidev_bytes)?;
unsafe { ui_dev_create(fd)?; }
Ok(Self { fd, name: device_name.to_string() })
}
}
Step 4: Drop Privileges Immediately
Once the virtual keyboard is created, root is no longer needed. The code drops back to the original user:
// From main.rs:54-102
fn drop_privileges(&self) -> Result<()> {
if getuid().is_root() {
// Preserve environment variables needed for audio
let pulse_runtime_path = env::var("PULSE_RUNTIME_PATH").ok();
let xdg_runtime_dir = env::var("XDG_RUNTIME_DIR").ok();
let wayland_display = env::var("WAYLAND_DISPLAY").ok();
// Drop group first, then user (required order!)
setgid(self.gid)?;
setuid(self.uid)?;
// Restore environment for audio access
if let Some(xdg_path) = xdg_runtime_dir {
env::set_var("XDG_RUNTIME_DIR", xdg_path);
}
// ... etc
}
Ok(())
}
Step 5: Send Key Events
With the file descriptor still open (it persists after privilege drop), the code can send key events:
// From virtual_keyboard.rs:106-145
fn send_event(&self, event: InputEvent) -> Result<()> {
let event_bytes = unsafe {
std::slice::from_raw_parts(
&event as *const _ as *const u8,
std::mem::size_of::<InputEvent>(),
)
};
unsafe {
libc::write(self.fd, event_bytes.as_ptr() as *const libc::c_void, event_bytes.len());
}
Ok(())
}
fn send_key(&self, keycode: u16, pressed: bool) -> Result<()> {
// Send key event
let key_event = InputEvent::key_event(keycode, pressed);
self.send_event(key_event)?;
// Send synchronization event (required!)
let syn_event = InputEvent::syn_event();
self.send_event(syn_event)?;
Ok(())
}
The Linux Input Event Structure
The code defines the exact structure the kernel expects:
// From input_event.rs:3-39
#[repr(C)]
pub struct InputEvent {
pub time: libc::timeval, // Timestamp
pub type_: u16, // Event type (EV_KEY, EV_SYN, etc.)
pub code: u16, // Key code
pub value: i32, // 1 = pressed, 0 = released
}
// Event types
pub const EV_SYN: u16 = 0x00; // Synchronization
pub const EV_KEY: u16 = 0x01; // Key press/release
// Example key codes
pub const KEY_A: u16 = 30;
pub const KEY_ENTER: u16 = 28;
pub const KEY_LEFTSHIFT: u16 = 42;
Why This Works on Wayland (When ydotool Doesn't)
The ydotool Problem
ydotool uses a daemon (ydotoold) that:
- Creates a virtual input device
- Listens on a socket for commands
- Requires the daemon to be running with correct permissions
This architecture has several failure points on Wayland:
- Daemon permission issues
- Socket communication failures
- Race conditions between daemon and client
- Not maintained (stale project)
The Deepgram Solution
By contrast, Deepgram's approach:
- No daemon - single process creates and uses the device
- Direct kernel communication - writes directly to the file descriptor
- File descriptor persists - once
/dev/uinputis opened as root, the FD remains usable after privilege drop - Protocol-agnostic - works identically on X11 and Wayland because it operates at the kernel level, below the display server
Key ioctl Calls
The code defines these ioctl macros for uinput device setup:
// From virtual_keyboard.rs:14-19
nix::ioctl_write_int!(ui_set_evbit, b'U', 100); // Enable event types
nix::ioctl_write_int!(ui_set_keybit, b'U', 101); // Enable key codes
nix::ioctl_none!(ui_dev_create, b'U', 1); // Create device
nix::ioctl_none!(ui_dev_destroy, b'U', 2); // Destroy device
These correspond to:
UI_SET_EVBIT(0x40045564) - Enable an event typeUI_SET_KEYBIT(0x40045565) - Enable a specific key codeUI_DEV_CREATE(0x5501) - Create the virtual deviceUI_DEV_DESTROY(0x5502) - Destroy the virtual device
Character to Keycode Mapping
The code includes a complete mapping from characters to Linux key codes:
// From input_event.rs:172-240
pub fn char_to_keycode(c: char) -> Option<(u16, bool)> {
match c {
'a' | 'A' => Some((KEY_A, c.is_uppercase())),
// ... all letters ...
'!' => Some((KEY_1, true)), // Shift+1
'@' => Some((KEY_2, true)), // Shift+2
// ... all symbols ...
}
}
The tuple returns (keycode, needs_shift) - if shift is needed, the code presses shift, then the key, then releases both.
Dependencies
Minimal external dependencies (from Cargo.toml):
nix = { version = "0.27", features = ["user", "fs", "ioctl"] } # Unix syscalls
libc = "0.2" # C library bindings
No ydotool, no xdotool, no display-server-specific libraries.
Security Considerations
- Temporary root access - Root is only used to create the device, then immediately dropped
- No persistent daemon - No long-running privileged process
- File descriptor isolation - Once created, the FD is owned by the process
Implications for Other STT Projects
Any speech-to-text project on Linux/Wayland could use this approach:
- Run as root initially (via sudo)
- Open
/dev/uinputand create virtual keyboard - Drop privileges back to user
- Keep the file descriptor - it remains usable
- Write input events directly to the FD
This completely bypasses the need for:
ydotool/ydotooldxdotool(X11 only anyway)- Any display-server-specific input injection
Code Quality Notes
The implementation is well-structured:
- Clean separation between hardware abstraction (
KeyboardHardwaretrait) and business logic - Mock implementation for testing
- Comprehensive test suite (800+ lines of tests)
- Proper error handling with
anyhow - Logging with
tracing