// client/src/input/inputs.rs #[cfg(not(coverage))] use anyhow::bail; use anyhow::{Context, Result}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; use std::collections::HashSet; #[cfg(not(coverage))] use std::path::{Path, PathBuf}; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; use std::time::Instant; use tokio::{ sync::broadcast::Sender, time::{Duration, interval}, }; use tracing::{debug, info, warn}; use lesavka_common::lesavka::{KeyboardReport, MouseReport}; use super::{ keyboard::{KeyboardAggregator, build_keyboard_report, emit_live_keyboard_report}, mouse::MouseAggregator, }; use crate::layout::{Layout, apply as apply_layout}; use tokio::sync::mpsc::UnboundedSender; pub struct InputAggregator { kbd_tx: Sender, mou_tx: Sender, dev_mode: bool, released: bool, magic_active: bool, pending_release: bool, pending_kill: bool, pending_keys: HashSet, last_keyboard_report: [u8; 8], paste_tx: Option>, keyboards: Vec, mice: Vec, selected_keyboard_path: Option, selected_mouse_path: Option, capture_remote_boot: bool, quick_toggle_key: Option, quick_toggle_down: bool, quick_toggle_debounce: Duration, last_quick_toggle_at: Option, pending_release_started_at: Option, pending_release_timeout: Duration, #[cfg(not(coverage))] routing_control_path: Option, #[cfg(not(coverage))] last_routing_request_raw: Option, #[cfg(not(coverage))] quick_toggle_control_path: Option, #[cfg(not(coverage))] last_quick_toggle_request_raw: Option, #[cfg(not(coverage))] clipboard_control_path: Option, #[cfg(not(coverage))] clipboard_control_marker: u128, #[cfg(not(coverage))] routing_state_path: Option, #[cfg(not(coverage))] published_remote_capture: Option, remote_capture_enabled: Arc, } impl InputAggregator { pub fn new( dev_mode: bool, kbd_tx: Sender, mou_tx: Sender, paste_tx: Option>, ) -> Self { Self::new_with_capture_mode(dev_mode, kbd_tx, mou_tx, paste_tx, true) } pub fn new_with_capture_mode( dev_mode: bool, kbd_tx: Sender, mou_tx: Sender, paste_tx: Option>, capture_remote_boot: bool, ) -> Self { let quick_toggle_key = quick_toggle_key_from_env(); #[cfg(not(coverage))] let routing_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_CONTROL"); #[cfg(not(coverage))] let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE"); #[cfg(not(coverage))] let quick_toggle_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"); #[cfg(not(coverage))] let clipboard_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL"); Self { kbd_tx, mou_tx, dev_mode, released: !capture_remote_boot, magic_active: false, pending_release: false, pending_kill: false, pending_keys: HashSet::new(), last_keyboard_report: [0; 8], paste_tx, keyboards: Vec::new(), mice: Vec::new(), selected_keyboard_path: input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"), selected_mouse_path: input_device_override_from_env("LESAVKA_MOUSE_DEVICE"), capture_remote_boot, quick_toggle_key, quick_toggle_down: false, quick_toggle_debounce: quick_toggle_debounce_from_env(), last_quick_toggle_at: None, pending_release_started_at: None, pending_release_timeout: pending_release_timeout_from_env(), #[cfg(not(coverage))] last_routing_request_raw: routing_control_path .as_deref() .and_then(read_launcher_control_snapshot), #[cfg(not(coverage))] routing_control_path, #[cfg(not(coverage))] last_quick_toggle_request_raw: quick_toggle_control_path .as_deref() .and_then(read_launcher_control_snapshot), #[cfg(not(coverage))] quick_toggle_control_path, #[cfg(not(coverage))] clipboard_control_marker: clipboard_control_path .as_deref() .map(path_marker) .unwrap_or_default(), #[cfg(not(coverage))] clipboard_control_path, #[cfg(not(coverage))] routing_state_path, #[cfg(not(coverage))] published_remote_capture: None, remote_capture_enabled: Arc::new(AtomicBool::new(capture_remote_boot)), } } pub fn remote_capture_enabled_handle(&self) -> Arc { Arc::clone(&self.remote_capture_enabled) } #[cfg(coverage)] pub fn init(&mut self) -> Result<()> { let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?; for path in paths.flatten().map(|entry| entry.path()) { if !path .file_name() .map(|f| f.to_string_lossy().starts_with("event")) .unwrap_or(false) { continue; } if let Ok(dev) = Device::open(&path) { let _ = dev.set_nonblocking(true); match classify_device(&dev) { DeviceKind::Keyboard => { if !matches_selected_input_device( &path, self.selected_keyboard_path.as_deref(), ) { continue; } let mut aggregator = KeyboardAggregator::new( dev, self.dev_mode, self.kbd_tx.clone(), self.paste_tx.clone(), ); aggregator.set_send(self.capture_remote_boot); if !self.capture_remote_boot { aggregator.set_grab(false); } self.keyboards.push(aggregator); } DeviceKind::Mouse => { if !matches_selected_input_device( &path, self.selected_mouse_path.as_deref(), ) { continue; } let mut aggregator = MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()); aggregator.set_send(self.capture_remote_boot); if !self.capture_remote_boot { aggregator.set_grab(false); } self.mice.push(aggregator); } DeviceKind::Other => {} } } } Ok(()) } #[cfg(not(coverage))] pub fn init(&mut self) -> Result<()> { let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?; let mut found_any = false; for entry in paths { let entry = entry?; let path = entry.path(); if !path .file_name() .map_or(false, |f| f.to_string_lossy().starts_with("event")) { continue; } let mut dev = match Device::open(&path) { Ok(d) => d, Err(e) => { warn!("❌ open {}: {e}", path.display()); continue; } }; dev.set_nonblocking(true) .with_context(|| format!("set_non_blocking {:?}", path))?; match classify_device(&dev) { DeviceKind::Keyboard => { if !matches_selected_input_device(&path, self.selected_keyboard_path.as_deref()) { continue; } if self.capture_remote_boot { dev.grab() .with_context(|| format!("grabbing keyboard {path:?}"))?; info!( "🤏🖱️ Grabbed keyboard {:?}", dev.name().unwrap_or("UNKNOWN") ); } else { info!( "⌨️ local-input boot mode; keyboard left ungrabbed {:?}", dev.name().unwrap_or("UNKNOWN") ); } let mut kbd_agg = KeyboardAggregator::new( dev, self.dev_mode, self.kbd_tx.clone(), self.paste_tx.clone(), ); kbd_agg.set_send(self.capture_remote_boot); if !self.capture_remote_boot { kbd_agg.set_grab(false); } self.keyboards.push(kbd_agg); found_any = true; continue; } DeviceKind::Mouse => { if !matches_selected_input_device(&path, self.selected_mouse_path.as_deref()) { continue; } if self.capture_remote_boot { dev.grab() .with_context(|| format!("grabbing mouse {path:?}"))?; info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN")); } else { info!( "🖱️ local-input boot mode; mouse left ungrabbed {:?}", dev.name().unwrap_or("UNKNOWN") ); } let mut mouse_agg = MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()); mouse_agg.set_send(self.capture_remote_boot); if !self.capture_remote_boot { mouse_agg.set_grab(false); } self.mice.push(mouse_agg); found_any = true; continue; } DeviceKind::Other => { debug!( "Skipping non-kbd/mouse device: {:?}", dev.name().unwrap_or("UNKNOWN") ); continue; } } } if !found_any { bail!("No suitable keyboard/mouse devices found or none grabbed."); } Ok(()) } #[cfg(coverage)] pub async fn run(&mut self) -> Result<()> { loop { self.process_keyboard_updates(); let quick_toggle_now = self.quick_toggle_active(); self.observe_quick_toggle(quick_toggle_now); if self.pending_release || self.pending_kill { let chord_released = if self.pending_keys.is_empty() { !self .keyboards .iter() .any(|k| k.magic_grab() || k.magic_kill()) } else { self.pending_keys .iter() .all(|key| !self.keyboards.iter().any(|k| k.has_key(*key))) }; if chord_released { for k in &mut self.keyboards { k.set_grab(false); k.reset_state(); } for m in &mut self.mice { m.set_grab(false); m.reset_state(); } self.released = true; self.pending_release = false; self.pending_keys.clear(); if self.pending_kill { return Ok(()); } } } for mouse in &mut self.mice { mouse.process_events(); } tokio::task::yield_now().await; } } #[cfg(not(coverage))] pub async fn run(&mut self) -> Result<()> { // Example approach: poll each aggregator in a simple loop let mut tick = interval(Duration::from_millis(10)); let mut current = Layout::SideBySide; self.publish_routing_state_if_changed(); loop { let mut want_kill = false; self.process_keyboard_updates(); for kbd in &self.keyboards { want_kill |= kbd.magic_kill(); } self.poll_launcher_routing_request(); self.poll_launcher_quick_toggle_request(); self.poll_launcher_clipboard_request(); let quick_toggle_now = self.quick_toggle_active(); self.observe_quick_toggle(quick_toggle_now); let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); let magic_left = self.keyboards.iter().any(|k| k.magic_left()); let magic_right = self.keyboards.iter().any(|k| k.magic_right()); if magic_now && !self.magic_active { self.toggle_grab(); } if (magic_left || magic_right) && self.magic_active { current = match current { Layout::SideBySide => Layout::FullLeft, Layout::FullLeft => Layout::FullRight, Layout::FullRight => Layout::SideBySide, }; apply_layout(current); } if want_kill && !self.pending_kill { warn!("🧙 magic chord - killing 🪄 AVADA KEDAVRA!!! 💥💀⚰️"); self.remote_capture_enabled.store(false, Ordering::Relaxed); for k in &mut self.keyboards { k.send_empty_report(); k.set_send(false); } for m in &mut self.mice { m.reset_state(); m.set_send(false); } self.pending_kill = true; self.capture_pending_keys(); self.pending_release_started_at = Some(Instant::now()); } if self.pending_release || self.pending_kill { let chord_released = if self.pending_keys.is_empty() { !self .keyboards .iter() .any(|k| k.magic_grab() || k.magic_kill()) } else { self.pending_keys .iter() .all(|key| !self.keyboards.iter().any(|k| k.has_key(*key))) }; let timed_out = self.pending_release_timed_out(); if chord_released || timed_out { if timed_out { warn!( "⌛ local release timed out waiting for key-up events; forcing the handoff" ); } self.finish_local_release(!self.pending_kill); if self.pending_kill { return Ok(()); } } } for mouse in &mut self.mice { mouse.process_events(); } self.magic_active = magic_now; tick.tick().await; } } fn toggle_grab(&mut self) { if self.pending_release || self.pending_kill { return; } if self.released { tracing::info!("🧙 magic chord - restricting devices 🪄 IMPERIUS!!! 🎮🔒"); } else { tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️"); } if self.released { self.enable_remote_capture(); #[cfg(not(coverage))] self.publish_routing_state_if_changed(); } else { self.begin_local_release(); } } fn enable_remote_capture(&mut self) { self.remote_capture_enabled.store(true, Ordering::Relaxed); for k in &mut self.keyboards { k.reset_state(); k.set_send(true); k.set_grab(true); } for m in &mut self.mice { m.reset_state(); m.set_send(true); m.set_grab(true); } self.released = false; self.pending_release = false; self.pending_release_started_at = None; self.pending_keys.clear(); self.last_keyboard_report = [0; 8]; } fn begin_local_release(&mut self) { if self.released && !self.pending_release { #[cfg(not(coverage))] self.publish_routing_state_if_changed(); return; } self.remote_capture_enabled.store(false, Ordering::Relaxed); for k in &mut self.keyboards { k.send_empty_report(); k.set_send(false); } for m in &mut self.mice { m.reset_state(); m.set_send(false); } self.pending_release = true; self.pending_release_started_at = Some(Instant::now()); self.last_keyboard_report = [0; 8]; self.capture_pending_keys(); } fn finish_local_release(&mut self, focus_launcher: bool) { for k in &mut self.keyboards { k.set_grab(false); k.reset_state(); } for m in &mut self.mice { m.set_grab(false); m.reset_state(); } self.released = true; self.pending_release = false; self.pending_release_started_at = None; self.pending_keys.clear(); if focus_launcher { #[cfg(not(coverage))] focus_launcher_on_local_if_enabled(); } #[cfg(not(coverage))] self.publish_routing_state_if_changed(); } fn pending_release_timed_out(&self) -> bool { (self.pending_release || self.pending_kill) && self .pending_release_started_at .is_some_and(|started_at| started_at.elapsed() >= self.pending_release_timeout) } fn capture_pending_keys(&mut self) { self.pending_keys.clear(); for k in &self.keyboards { for key in k.pressed_keys_snapshot() { self.pending_keys.insert(key); } } } fn process_keyboard_updates(&mut self) { for index in 0..self.keyboards.len() { let mut keyboard_shadow: HashSet = self.keyboards[index] .pressed_keys_snapshot() .into_iter() .collect(); let other_pressed: HashSet = self .keyboards .iter() .enumerate() .filter(|(other_index, _)| *other_index != index) .flat_map(|(_, keyboard)| keyboard.pressed_keys_snapshot()) .collect(); let updates = { let keyboard = &mut self.keyboards[index]; keyboard.drain_key_updates() }; for update in updates { update_shadow_pressed_keys(&mut keyboard_shadow, update.code, update.value); if update.swallowed || !self.keyboard_capture_enabled() { continue; } let report = build_keyboard_report( other_pressed .iter() .copied() .chain(keyboard_shadow.iter().copied()), ); if report == self.last_keyboard_report { continue; } emit_live_keyboard_report(&self.kbd_tx, update.code, update.value, report); self.last_keyboard_report = report; } } } fn keyboard_capture_enabled(&self) -> bool { self.keyboards .iter() .any(KeyboardAggregator::sending_enabled) } fn quick_toggle_active(&mut self) -> bool { self.quick_toggle_key.is_some_and(|key| { self.keyboards .iter_mut() .any(|kbd| kbd.take_key_activation(key)) }) } fn observe_quick_toggle(&mut self, quick_toggle_now: bool) { if quick_toggle_now && !self.quick_toggle_down { let now = Instant::now(); let debounced = self .last_quick_toggle_at .is_none_or(|last| now.duration_since(last) >= self.quick_toggle_debounce); if debounced { if let Some(key) = self.quick_toggle_key { info!( "🎛️ quick-toggle {:?} engaged for smooth local/remote handoff", key ); } self.toggle_grab(); self.last_quick_toggle_at = Some(now); } } self.quick_toggle_down = quick_toggle_now; } #[cfg(not(coverage))] fn poll_launcher_routing_request(&mut self) { let Some(path) = self.routing_control_path.as_deref() else { return; }; let Some(raw) = read_launcher_control_snapshot(path) else { return; }; if self.last_routing_request_raw.as_deref() == Some(raw.as_str()) { return; } self.last_routing_request_raw = Some(raw.clone()); let Some(remote_capture) = parse_launcher_routing_request(&raw) else { return; }; if self.pending_kill { return; } if remote_capture { if !self.released && !self.pending_release { return; } info!("🎛️ launcher requested remote input capture"); self.enable_remote_capture(); self.publish_routing_state_if_changed(); } else { if self.released && !self.pending_release { return; } info!("🎛️ launcher requested local input capture"); self.begin_local_release(); } } #[cfg(not(coverage))] fn poll_launcher_quick_toggle_request(&mut self) { let Some(path) = self.quick_toggle_control_path.as_deref() else { return; }; let Some(raw) = read_launcher_control_snapshot(path) else { return; }; if self.last_quick_toggle_request_raw.as_deref() == Some(raw.as_str()) { return; } self.last_quick_toggle_request_raw = Some(raw.clone()); let next_key = raw .split_ascii_whitespace() .next() .and_then(parse_quick_toggle_key); self.quick_toggle_key = next_key; self.quick_toggle_down = false; self.last_quick_toggle_at = None; match next_key { Some(key) => info!("🎛️ launcher updated the live swap key to {:?}", key), None => info!("🎛️ launcher disabled the live swap key"), } } #[cfg(not(coverage))] fn poll_launcher_clipboard_request(&mut self) { let Some(path) = self.clipboard_control_path.as_deref() else { return; }; let marker = path_marker(path); if marker <= self.clipboard_control_marker { return; } self.clipboard_control_marker = marker; let Some(keyboard) = self.keyboards.first_mut() else { warn!("📋 launcher requested clipboard paste, but no keyboard is available"); return; }; info!("📋 launcher requested clipboard paste on the live relay session"); keyboard.trigger_clipboard_paste(); } #[cfg(not(coverage))] fn publish_routing_state_if_changed(&mut self) { let remote_capture = !self.released; if self.published_remote_capture == Some(remote_capture) { return; } if let Some(path) = self.routing_state_path.as_deref() { let _ = std::fs::write( path, if remote_capture { "remote\n" } else { "local\n" }, ); } self.published_remote_capture = Some(remote_capture); } } /// The classification function #[cfg(coverage)] fn classify_device(dev: &Device) -> DeviceKind { let evbits = dev.supported_events(); let keyset = dev.supported_keys(); if evbits.contains(EventType::KEY) && keyset .is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER)) { if should_ignore_keyboard_device(dev) { return DeviceKind::Other; } return DeviceKind::Keyboard; } if evbits.contains(EventType::RELATIVE) && let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), keyset) && rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y) && (keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT)) { return DeviceKind::Mouse; } if evbits.contains(EventType::ABSOLUTE) && let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), keyset) && ((abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y)) || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y))) && (keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT)) { return DeviceKind::Mouse; } DeviceKind::Other } #[cfg(not(coverage))] fn classify_device(dev: &Device) -> DeviceKind { let evbits = dev.supported_events(); // Keyboard logic if evbits.contains(EventType::KEY) { if let Some(keys) = dev.supported_keys() { if keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER) { if should_ignore_keyboard_device(dev) { return DeviceKind::Other; } return DeviceKind::Keyboard; } } } // Mouse logic (relative) if evbits.contains(EventType::RELATIVE) { if let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) { let has_xy = rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y); let has_btn = keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT); if has_xy && has_btn { return DeviceKind::Mouse; } } } // Touchpad logic (absolute) if evbits.contains(EventType::ABSOLUTE) { if let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), dev.supported_keys()) { let has_xy = (abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y)) || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y)); let has_btn = keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT); if has_xy && has_btn { return DeviceKind::Mouse; } } } DeviceKind::Other } /// Internal enum for device classification #[derive(Debug, Clone, Copy)] enum DeviceKind { Keyboard, Mouse, Other, } fn should_ignore_keyboard_device(dev: &Device) -> bool { let name = dev.name().unwrap_or_default().to_ascii_lowercase(); name.contains("lesavka") || name.contains("automation input") || name.contains("codex-persistent-kbd") } fn update_shadow_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) { if value == 0 { pressed_keys.remove(&code); } else if value > 0 { pressed_keys.insert(code); } } /// Resolves the quick-toggle key from env, defaulting to Pause/Break. fn quick_toggle_key_from_env() -> Option { match std::env::var("LESAVKA_INPUT_TOGGLE_KEY") { Ok(raw) => parse_quick_toggle_key(&raw), Err(_) => Some(KeyCode::KEY_PAUSE), } } /// Parses a launcher/operator key alias into an evdev key code. fn parse_quick_toggle_key(raw: &str) -> Option { let normalized = raw.trim().to_ascii_lowercase(); if matches!(normalized.as_str(), "" | "off" | "none" | "disabled") { return None; } if let Some(letter) = parse_quick_toggle_letter(&normalized) { return Some(letter); } if let Some(digit) = parse_quick_toggle_digit(&normalized) { return Some(digit); } if let Some(function) = parse_quick_toggle_function_key(&normalized) { return Some(function); } match normalized.as_str() { "scrolllock" | "scroll_lock" | "scroll-lock" => Some(KeyCode::KEY_SCROLLLOCK), "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { Some(KeyCode::KEY_SYSRQ) } "pause" | "pausebreak" | "pause_break" | "pause-break" => Some(KeyCode::KEY_PAUSE), "escape" | "esc" => Some(KeyCode::KEY_ESC), "tab" => Some(KeyCode::KEY_TAB), "capslock" | "caps_lock" | "caps-lock" => Some(KeyCode::KEY_CAPSLOCK), "backspace" | "back_space" | "back-space" => Some(KeyCode::KEY_BACKSPACE), "space" | "spacebar" => Some(KeyCode::KEY_SPACE), "enter" | "return" => Some(KeyCode::KEY_ENTER), "insert" => Some(KeyCode::KEY_INSERT), "delete" | "del" => Some(KeyCode::KEY_DELETE), "home" => Some(KeyCode::KEY_HOME), "end" => Some(KeyCode::KEY_END), "pageup" | "page_up" | "page-up" => Some(KeyCode::KEY_PAGEUP), "pagedown" | "page_down" | "page-down" => Some(KeyCode::KEY_PAGEDOWN), "left" => Some(KeyCode::KEY_LEFT), "right" => Some(KeyCode::KEY_RIGHT), "up" => Some(KeyCode::KEY_UP), "down" => Some(KeyCode::KEY_DOWN), _ => Some(KeyCode::KEY_PAUSE), } } fn parse_quick_toggle_letter(raw: &str) -> Option { match raw { "a" => Some(KeyCode::KEY_A), "b" => Some(KeyCode::KEY_B), "c" => Some(KeyCode::KEY_C), "d" => Some(KeyCode::KEY_D), "e" => Some(KeyCode::KEY_E), "f" => Some(KeyCode::KEY_F), "g" => Some(KeyCode::KEY_G), "h" => Some(KeyCode::KEY_H), "i" => Some(KeyCode::KEY_I), "j" => Some(KeyCode::KEY_J), "k" => Some(KeyCode::KEY_K), "l" => Some(KeyCode::KEY_L), "m" => Some(KeyCode::KEY_M), "n" => Some(KeyCode::KEY_N), "o" => Some(KeyCode::KEY_O), "p" => Some(KeyCode::KEY_P), "q" => Some(KeyCode::KEY_Q), "r" => Some(KeyCode::KEY_R), "s" => Some(KeyCode::KEY_S), "t" => Some(KeyCode::KEY_T), "u" => Some(KeyCode::KEY_U), "v" => Some(KeyCode::KEY_V), "w" => Some(KeyCode::KEY_W), "x" => Some(KeyCode::KEY_X), "y" => Some(KeyCode::KEY_Y), "z" => Some(KeyCode::KEY_Z), _ => None, } } fn parse_quick_toggle_digit(raw: &str) -> Option { match raw { "1" => Some(KeyCode::KEY_1), "2" => Some(KeyCode::KEY_2), "3" => Some(KeyCode::KEY_3), "4" => Some(KeyCode::KEY_4), "5" => Some(KeyCode::KEY_5), "6" => Some(KeyCode::KEY_6), "7" => Some(KeyCode::KEY_7), "8" => Some(KeyCode::KEY_8), "9" => Some(KeyCode::KEY_9), "0" => Some(KeyCode::KEY_0), _ => None, } } fn parse_quick_toggle_function_key(raw: &str) -> Option { match raw { "f1" => Some(KeyCode::KEY_F1), "f2" => Some(KeyCode::KEY_F2), "f3" => Some(KeyCode::KEY_F3), "f4" => Some(KeyCode::KEY_F4), "f5" => Some(KeyCode::KEY_F5), "f6" => Some(KeyCode::KEY_F6), "f7" => Some(KeyCode::KEY_F7), "f8" => Some(KeyCode::KEY_F8), "f9" => Some(KeyCode::KEY_F9), "f10" => Some(KeyCode::KEY_F10), "f11" => Some(KeyCode::KEY_F11), "f12" => Some(KeyCode::KEY_F12), _ => None, } } /// Reads debounce window from env, with a safety floor to avoid rapid flapping. fn quick_toggle_debounce_from_env() -> Duration { let millis = std::env::var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS") .ok() .and_then(|raw| raw.parse::().ok()) .unwrap_or(350); Duration::from_millis(millis.max(50)) } fn pending_release_timeout_from_env() -> Duration { let millis = std::env::var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS") .ok() .and_then(|raw| raw.parse::().ok()) .unwrap_or(750); Duration::from_millis(millis.max(100)) } #[cfg(not(coverage))] fn focus_launcher_on_local_if_enabled() { if std::env::var("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL") .map(|raw| raw.trim() == "0") .unwrap_or(false) { return; } let focus_signal_path = std::env::var("LESAVKA_LAUNCHER_FOCUS_SIGNAL") .unwrap_or_else(|_| "/tmp/lesavka-launcher-focus.signal".to_string()); let _ = std::fs::write( focus_signal_path, format!( "{}\n", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or_default() ), ); let title = std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE").unwrap_or_else(|_| "Lesavka".to_string()); let _ = std::process::Command::new("wmctrl") .args(["-a", &title]) .status(); } #[cfg(not(coverage))] fn launcher_routing_path_from_env(key: &str) -> Option { std::env::var(key) .ok() .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty()) } fn input_device_override_from_env(key: &str) -> Option { std::env::var(key) .ok() .map(|raw| raw.trim().to_string()) .filter(|raw| !raw.is_empty() && !raw.eq_ignore_ascii_case("all")) } fn matches_selected_input_device(path: &std::path::Path, selected: Option<&str>) -> bool { selected.is_none_or(|selected| path.to_string_lossy() == selected) } #[cfg(not(coverage))] fn read_launcher_control_snapshot(path: &Path) -> Option { let raw = std::fs::read_to_string(path).ok()?; let trimmed = raw.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } #[cfg(not(coverage))] fn parse_launcher_routing_request(raw: &str) -> Option { match raw .split_ascii_whitespace() .next()? .to_ascii_lowercase() .as_str() { "remote" => Some(true), "local" => Some(false), _ => None, } } #[cfg(not(coverage))] fn path_marker(path: &Path) -> u128 { std::fs::metadata(path) .ok() .and_then(|meta| meta.modified().ok()) .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or_default() } #[cfg(test)] mod tests { use super::parse_quick_toggle_key; use evdev::KeyCode; #[test] fn parse_quick_toggle_key_supports_letters_digits_and_function_keys() { assert_eq!(parse_quick_toggle_key("a"), Some(KeyCode::KEY_A)); assert_eq!(parse_quick_toggle_key("7"), Some(KeyCode::KEY_7)); assert_eq!(parse_quick_toggle_key("f12"), Some(KeyCode::KEY_F12)); assert_eq!(parse_quick_toggle_key("F3"), Some(KeyCode::KEY_F3)); } #[test] fn parse_quick_toggle_key_supports_navigation_and_special_aliases() { assert_eq!(parse_quick_toggle_key("page_up"), Some(KeyCode::KEY_PAGEUP)); assert_eq!(parse_quick_toggle_key("delete"), Some(KeyCode::KEY_DELETE)); assert_eq!(parse_quick_toggle_key("spacebar"), Some(KeyCode::KEY_SPACE)); assert_eq!( parse_quick_toggle_key("print-screen"), Some(KeyCode::KEY_SYSRQ) ); } #[test] fn parse_quick_toggle_key_can_disable_or_fall_back() { assert_eq!(parse_quick_toggle_key("off"), None); assert_eq!( parse_quick_toggle_key("totally-unknown"), Some(KeyCode::KEY_PAUSE) ); } }