lesavka: merge live keyboard state across devices

This commit is contained in:
Brad Stein 2026-04-16 15:07:25 -03:00
parent 95445e9252
commit 20cb355aa0
4 changed files with 365 additions and 111 deletions

View File

@ -16,7 +16,10 @@ use tracing::{debug, info, warn};
use lesavka_common::lesavka::{KeyboardReport, MouseReport};
use super::{keyboard::KeyboardAggregator, mouse::MouseAggregator};
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;
@ -29,6 +32,7 @@ pub struct InputAggregator {
pending_release: bool,
pending_kill: bool,
pending_keys: HashSet<KeyCode>,
last_keyboard_report: [u8; 8],
paste_tx: Option<UnboundedSender<String>>,
keyboards: Vec<KeyboardAggregator>,
mice: Vec<MouseAggregator>,
@ -87,6 +91,7 @@ impl InputAggregator {
pending_release: false,
pending_kill: false,
pending_keys: HashSet::new(),
last_keyboard_report: [0; 8],
paste_tx,
keyboards: Vec::new(),
mice: Vec::new(),
@ -280,9 +285,7 @@ impl InputAggregator {
#[cfg(coverage)]
pub async fn run(&mut self) -> Result<()> {
loop {
for kbd in &mut self.keyboards {
kbd.process_events();
}
self.process_keyboard_updates();
let quick_toggle_now = self.quick_toggle_active();
self.observe_quick_toggle(quick_toggle_now);
@ -332,8 +335,8 @@ impl InputAggregator {
self.publish_routing_state_if_changed();
loop {
let mut want_kill = false;
for kbd in &mut self.keyboards {
kbd.process_events();
self.process_keyboard_updates();
for kbd in &self.keyboards {
want_kill |= kbd.magic_kill();
}
self.poll_launcher_routing_request();
@ -442,6 +445,7 @@ impl InputAggregator {
}
self.released = false;
self.pending_release = false;
self.last_keyboard_report = [0; 8];
}
fn begin_local_release(&mut self) {
@ -454,6 +458,7 @@ impl InputAggregator {
m.set_send(false);
}
self.pending_release = true;
self.last_keyboard_report = [0; 8];
self.capture_pending_keys();
}
@ -466,6 +471,42 @@ impl InputAggregator {
}
}
fn process_keyboard_updates(&mut self) {
for index in 0..self.keyboards.len() {
let updates = {
let keyboard = &mut self.keyboards[index];
keyboard.drain_key_updates()
};
for update in updates {
if update.swallowed || !self.keyboard_capture_enabled() {
continue;
}
let report = self.build_combined_keyboard_report();
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 build_combined_keyboard_report(&self) -> [u8; 8] {
let mut pressed = HashSet::new();
for keyboard in &self.keyboards {
for key in keyboard.pressed_keys_snapshot() {
pressed.insert(key);
}
}
build_keyboard_report(pressed.into_iter())
}
fn quick_toggle_active(&mut self) -> bool {
self.quick_toggle_key.is_some_and(|key| {
self.keyboards
@ -568,6 +609,9 @@ fn classify_device(dev: &Device) -> DeviceKind {
&& 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;
}
@ -601,6 +645,9 @@ fn classify_device(dev: &Device) -> DeviceKind {
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;
}
}
@ -642,6 +689,13 @@ enum DeviceKind {
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")
}
/// 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") {

View File

@ -15,6 +15,14 @@ use lesavka_common::lesavka::KeyboardReport;
use super::keymap::{is_modifier, keycode_to_usage};
use lesavka_common::hid::append_char_reports;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyboardEventUpdate {
pub code: KeyCode,
pub value: i32,
pub swallowed: bool,
pub report: [u8; 8],
}
pub struct KeyboardAggregator {
dev: Device,
tx: Sender<KeyboardReport>,
@ -94,104 +102,17 @@ impl KeyboardAggregator {
self.send_report([0; 8]);
}
#[cfg(coverage)]
pub fn process_events(&mut self) {
self.recent_key_presses.clear();
let Ok(events) = self
.dev
.fetch_events()
.map(|it| it.collect::<Vec<InputEvent>>())
else {
return;
};
for ev in events {
if ev.event_type() != EventType::KEY {
for update in self.drain_key_updates() {
if update.swallowed {
continue;
}
let code = KeyCode::new(ev.code());
let value = ev.value();
update_pressed_keys(&mut self.pressed_keys, code, value);
if value == 1 {
self.recent_key_presses.insert(code);
}
let swallowed = self.try_handle_paste_event(code, value);
if !swallowed {
let report = self.build_report();
self.emit_live_report(code, value, report);
}
}
}
#[cfg(not(coverage))]
pub fn process_events(&mut self) {
self.recent_key_presses.clear();
// --- 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());
let value = ev.value();
update_pressed_keys(&mut self.pressed_keys, code, value);
if value == 1 {
self.recent_key_presses.insert(code);
}
if self.try_handle_paste_event(code, value) {
continue;
}
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");
}
self.emit_live_report(code, value, report);
self.emit_live_report(update.code, update.value, update.report);
}
}
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;
continue;
}
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
build_keyboard_report(self.pressed_keys.iter().copied())
}
pub fn has_key(&self, kc: KeyCode) -> bool {
@ -206,6 +127,10 @@ impl KeyboardAggregator {
self.pressed_keys.iter().copied().collect()
}
pub fn sending_enabled(&self) -> bool {
!self.sending_disabled
}
pub fn magic_grab(&self) -> bool {
self.has_key(KeyCode::KEY_LEFTCTRL)
&& self.has_key(KeyCode::KEY_LEFTSHIFT)
@ -243,20 +168,53 @@ impl KeyboardAggregator {
if self.sending_disabled {
return;
}
let _ = self.tx.send(KeyboardReport {
data: report.to_vec(),
});
send_keyboard_report(&self.tx, report);
}
fn emit_live_report(&self, code: KeyCode, value: i32, report: [u8; 8]) {
if should_stage_modifier_report(code, value, report) {
self.send_report(modifier_only_report(report[0]));
let delay = live_modifier_delay();
if !delay.is_zero() {
std::thread::sleep(delay);
}
if self.sending_disabled {
return;
}
self.send_report(report);
emit_live_keyboard_report(&self.tx, code, value, report);
}
pub fn drain_key_updates(&mut self) -> Vec<KeyboardEventUpdate> {
self.recent_key_presses.clear();
let events = self.fetch_events();
if self.dev_mode && !events.is_empty() {
trace!(
"⌨️ {} kbd evts from {}",
events.len(),
self.dev.name().unwrap_or("?")
);
}
let mut updates = Vec::with_capacity(events.len());
for ev in events {
if ev.event_type() != EventType::KEY {
continue;
}
let code = KeyCode::new(ev.code());
let value = ev.value();
update_pressed_keys(&mut self.pressed_keys, code, value);
if value == 1 {
self.recent_key_presses.insert(code);
}
let swallowed = self.try_handle_paste_event(code, value);
let report = self.build_report();
let id = SEQ.fetch_add(1, Ordering::Relaxed);
if self.dev_mode {
debug!(seq = id, ?report, code = ?code, value, swallowed, "kbd");
}
updates.push(KeyboardEventUpdate {
code,
value,
swallowed,
report,
});
}
updates
}
#[cfg(coverage)]
@ -478,6 +436,81 @@ impl KeyboardAggregator {
}
}
#[cfg(coverage)]
impl KeyboardAggregator {
fn fetch_events(&mut self) -> Vec<InputEvent> {
self.dev
.fetch_events()
.map(|it| it.collect::<Vec<InputEvent>>())
.unwrap_or_default()
}
}
#[cfg(not(coverage))]
impl KeyboardAggregator {
fn fetch_events(&mut self) -> Vec<InputEvent> {
match self.dev.fetch_events() {
Ok(it) => it.collect(),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Vec::new(),
Err(e) => {
if self.dev_mode {
error!("⌨️❌ read error: {e}");
}
Vec::new()
}
}
}
}
pub fn build_keyboard_report<I>(pressed_keys: I) -> [u8; 8]
where
I: IntoIterator<Item = KeyCode>,
{
let mut out = [0u8; 8];
let mut mods = 0u8;
let mut keys = Vec::new();
for kc in pressed_keys {
if let Some(m) = is_modifier(kc) {
mods |= m;
continue;
}
if let Some(u) = keycode_to_usage(kc) {
keys.push(u);
}
}
keys.sort_unstable();
keys.dedup();
out[0] = mods;
for (i, k) in keys.into_iter().take(6).enumerate() {
out[2 + i] = k;
}
out
}
pub fn emit_live_keyboard_report(
tx: &Sender<KeyboardReport>,
code: KeyCode,
value: i32,
report: [u8; 8],
) {
if should_stage_modifier_report(code, value, report) {
send_keyboard_report(tx, modifier_only_report(report[0]));
let delay = live_modifier_delay();
if !delay.is_zero() {
std::thread::sleep(delay);
}
}
send_keyboard_report(tx, report);
}
pub fn send_keyboard_report(tx: &Sender<KeyboardReport>, report: [u8; 8]) {
let _ = tx.send(KeyboardReport {
data: report.to_vec(),
});
}
fn paste_rpc_enabled_from_env() -> bool {
let rpc_enabled = std::env::var("LESAVKA_PASTE_RPC")
.map(|v| v != "0")

View File

@ -50,7 +50,7 @@ mod inputs_contract {
let mut vdev = VirtualDevice::builder()
.ok()?
.name("lesavka-input-classify-kbd")
.name("input-classify-kbd")
.with_keys(&keys)
.ok()?
.build()
@ -120,6 +120,22 @@ mod inputs_contract {
open_virtual_device(&mut vdev)
}
fn build_named_keyboard(name: &str) -> Option<evdev::Device> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::KEY_A);
keys.insert(evdev::KeyCode::KEY_ENTER);
let mut vdev = VirtualDevice::builder()
.ok()?
.name(name)
.with_keys(&keys)
.ok()?
.build()
.ok()?;
open_virtual_device(&mut vdev)
}
fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::KEY_A);
@ -184,6 +200,17 @@ mod inputs_contract {
}
}
#[test]
#[serial]
fn classify_device_ignores_synthetic_automation_keyboards() {
if let Some(automation) = build_named_keyboard("Lesavka Automation Input") {
assert!(matches!(classify_device(&automation), DeviceKind::Other));
}
if let Some(persistent) = build_named_keyboard("codex-persistent-kbd") {
assert!(matches!(classify_device(&persistent), DeviceKind::Other));
}
}
#[test]
fn toggle_grab_switches_into_local_control_mode() {
let mut agg = new_aggregator();

View File

@ -42,10 +42,14 @@ mod inputs_contract_extra {
None
}
fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
fn build_keyboard_pair_with_keys(
name: &str,
keycodes: &[evdev::KeyCode],
) -> Option<(VirtualDevice, evdev::Device)> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::KEY_A);
keys.insert(evdev::KeyCode::KEY_ENTER);
for keycode in keycodes {
keys.insert(*keycode);
}
let mut vdev = VirtualDevice::builder()
.ok()?
@ -59,6 +63,10 @@ mod inputs_contract_extra {
Some((vdev, dev))
}
fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER])
}
#[test]
#[serial]
fn quick_toggle_detects_tap_when_press_and_release_land_in_same_poll_cycle() {
@ -92,4 +100,136 @@ mod inputs_contract_extra {
"tap activation should be consumed after one observation"
);
}
#[test]
#[serial]
fn process_keyboard_updates_merges_modifier_and_key_across_keyboards() {
let Some((mut shift_vdev, shift_dev)) = build_keyboard_pair_with_keys(
"lesavka-input-merge-shift",
&[evdev::KeyCode::KEY_LEFTSHIFT],
) else {
return;
};
let Some((mut a_vdev, a_dev)) =
build_keyboard_pair_with_keys("lesavka-input-merge-a", &[evdev::KeyCode::KEY_A])
else {
return;
};
let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(16);
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
let shift_keyboard = KeyboardAggregator::new(shift_dev, false, kbd_tx.clone(), None);
let a_keyboard = KeyboardAggregator::new(a_dev, false, kbd_tx.clone(), None);
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
agg.keyboards.push(shift_keyboard);
agg.keyboards.push(a_keyboard);
shift_vdev
.emit(&[evdev::InputEvent::new(
evdev::EventType::KEY.0,
evdev::KeyCode::KEY_LEFTSHIFT.0,
1,
)])
.expect("emit shift");
a_vdev
.emit(&[evdev::InputEvent::new(
evdev::EventType::KEY.0,
evdev::KeyCode::KEY_A.0,
1,
)])
.expect("emit a");
thread::sleep(std::time::Duration::from_millis(25));
temp_env::with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
agg.process_keyboard_updates();
});
let reports: Vec<Vec<u8>> =
std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect();
assert!(
reports.contains(&vec![0x02, 0, 0x04, 0, 0, 0, 0, 0]),
"expected merged shift+A report, got {reports:?}"
);
}
#[test]
#[serial]
fn process_keyboard_updates_keeps_overlapping_plain_keys_from_sticking_across_keyboards() {
let Some((mut a_vdev, a_dev)) =
build_keyboard_pair_with_keys("lesavka-input-merge-a-only", &[evdev::KeyCode::KEY_A])
else {
return;
};
let Some((mut s_vdev, s_dev)) =
build_keyboard_pair_with_keys("lesavka-input-merge-s-only", &[evdev::KeyCode::KEY_S])
else {
return;
};
let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(32);
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
let a_keyboard = KeyboardAggregator::new(a_dev, false, kbd_tx.clone(), None);
let s_keyboard = KeyboardAggregator::new(s_dev, false, kbd_tx.clone(), None);
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
agg.keyboards.push(a_keyboard);
agg.keyboards.push(s_keyboard);
a_vdev
.emit(&[evdev::InputEvent::new(
evdev::EventType::KEY.0,
evdev::KeyCode::KEY_A.0,
1,
)])
.expect("emit a down");
s_vdev
.emit(&[evdev::InputEvent::new(
evdev::EventType::KEY.0,
evdev::KeyCode::KEY_S.0,
1,
)])
.expect("emit s down");
thread::sleep(std::time::Duration::from_millis(25));
agg.process_keyboard_updates();
a_vdev
.emit(&[evdev::InputEvent::new(
evdev::EventType::KEY.0,
evdev::KeyCode::KEY_A.0,
0,
)])
.expect("emit a up");
s_vdev
.emit(&[evdev::InputEvent::new(
evdev::EventType::KEY.0,
evdev::KeyCode::KEY_S.0,
0,
)])
.expect("emit s up");
thread::sleep(std::time::Duration::from_millis(25));
agg.process_keyboard_updates();
let reports: Vec<Vec<u8>> =
std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect();
assert!(
reports.contains(&vec![0, 0, 0x04, 0, 0, 0, 0, 0]),
"expected A-down report, got {reports:?}"
);
assert!(
reports.iter().any(|pkt| {
let keys = &pkt[2..8];
keys.contains(&0x04) && keys.contains(&0x16)
}),
"expected merged A+S overlap report, got {reports:?}"
);
assert!(
reports.contains(&vec![0, 0, 0x16, 0, 0, 0, 0, 0]),
"expected lone S report after A release, got {reports:?}"
);
assert!(
reports.contains(&vec![0; 8]),
"expected final empty report after both releases, got {reports:?}"
);
}
}