lesavka/client/src/input/keyboard.rs

351 lines
11 KiB
Rust

// 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<KeyboardReport>,
dev_mode: bool,
sending_disabled: bool,
paste_enabled: bool,
paste_rpc_enabled: bool,
paste_tx: Option<UnboundedSender<String>>,
pressed_keys: HashSet<KeyCode>,
}
/*───────── 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<KeyboardReport>,
paste_tx: Option<UnboundedSender<String>>,
) -> 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<InputEvent> = 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<KeyCode> {
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::<u64>().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::<usize>().ok())
.unwrap_or(4096);
let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().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<String> {
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(),
});
}
}