// client/src/input/keyboard.rs use evdev::{Device, EventType, InputEvent, KeyCode}; use std::{ collections::HashSet, sync::atomic::{AtomicU32, AtomicU64, Ordering}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::sync::broadcast::Sender; use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, error, trace}; use lesavka_common::lesavka::KeyboardReport; use super::keymap::{char_to_usage, is_modifier, keycode_to_usage}; pub struct KeyboardAggregator { dev: Device, tx: Sender, dev_mode: bool, sending_disabled: bool, paste_enabled: bool, paste_rpc_enabled: bool, paste_tx: Option>, pressed_keys: HashSet, } /*───────── helpers ───────────────────────────────────────────────────*/ /// Monotonically-increasing ID that can be logged on server & client. static SEQ: AtomicU32 = AtomicU32::new(0); static LAST_PASTE_MS: AtomicU64 = AtomicU64::new(0); impl KeyboardAggregator { pub fn new( dev: Device, dev_mode: bool, tx: Sender, paste_tx: Option>, ) -> Self { let _ = dev.set_nonblocking(true); Self { dev, tx, dev_mode, sending_disabled: false, paste_enabled: std::env::var("LESAVKA_CLIPBOARD_PASTE") .map(|v| v != "0") .unwrap_or(true), paste_rpc_enabled: std::env::var("LESAVKA_PASTE_RPC") .map(|v| v != "0") .unwrap_or(true), paste_tx, pressed_keys: HashSet::new(), } } pub fn set_grab(&mut self, grab: bool) { let _ = if grab { self.dev.grab() } else { self.dev.ungrab() }; } pub fn set_send(&mut self, send: bool) { self.sending_disabled = !send; } pub fn send_empty_report(&self) { self.send_report([0; 8]); } pub fn process_events(&mut self) { // --- first fetch, then log (avoids aliasing borrow) --- let events: Vec = match self.dev.fetch_events() { Ok(it) => it.collect(), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return, Err(e) => { if self.dev_mode { error!("⌨️❌ read error: {e}"); } return; } }; if self.dev_mode && !events.is_empty() { trace!( "⌨️ {} kbd evts from {}", events.len(), self.dev.name().unwrap_or("?") ); } for ev in events { if ev.event_type() != EventType::KEY { continue; } let code = KeyCode::new(ev.code()); if self.paste_enabled && ev.value() == 1 && code == KeyCode::KEY_V && self.paste_chord_active() { if !self.paste_debounced() { continue; } // swallow Ctrl+V and inject clipboard text instead self.pressed_keys.remove(&KeyCode::KEY_V); self.pressed_keys.remove(&KeyCode::KEY_LEFTCTRL); self.pressed_keys.remove(&KeyCode::KEY_RIGHTCTRL); self.pressed_keys.remove(&KeyCode::KEY_LEFTALT); self.pressed_keys.remove(&KeyCode::KEY_RIGHTALT); self.send_empty_report(); if self.paste_rpc_enabled && self.paste_via_rpc() { continue; } self.paste_clipboard(); continue; } match ev.value() { 1 => { self.pressed_keys.insert(code); } // press 0 => { self.pressed_keys.remove(&code); } // release _ => {} } let report = self.build_report(); // Generate a local sequence number for debugging/log-merge only. let id = SEQ.fetch_add(1, Ordering::Relaxed); if self.dev_mode { debug!(seq = id, ?report, "kbd"); } if !self.sending_disabled { let _ = self.tx.send(KeyboardReport { data: report.to_vec(), }); } } } fn build_report(&self) -> [u8; 8] { let mut out = [0u8; 8]; let mut mods = 0u8; let mut keys = Vec::new(); for &kc in &self.pressed_keys { if let Some(m) = is_modifier(kc) { mods |= m } else if let Some(u) = keycode_to_usage(kc) { keys.push(u) } } out[0] = mods; for (i, k) in keys.into_iter().take(6).enumerate() { out[2 + i] = k } out } pub fn has_key(&self, kc: KeyCode) -> bool { self.pressed_keys.contains(&kc) } pub fn pressed_keys_snapshot(&self) -> Vec { self.pressed_keys.iter().copied().collect() } pub fn magic_grab(&self) -> bool { self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_LEFTSHIFT) && self.has_key(KeyCode::KEY_G) } pub fn magic_left(&self) -> bool { self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_LEFTSHIFT) && self.has_key(KeyCode::KEY_LEFT) } pub fn magic_right(&self) -> bool { self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_LEFTSHIFT) && self.has_key(KeyCode::KEY_RIGHT) } pub fn magic_kill(&self) -> bool { self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_ESC) } pub fn reset_state(&mut self) { if self.pressed_keys.is_empty() { self.send_empty_report(); return; } self.pressed_keys.clear(); self.send_empty_report(); } fn send_report(&self, report: [u8; 8]) { if self.sending_disabled { return; } let _ = self.tx.send(KeyboardReport { data: report.to_vec(), }); } fn paste_chord_active(&self) -> bool { let chord = std::env::var("LESAVKA_CLIPBOARD_CHORD") .unwrap_or_else(|_| "ctrl+alt+v".into()) .to_ascii_lowercase(); let have_ctrl = self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL); let have_alt = self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT); match chord.as_str() { "ctrl+v" => have_ctrl, "ctrl+alt+v" => have_ctrl && have_alt, _ => have_ctrl && have_alt, } } fn paste_debounced(&self) -> bool { let debounce_ms = std::env::var("LESAVKA_CLIPBOARD_DEBOUNCE_MS") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(500); if debounce_ms == 0 { return true; } let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let last = LAST_PASTE_MS.load(Ordering::Relaxed); if now_ms.saturating_sub(last) < debounce_ms { tracing::debug!("📋 paste ignored (debounce)"); return false; } LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); true } fn paste_clipboard(&self) { let text = match read_clipboard_text() { Some(t) if !t.is_empty() => t, Some(_) => { tracing::warn!("📋 clipboard empty"); return; } None => { tracing::warn!("📋 clipboard read failed"); return; } }; let max = std::env::var("LESAVKA_CLIPBOARD_MAX") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(4096); let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(1); let delay = Duration::from_millis(delay_ms); tracing::info!("📋 pasting {} chars", text.chars().count().min(max)); for c in text.chars().take(max) { if let Some((usage, mods)) = char_to_usage(c) { self.send_report([mods, 0, usage, 0, 0, 0, 0, 0]); self.send_report([0; 8]); if delay_ms > 0 { std::thread::sleep(delay); } } } } fn paste_via_rpc(&self) -> bool { let Some(tx) = self.paste_tx.as_ref() else { return false; }; let text = match read_clipboard_text() { Some(t) if !t.is_empty() => t, Some(_) => { tracing::warn!("📋 clipboard empty"); return true; } None => { tracing::warn!("📋 clipboard read failed"); return true; } }; tx.send(text).is_ok() } } fn read_clipboard_text() -> Option { if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") { if let Ok(out) = std::process::Command::new("sh") .arg("-lc") .arg(cmd.clone()) .output() { if out.status.success() { let text = String::from_utf8_lossy(&out.stdout).to_string(); if !text.is_empty() { return Some(text); } tracing::warn!("📋 clipboard command returned empty"); } else { let err = String::from_utf8_lossy(&out.stderr); tracing::warn!("📋 clipboard command failed: {cmd} ({err})"); } } else { tracing::warn!("📋 clipboard command failed to spawn: {cmd}"); } // fall through to auto-detect if custom command fails } let candidates: &[(&str, &[&str])] = &[ ("wl-paste", &["--no-newline", "--type", "text/plain"]), ("wl-paste", &["--no-newline"]), ("wl-paste", &[]), ("xclip", &["-selection", "clipboard", "-o"]), ("xsel", &["-b", "-o"]), ]; for (cmd, args) in candidates { if let Ok(out) = std::process::Command::new(cmd).args(*args).output() { if out.status.success() { return Some(String::from_utf8_lossy(&out.stdout).to_string()); } } } None } impl Drop for KeyboardAggregator { fn drop(&mut self) { let _ = self.dev.ungrab(); let _ = self.tx.send(KeyboardReport { data: [0; 8].into(), }); } }