// 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; 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, 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, paste_tx: Option>, keyboards: Vec, mice: Vec, capture_remote_boot: bool, quick_toggle_key: Option, quick_toggle_down: bool, quick_toggle_debounce: Duration, last_quick_toggle_at: Option, } 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(); Self { kbd_tx, mou_tx, dev_mode, released: !capture_remote_boot, magic_active: false, pending_release: false, pending_kill: false, pending_keys: HashSet::new(), paste_tx, keyboards: Vec::new(), mice: Vec::new(), capture_remote_boot, quick_toggle_key, quick_toggle_down: false, quick_toggle_debounce: quick_toggle_debounce_from_env(), last_quick_toggle_at: None, } } #[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 => { 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 => { 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 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 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 { for kbd in &mut self.keyboards { kbd.process_events(); } 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; loop { let mut want_kill = false; for kbd in &mut self.keyboards { kbd.process_events(); want_kill |= kbd.magic_kill(); } 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!!! 💥💀⚰️"); 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(); } 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; if !self.pending_kill { focus_launcher_on_local_if_enabled(); } if self.pending_kill { return Ok(()); } self.pending_release = false; self.pending_keys.clear(); } } 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 { 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; } else { 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.capture_pending_keys(); } } 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 quick_toggle_active(&self) -> bool { self.quick_toggle_key .is_some_and(|key| self.keyboards.iter().any(|kbd| kbd.has_key(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; } } /// 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)) { 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) { 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, } /// 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(); match normalized.as_str() { "" | "off" | "none" | "disabled" => None, "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), "f12" => Some(KeyCode::KEY_F12), "f11" => Some(KeyCode::KEY_F11), "f10" => Some(KeyCode::KEY_F10), _ => Some(KeyCode::KEY_PAUSE), } } /// 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)) } #[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 Launcher".to_string()); let _ = std::process::Command::new("wmctrl") .args(["-a", &title]) .status(); }