Skip to main content

opencode_voice/input/
hotkey_linux.rs

1//! Linux global hotkey backend using evdev (kernel input subsystem).
2//!
3//! Reads key events directly from `/dev/input/event*` devices, bypassing
4//! the display server entirely. Works on X11, Wayland, and headless.
5//!
6//! Requires the user to be in the `input` group (or root).
7
8use anyhow::{Context, Result};
9use evdev::{Device, InputEventKind, Key};
10use std::collections::HashMap;
11use std::sync::OnceLock;
12use tokio_util::sync::CancellationToken;
13
14use crate::state::InputEvent;
15
16fn build_key_map() -> HashMap<&'static str, Key> {
17    let mut m = HashMap::new();
18
19    // Numpad keys
20    m.insert("numpad_0", Key::KEY_KP0);
21    m.insert("numpad_1", Key::KEY_KP1);
22    m.insert("numpad_2", Key::KEY_KP2);
23    m.insert("numpad_3", Key::KEY_KP3);
24    m.insert("numpad_4", Key::KEY_KP4);
25    m.insert("numpad_5", Key::KEY_KP5);
26    m.insert("numpad_6", Key::KEY_KP6);
27    m.insert("numpad_7", Key::KEY_KP7);
28    m.insert("numpad_8", Key::KEY_KP8);
29    m.insert("numpad_9", Key::KEY_KP9);
30    m.insert("numpad_enter", Key::KEY_KPENTER);
31    m.insert("numpad_decimal", Key::KEY_KPDOT);
32    m.insert("numpad_dot", Key::KEY_KPDOT);
33    m.insert("numpad_plus", Key::KEY_KPPLUS);
34    m.insert("numpad_add", Key::KEY_KPPLUS);
35    m.insert("numpad_minus", Key::KEY_KPMINUS);
36    m.insert("numpad_subtract", Key::KEY_KPMINUS);
37    m.insert("numpad_multiply", Key::KEY_KPASTERISK);
38    m.insert("numpad_divide", Key::KEY_KPSLASH);
39    m.insert("numpad_clear", Key::KEY_NUMLOCK); // macOS "Clear" is Num Lock position on PC
40    m.insert("numpad_equals", Key::KEY_KPEQUAL);
41
42    // Modifier keys
43    m.insert("right_option", Key::KEY_RIGHTALT);
44    m.insert("right_alt", Key::KEY_RIGHTALT);
45    m.insert("left_option", Key::KEY_LEFTALT);
46    m.insert("left_alt", Key::KEY_LEFTALT);
47    m.insert("right_command", Key::KEY_RIGHTMETA);
48    m.insert("right_cmd", Key::KEY_RIGHTMETA);
49    m.insert("left_command", Key::KEY_LEFTMETA);
50    m.insert("left_cmd", Key::KEY_LEFTMETA);
51    m.insert("right_shift", Key::KEY_RIGHTSHIFT);
52    m.insert("left_shift", Key::KEY_LEFTSHIFT);
53    m.insert("right_control", Key::KEY_RIGHTCTRL);
54    m.insert("right_ctrl", Key::KEY_RIGHTCTRL);
55    m.insert("left_control", Key::KEY_LEFTCTRL);
56    m.insert("left_ctrl", Key::KEY_LEFTCTRL);
57    m.insert("fn_key", Key::KEY_FN);
58    m.insert("fn", Key::KEY_FN);
59    m.insert("caps_lock", Key::KEY_CAPSLOCK);
60
61    // Function keys F1-F12
62    m.insert("f1", Key::KEY_F1);
63    m.insert("f2", Key::KEY_F2);
64    m.insert("f3", Key::KEY_F3);
65    m.insert("f4", Key::KEY_F4);
66    m.insert("f5", Key::KEY_F5);
67    m.insert("f6", Key::KEY_F6);
68    m.insert("f7", Key::KEY_F7);
69    m.insert("f8", Key::KEY_F8);
70    m.insert("f9", Key::KEY_F9);
71    m.insert("f10", Key::KEY_F10);
72    m.insert("f11", Key::KEY_F11);
73    m.insert("f12", Key::KEY_F12);
74
75    // Function keys F13-F20 (native evdev codes, unlike macOS raw keycodes)
76    m.insert("f13", Key::KEY_F13);
77    m.insert("f14", Key::KEY_F14);
78    m.insert("f15", Key::KEY_F15);
79    m.insert("f16", Key::KEY_F16);
80    m.insert("f17", Key::KEY_F17);
81    m.insert("f18", Key::KEY_F18);
82    m.insert("f19", Key::KEY_F19);
83    m.insert("f20", Key::KEY_F20);
84
85    // Common keys
86    m.insert("space", Key::KEY_SPACE);
87    m.insert("tab", Key::KEY_TAB);
88    m.insert("escape", Key::KEY_ESC);
89    m.insert("delete", Key::KEY_BACKSPACE);
90    m.insert("forward_delete", Key::KEY_DELETE);
91    m.insert("return_key", Key::KEY_ENTER);
92    m.insert("return", Key::KEY_ENTER);
93    m.insert("enter", Key::KEY_ENTER);
94    m.insert("home", Key::KEY_HOME);
95    m.insert("end", Key::KEY_END);
96    m.insert("page_up", Key::KEY_PAGEUP);
97    m.insert("page_down", Key::KEY_PAGEDOWN);
98    m.insert("up_arrow", Key::KEY_UP);
99    m.insert("down_arrow", Key::KEY_DOWN);
100    m.insert("left_arrow", Key::KEY_LEFT);
101    m.insert("right_arrow", Key::KEY_RIGHT);
102    m.insert("insert", Key::KEY_INSERT);
103    m.insert("print_screen", Key::KEY_SYSRQ);
104    m.insert("scroll_lock", Key::KEY_SCROLLLOCK);
105    m.insert("pause", Key::KEY_PAUSE);
106    m.insert("num_lock", Key::KEY_NUMLOCK);
107
108    // Punctuation / symbols
109    // Section sign (§) — the ISO 102nd key. On evdev this is KEY_102ND.
110    m.insert("section", Key::KEY_102ND);
111    m.insert("grave", Key::KEY_GRAVE);
112    m.insert("minus", Key::KEY_MINUS);
113    m.insert("equal", Key::KEY_EQUAL);
114    m.insert("left_bracket", Key::KEY_LEFTBRACE);
115    m.insert("right_bracket", Key::KEY_RIGHTBRACE);
116    m.insert("backslash", Key::KEY_BACKSLASH);
117    m.insert("semicolon", Key::KEY_SEMICOLON);
118    m.insert("quote", Key::KEY_APOSTROPHE);
119    m.insert("comma", Key::KEY_COMMA);
120    m.insert("period", Key::KEY_DOT);
121    m.insert("slash", Key::KEY_SLASH);
122
123    m
124}
125
126static KEY_MAP: OnceLock<HashMap<&'static str, Key>> = OnceLock::new();
127
128pub(crate) fn get_key_map() -> &'static HashMap<&'static str, Key> {
129    KEY_MAP.get_or_init(build_key_map)
130}
131
132/// Resolves a key name string to an evdev Key.
133fn resolve_key(input: &str) -> Result<Key> {
134    let key_map = get_key_map();
135
136    if let Some(&key) = key_map.get(input) {
137        return Ok(key);
138    }
139
140    // Hex number fallback (Linux evdev keycode)
141    if let Some(hex) = input
142        .strip_prefix("0x")
143        .or_else(|| input.strip_prefix("0X"))
144    {
145        if let Ok(n) = u16::from_str_radix(hex, 16) {
146            return Ok(Key::new(n));
147        }
148    }
149
150    // Decimal number fallback
151    if let Ok(n) = input.parse::<u16>() {
152        return Ok(Key::new(n));
153    }
154
155    Err(anyhow::anyhow!(
156        "Unknown key name: '{}'. Use 'opencode-voice keys' to list valid names.",
157        input
158    ))
159}
160
161/// Finds all input devices that report the given key.
162fn find_devices_with_key(key: Key) -> Vec<Device> {
163    evdev::enumerate()
164        .filter_map(|(_, device)| {
165            let supports_key = device
166                .supported_keys()
167                .map_or(false, |keys| keys.contains(key));
168            if supports_key {
169                Some(device)
170            } else {
171                None
172            }
173        })
174        .collect()
175}
176
177/// Global hotkey monitor using evdev (Linux kernel input).
178pub struct GlobalHotkey {
179    target_key: Key,
180    sender: tokio::sync::mpsc::UnboundedSender<InputEvent>,
181    cancel: CancellationToken,
182}
183
184impl GlobalHotkey {
185    /// Creates a new global hotkey monitor.
186    ///
187    /// Validates the key name and verifies that at least one input device
188    /// supports the target key. Returns an error immediately if no suitable
189    /// devices are found (rather than failing silently in a background thread).
190    pub fn new(
191        key_name: &str,
192        sender: tokio::sync::mpsc::UnboundedSender<InputEvent>,
193        cancel: CancellationToken,
194    ) -> Result<Self> {
195        let target_key =
196            resolve_key(key_name).with_context(|| format!("Invalid hotkey: {}", key_name))?;
197
198        // Check for suitable devices now so the caller gets a clear error.
199        let probe = find_devices_with_key(target_key);
200        if probe.is_empty() {
201            return Err(diagnose_no_devices(target_key));
202        }
203
204        Ok(GlobalHotkey {
205            target_key,
206            sender,
207            cancel,
208        })
209    }
210
211    /// Starts the global hotkey listener.
212    ///
213    /// Spawns one OS thread per keyboard device that supports the target key.
214    /// Each thread blocks on `fetch_events()` and forwards matching key events
215    /// through the channel.
216    pub fn run(&self) -> Result<()> {
217        // Re-enumerate devices (they were validated in new() but may have
218        // changed; this also avoids storing Device which may not be Send).
219        let devices = find_devices_with_key(self.target_key);
220
221        for device in devices {
222            let target_key = self.target_key;
223            let sender = self.sender.clone();
224            let cancel = self.cancel.clone();
225
226            std::thread::spawn(move || {
227                listen_on_device(device, target_key, sender, cancel);
228            });
229        }
230
231        Ok(())
232    }
233}
234
235/// Produces a detailed error when no devices support the target key.
236///
237/// Distinguishes three cases:
238/// 1. No input devices accessible at all → permission issue.
239/// 2. Some devices accessible but no keyboards → probably can't open keyboard
240///    devices (permission issue for those specific devices).
241/// 3. Keyboards accessible but none support the target key → wrong key choice.
242fn diagnose_no_devices(target_key: Key) -> anyhow::Error {
243    let accessible: Vec<_> = evdev::enumerate().collect();
244
245    if accessible.is_empty() {
246        return anyhow::anyhow!(
247            "No input devices accessible. Global hotkey requires read access to /dev/input/.\n  \
248             Fix: sudo usermod -a -G input $USER  (then log out and back in)"
249        );
250    }
251
252    // Check how many accessible devices look like keyboards (support KEY_SPACE).
253    let keyboards: Vec<_> = accessible
254        .iter()
255        .filter(|(_, d)| {
256            d.supported_keys()
257                .map_or(false, |keys| keys.contains(Key::KEY_SPACE))
258        })
259        .collect();
260
261    // Count total device nodes to detect permission gaps.
262    let total_device_nodes = std::fs::read_dir("/dev/input")
263        .map(|rd| {
264            rd.filter(|e| {
265                e.as_ref().map_or(false, |e| {
266                    e.file_name().to_string_lossy().starts_with("event")
267                })
268            })
269            .count()
270        })
271        .unwrap_or(0);
272
273    if keyboards.is_empty() {
274        // We can open some devices but none are keyboards.
275        anyhow::anyhow!(
276            "Cannot access keyboard input devices ({} of {} /dev/input/event* devices accessible, \
277             but none are keyboards).\n  \
278             Fix: sudo usermod -a -G input $USER  (then log out and back in)\n  \
279             Or run with --no-global for terminal-only input.",
280            accessible.len(),
281            total_device_nodes,
282        )
283    } else {
284        // Keyboards are accessible but don't have the target key.
285        let kb_names: Vec<String> = keyboards
286            .iter()
287            .filter_map(|(_, d)| d.name().map(|s| s.to_string()))
288            .collect();
289        anyhow::anyhow!(
290            "Found {} keyboard(s) ({}) but none report support for key {:?}.\n  \
291             Try a different key with --hotkey, or use --no-global for terminal-only input.",
292            keyboards.len(),
293            kb_names.join(", "),
294            target_key,
295        )
296    }
297}
298
299/// Reads key events from a single evdev device in a blocking loop.
300fn listen_on_device(
301    mut device: Device,
302    target_key: Key,
303    sender: tokio::sync::mpsc::UnboundedSender<InputEvent>,
304    cancel: CancellationToken,
305) {
306    let mut pressed = false;
307
308    loop {
309        if cancel.is_cancelled() {
310            break;
311        }
312
313        let events = match device.fetch_events() {
314            Ok(events) => events,
315            Err(_) => break, // Device error (disconnected, etc.)
316        };
317
318        for ev in events {
319            if cancel.is_cancelled() {
320                return;
321            }
322
323            if let InputEventKind::Key(key) = ev.kind() {
324                if key == target_key {
325                    match ev.value() {
326                        1 => {
327                            // Press
328                            if !pressed {
329                                pressed = true;
330                                let _ = sender.send(InputEvent::KeyDown);
331                            }
332                        }
333                        0 => {
334                            // Release
335                            pressed = false;
336                            let _ = sender.send(InputEvent::KeyUp);
337                            let _ = sender.send(InputEvent::Toggle);
338                        }
339                        _ => {} // Repeat (value 2) — ignored
340                    }
341                }
342            }
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_resolve_key_right_option() {
353        let result = resolve_key("right_option");
354        assert!(result.is_ok());
355        assert_eq!(result.unwrap(), Key::KEY_RIGHTALT);
356    }
357
358    #[test]
359    fn test_resolve_key_alias_right_alt() {
360        let k1 = resolve_key("right_option").unwrap();
361        let k2 = resolve_key("right_alt").unwrap();
362        assert_eq!(k1, k2);
363    }
364
365    #[test]
366    fn test_resolve_key_decimal_number() {
367        let result = resolve_key("57"); // KEY_SPACE = 57
368        assert!(result.is_ok());
369    }
370
371    #[test]
372    fn test_resolve_key_hex_number() {
373        let result = resolve_key("0x39"); // KEY_SPACE = 0x39 = 57
374        assert!(result.is_ok());
375    }
376
377    #[test]
378    fn test_resolve_key_unknown() {
379        let result = resolve_key("not_a_key");
380        assert!(result.is_err());
381    }
382
383    #[test]
384    fn test_resolve_key_space() {
385        assert_eq!(resolve_key("space").unwrap(), Key::KEY_SPACE);
386    }
387
388    #[test]
389    fn test_resolve_key_f1() {
390        assert_eq!(resolve_key("f1").unwrap(), Key::KEY_F1);
391    }
392
393    #[test]
394    fn test_resolve_key_f13() {
395        assert_eq!(resolve_key("f13").unwrap(), Key::KEY_F13);
396    }
397
398    #[test]
399    fn test_resolve_key_left_command() {
400        assert_eq!(resolve_key("left_command").unwrap(), Key::KEY_LEFTMETA);
401    }
402
403    #[test]
404    fn test_resolve_key_caps_lock() {
405        assert_eq!(resolve_key("caps_lock").unwrap(), Key::KEY_CAPSLOCK);
406    }
407
408    #[test]
409    fn test_resolve_key_escape() {
410        assert_eq!(resolve_key("escape").unwrap(), Key::KEY_ESC);
411    }
412
413    #[test]
414    fn test_key_map_has_60_plus_entries() {
415        let map = get_key_map();
416        assert!(
417            map.len() >= 60,
418            "KEY_MAP should have at least 60 entries, has {}",
419            map.len()
420        );
421    }
422
423    #[test]
424    fn test_resolve_key_modifiers() {
425        assert_eq!(resolve_key("left_alt").unwrap(), Key::KEY_LEFTALT);
426        assert_eq!(resolve_key("left_shift").unwrap(), Key::KEY_LEFTSHIFT);
427        assert_eq!(resolve_key("left_ctrl").unwrap(), Key::KEY_LEFTCTRL);
428        assert_eq!(resolve_key("right_shift").unwrap(), Key::KEY_RIGHTSHIFT);
429        assert_eq!(resolve_key("right_ctrl").unwrap(), Key::KEY_RIGHTCTRL);
430    }
431
432    #[test]
433    fn test_resolve_key_numpad() {
434        assert_eq!(resolve_key("numpad_0").unwrap(), Key::KEY_KP0);
435        assert_eq!(resolve_key("numpad_enter").unwrap(), Key::KEY_KPENTER);
436        assert_eq!(resolve_key("numpad_plus").unwrap(), Key::KEY_KPPLUS);
437        assert_eq!(resolve_key("numpad_add").unwrap(), Key::KEY_KPPLUS);
438    }
439}