lesavka/client/src/input/inputs.rs

674 lines
23 KiB
Rust
Raw Normal View History

2025-06-08 22:24:14 -05:00
// 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::time::Instant;
use tokio::{
sync::broadcast::Sender,
time::{Duration, interval},
};
2025-11-30 23:41:29 -03:00
use tracing::{debug, info, warn};
2025-06-08 22:24:14 -05:00
2025-06-23 07:18:26 -05:00
use lesavka_common::lesavka::{KeyboardReport, MouseReport};
2025-07-04 01:56:59 -05:00
use super::{keyboard::KeyboardAggregator, mouse::MouseAggregator};
2025-06-29 04:54:39 -05:00
use crate::layout::{Layout, apply as apply_layout};
use tokio::sync::mpsc::UnboundedSender;
2025-06-08 22:24:14 -05:00
pub struct InputAggregator {
2025-06-17 20:54:31 -05:00
kbd_tx: Sender<KeyboardReport>,
mou_tx: Sender<MouseReport>,
dev_mode: bool,
2025-06-28 15:45:35 -05:00
released: bool,
2025-06-28 17:55:15 -05:00
magic_active: bool,
pending_release: bool,
pending_kill: bool,
pending_keys: HashSet<KeyCode>,
paste_tx: Option<UnboundedSender<String>>,
2025-06-17 20:54:31 -05:00
keyboards: Vec<KeyboardAggregator>,
mice: Vec<MouseAggregator>,
capture_remote_boot: bool,
quick_toggle_key: Option<KeyCode>,
quick_toggle_down: bool,
quick_toggle_debounce: Duration,
last_quick_toggle_at: Option<Instant>,
#[cfg(not(coverage))]
routing_control_path: Option<PathBuf>,
#[cfg(not(coverage))]
routing_control_marker: u128,
#[cfg(not(coverage))]
routing_state_path: Option<PathBuf>,
#[cfg(not(coverage))]
published_remote_capture: Option<bool>,
2025-06-08 22:24:14 -05:00
}
impl InputAggregator {
pub fn new(
dev_mode: bool,
kbd_tx: Sender<KeyboardReport>,
mou_tx: Sender<MouseReport>,
paste_tx: Option<UnboundedSender<String>>,
) -> 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<KeyboardReport>,
mou_tx: Sender<MouseReport>,
paste_tx: Option<UnboundedSender<String>>,
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");
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(not(coverage))]
routing_control_marker: routing_control_path
.as_deref()
.map(path_marker)
.unwrap_or_default(),
#[cfg(not(coverage))]
routing_control_path,
#[cfg(not(coverage))]
routing_state_path,
#[cfg(not(coverage))]
published_remote_capture: None,
}
2025-06-08 22:24:14 -05:00
}
#[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))]
2025-06-08 22:24:14 -05:00
pub fn init(&mut self) -> Result<()> {
let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?;
2025-06-08 22:24:14 -05:00
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"))
{
2025-06-08 22:24:14 -05:00
continue;
}
2025-06-29 04:54:39 -05:00
let mut dev = match Device::open(&path) {
2025-06-08 22:24:14 -05:00
Ok(d) => d,
2025-06-29 04:17:44 -05:00
Err(e) => {
2025-06-29 04:54:39 -05:00
warn!("❌ open {}: {e}", path.display());
2025-06-29 04:17:44 -05:00
continue;
}
2025-06-08 22:24:14 -05:00
};
dev.set_nonblocking(true)
.with_context(|| format!("set_non_blocking {:?}", path))?;
2025-06-11 00:37:01 -05:00
2025-06-08 22:24:14 -05:00
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")
);
}
2025-06-11 00:37:01 -05:00
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);
}
2025-06-08 22:24:14 -05:00
self.keyboards.push(kbd_agg);
found_any = true;
2025-06-11 00:37:01 -05:00
continue;
2025-06-08 22:24:14 -05:00
}
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")
);
}
2025-06-11 00:37:01 -05:00
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);
}
2025-06-08 22:24:14 -05:00
self.mice.push(mouse_agg);
found_any = true;
2025-06-11 00:37:01 -05:00
continue;
2025-06-08 22:24:14 -05:00
}
DeviceKind::Other => {
debug!(
"Skipping non-kbd/mouse device: {:?}",
dev.name().unwrap_or("UNKNOWN")
);
2025-06-11 00:37:01 -05:00
continue;
2025-06-08 22:24:14 -05:00
}
}
}
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))]
2025-06-08 22:24:14 -05:00
pub async fn run(&mut self) -> Result<()> {
// Example approach: poll each aggregator in a simple loop
let mut tick = interval(Duration::from_millis(10));
2025-06-29 04:54:39 -05:00
let mut current = Layout::SideBySide;
self.publish_routing_state_if_changed();
2025-06-08 22:24:14 -05:00
loop {
let mut want_kill = false;
2025-06-08 22:24:14 -05:00
for kbd in &mut self.keyboards {
kbd.process_events();
want_kill |= kbd.magic_kill();
2025-06-08 22:24:14 -05:00
}
self.poll_launcher_routing_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());
2025-06-28 15:45:35 -05:00
if magic_now && !self.magic_active {
self.toggle_grab();
}
2025-06-29 04:54:39 -05:00
if (magic_left || magic_right) && self.magic_active {
current = match current {
Layout::SideBySide => Layout::FullLeft,
Layout::FullLeft => Layout::FullRight,
Layout::FullRight => Layout::SideBySide,
2025-06-29 04:54:39 -05:00
};
apply_layout(current);
}
if want_kill && !self.pending_kill {
2025-06-28 15:45:35 -05:00
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();
}
self.publish_routing_state_if_changed();
if self.pending_kill {
return Ok(());
}
self.pending_release = false;
self.pending_keys.clear();
}
2025-06-28 15:45:35 -05:00
}
2025-06-08 22:24:14 -05:00
for mouse in &mut self.mice {
mouse.process_events();
}
2025-06-28 17:55:15 -05:00
self.magic_active = magic_now;
2025-06-08 22:24:14 -05:00
tick.tick().await;
}
}
2025-06-28 15:45:35 -05:00
fn toggle_grab(&mut self) {
if self.pending_release || self.pending_kill {
return;
}
2025-06-28 15:45:35 -05:00
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) {
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;
}
fn begin_local_release(&mut self) {
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);
}
}
2025-06-28 15:45:35 -05:00
}
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;
}
#[cfg(not(coverage))]
fn poll_launcher_routing_request(&mut self) {
let Some(path) = self.routing_control_path.as_deref() else {
return;
};
let marker = path_marker(path);
if marker <= self.routing_control_marker {
return;
}
self.routing_control_marker = marker;
let Some(remote_capture) = read_launcher_routing_request(path) else {
return;
};
if self.pending_release || self.pending_kill || remote_capture == !self.released {
return;
}
if remote_capture {
info!("🎛️ launcher requested remote input capture");
self.enable_remote_capture();
self.publish_routing_state_if_changed();
} else {
info!("🎛️ launcher requested local input capture");
self.begin_local_release();
}
}
#[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);
}
2025-06-08 22:24:14 -05:00
}
/// 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))]
2025-06-08 22:24:14 -05:00
fn classify_device(dev: &Device) -> DeviceKind {
let evbits = dev.supported_events();
2025-06-11 00:37:01 -05:00
// Keyboard logic
2025-06-08 22:24:14 -05:00
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;
}
}
}
2025-06-11 00:37:01 -05:00
// Mouse logic (relative)
2025-06-11 00:37:01 -05:00
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);
2025-06-11 00:37:01 -05:00
if has_xy && has_btn {
2025-06-08 22:24:14 -05:00
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;
}
}
}
2025-06-08 22:24:14 -05:00
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<KeyCode> {
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<KeyCode> {
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::<u64>().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();
}
#[cfg(not(coverage))]
fn launcher_routing_path_from_env(key: &str) -> Option<PathBuf> {
std::env::var(key)
.ok()
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
}
#[cfg(not(coverage))]
fn read_launcher_routing_request(path: &Path) -> Option<bool> {
let raw = std::fs::read_to_string(path).ok()?;
match raw.trim().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()
}