Hotkeys & gestures
voxctrl listens for global shortcuts that work regardless of which application has focus. On Linux this happens through the XDG desktop portal, so voxctrl does not read your keyboard — your desktop owns the key grab and tells voxctrl exactly one thing: that its own shortcut fired. On Windows, Win32 low-level keyboard hooks are used, which is the only mechanism the platform offers for this kind of shortcut.
/dev/input/event* directly and required a udev rule granting keyboard-wide access. See
why this changed below.How shortcuts reach voxctrl
Two backends are available on Linux. The portal is the default and is used whenever your desktop supports it; the evdev path only ever engages as a fallback, and only if your system already allows the process to read input devices — voxctrl never grants itself that access.
| Portal (default) | evdev fallback | |
|---|---|---|
| Can see keystrokes in other apps | No | Yes, all of them |
Needs a udev rule or input group |
No | Yes |
| Needs any permission setup | No | Yes, and voxctrl will not do it for you |
| Who chooses the keys | You, through your desktop | You, in voxctrl |
| Works on Wayland | Yes | Yes |
| Works on X11 | Yes | Yes |
| Works with no desktop (bare TTY) | No | Yes |
Settings → Hotkeys and the setup window both state which of these is in use, live. If it says your desktop is handling the shortcuts, voxctrl is not reading your keyboard.
Portal — org.freedesktop.portal.GlobalShortcuts
voxctrl declares its shortcuts as preferred triggers over D-Bus; your desktop decides what actually
binds — it may confirm with you, and it may assign different keys — and from then on sends
Activated/Deactivated events when the shortcut is pressed and released. No
permissions are required: nothing to install, no group to join, no rule to write, no logout. Supported by
KDE Plasma (5.27+), GNOME 48+, and Hyprland. Compositors without the interface — Sway and most other wlroots
compositors as of writing — fall through to the evdev fallback below.
If the portal refuses the session, the app distinguishes a missing portal ("no shortcuts portal available") from a refusal ("your desktop has one but declined the request") and shows the exact D-Bus error under Portal reported: in the setup window.
Linux evdev fallback
Only used when the portal is unavailable, and only if your system already permits this process to read
input devices. In this mode voxctrl reads /dev/input/event* directly, so every keystroke on the
system passes through the process — but nothing is logged, stored, or transmitted. Key names live briefly in
memory and never cross into the UI layer or any network call, and the Hotkeys tab and setup window say so
in plain language rather than staying quiet about it. A specific keyboard can be pinned with
audio.evdev_device (e.g. "/dev/input/event4"); synthetic devices
(uinput, XTEST, anything named "virtual") are always skipped so voxctrl cannot
react to keystrokes it injects itself.
Windows
Uses the Win32 SetWindowsHookEx / WH_KEYBOARD_LL low-level keyboard hook. No
special permissions are required. Like the evdev fallback, this hook sees all keystrokes — that is the only
mechanism Windows offers for this kind of shortcut.
Why this changed
Reading /dev/input/event* requires a udev rule tagging input devices with uaccess
(or membership of the input group). voxctrl used to install one during setup:
SUBSYSTEM=="input", KERNEL=="event*", TAG+="uaccess"
That rule isn't narrow — it grants every process running as you the ability to read
every keystroke on the system: a compromised npm postinstall script, any
Electron app, any shell one-liner. systemd's own defaults deliberately grant uaccess on input
devices to joysticks and nothing else, precisely to avoid this. A dictation app shouldn't be the reason your
machine's security posture changes, and it certainly shouldn't do it silently in a first-run wizard, so:
- voxctrl never writes that rule and never runs
usermod -aG input— not at install, not on first launch, not from any button in the UI - if your desktop has no shortcuts portal, voxctrl tells you at launch and explains the trade-off, rather than quietly widening access
- on a machine that still has the rule from an older version, the installer and uninstaller remove it, so upgrading narrows access instead of leaving it wide open
Administrator rights are still requested for one thing on Linux: installing host packages such as
wtype and xdotool, which type the transcription into your focused window. That
step touches no permissions.
Default bindings
| Gesture | Keys | Action |
|---|---|---|
| Hold-to-talk | Super + Space | Hold while speaking, release to deliver |
| Toggle-to-talk | Ctrl + Super + Space | Tap to start, tap again to stop |
| Double-tap | Alt | Double-tap and hold Alt to record |
All configurable in Settings → Hotkeys or directly in ~/.config/voxctrl/bindings.toml. Each
gesture can be disabled without deleting it.
Gesture modes
Hold
Most predictable. Press, speak, release. Mic state is unambiguous — visible in the overlay. The
hold_threshold_ms field (default 200ms) sets the minimum hold duration before a recording start
is registered, preventing accidental triggers.
Toggle
Hands-free. Tap once to start, tap again to stop. The RMS noise gate also stops recording on silence if configured.
Double-tap
Reuses an existing modifier without colliding with normal usage. Double-tapping Alt never fires
when Alt is held as part of Alt+Tab. Default tap window (tap_ms):
250ms.
Double-tap-hold
Double-tap and keep the key held down on the second tap to start recording; release the key to stop.
Enforces hold_threshold_ms on the second press to distinguish from a standard double-tap, and
has a 2-minute safety timeout.
chord gesture (hold base keys, press a subkey to
start) has been removed — it couldn't be expressed as a system shortcut, since the portal has no concept of a
partially-held combo. Existing chord bindings aren't broken: on load they're converted to
hold using the keys already in keys, and the obsolete subkey field is
dropped the next time bindings are saved. Nothing needs to be edited by hand.What can be a shortcut
An accelerator is any number of modifiers plus exactly one regular key — Super+Space,
Ctrl+Alt+D, and F5 are all fine. Two shapes are not accelerators, and no desktop
can bind them: a lone modifier with nothing else (a bare double-tapped Super is the one that
catches people out), and two regular keys together (A+B). The key recorder in Settings →
Hotkeys refuses these while you're recording and explains why, rather than saving a binding that silently
never fires.
Conflict detection
The Settings UI checks for collisions as you record new keys:
- Exact duplicate — same keys and same gesture; disable one
- Superset shadowing — if one binding's keys are a subset of another's (e.g. Super+Space vs Ctrl+Super+Space), only the longer, more specific combo fires
- Bare single key — non-modifier key alone intercepts every press
double_tap and double_tap_hold on the same keys are not a
conflict — they're a supported pairing (a quick double-tap runs one, double-tap-and-hold runs the other), and
on the portal backend they're registered as a single system shortcut so your desktop is never asked to bind
the same keys twice.
Recording new bindings
Press the Bind button next to a key field. The button turns orange and shows "Recording…". Press your keys. The display updates live as keys are held. Release — binding captured. This capture happens inside voxctrl's own focused window using ordinary browser key events — it is not a global listener.
Configuration
The full schema for ~/.config/voxctrl/bindings.toml with one binding per gesture type:
[[binding]] id = "dictate_hold" label = "Dictate (Hold)" keys = ["KEY_LEFTMETA", "KEY_SPACE"] gesture = "hold" target_ids = ["default"] hold_threshold_ms = 200 [[binding]] id = "agent_command" label = "Agent (Double-tap)" keys = ["KEY_LEFTCTRL"] gesture = "double_tap" target_ids = ["hermes"] tap_ms = 250 [[binding]] id = "journal_hold" label = "Journal (Double-tap & Hold)" keys = ["KEY_LEFTMETA", "KEY_SPACE"] gesture = "double_tap_hold" target_ids = ["journal"] tap_ms = 300 hold_threshold_ms = 200
Other fields: target_id (legacy singular target, resolved when target_ids is
empty) and disabled (default false, disables a binding without removing it). LLM
rewriting per binding is configured with the openai_enabled, openai_model,
openai_mode, openai_prompt, and openai_system_prompt fields — the
legacy ollama_* field names are still accepted as aliases.
Supported gesture values
| Value | Description |
|---|---|
hold |
Record while key is held; stop on release |
toggle |
First press starts; second press stops |
double_tap |
Two presses within tap_ms window to activate |
double_tap_hold |
Double-tap and keep held on the second press to start; release to stop |