diff --git a/client/Cargo.toml b/client/Cargo.toml index 46c16da..6320e9a 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,16 +1,22 @@ [package] -name = "navka-client" -version = "0.1.0" -edition = "2024" +name = "navka_client" +version = "0.2.0" +edition = "2024" [dependencies] -tokio = { version = "1.45", features = ["full", "fs"] } -tonic = "0.11" -tokio-stream = { version = "0.1", features = ["sync"] } -anyhow = "1.0" -navka-common = { path = "../common" } -tracing = { version = "0.1", features = ["std"] } -tracing-subscriber = "0.3" +tokio = { version = "1.45", features = ["full", "fs"] } +tonic = { version = "0.13", features = ["transport"] } +tokio-stream = { version = "0.1", features = ["sync"] } +anyhow = "1.0" +navka_common = { path = "../common" } +tracing = { version = "0.1", features = ["std"] } +tracing-subscriber = "0.3" +futures = "0.3" +evdev = "0.13" [build-dependencies] -prost-build = "0.12" +prost-build = "0.13" + +[lib] +name = "navka_client" +path = "src/lib.rs" diff --git a/client/src/app.rs b/client/src/app.rs new file mode 100644 index 0000000..11c37ce --- /dev/null +++ b/client/src/app.rs @@ -0,0 +1,56 @@ +use anyhow::{Context, Result}; +use navka_common::navka::{relay_client::RelayClient, HidReport}; +use tokio::sync::mpsc; +// use tokio_stream::{wrappers::ReceiverStream, StreamExt}; +// use tonic::{Status, Request}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::Request; +use crate::input::keyboard::KeyboardAggregator; + +pub struct NavkaClientApp { + server_addr: String, +} + +impl NavkaClientApp { + pub fn new() -> Result { + let addr = std::env::args() + .nth(1) + .or_else(|| std::env::var("NAVKA_SERVER_ADDR").ok()) + .unwrap_or_else(|| "http://127.0.0.1:50051".to_owned()); + + Ok(Self { server_addr: addr }) + } + + pub async fn run(&mut self) -> Result<()> { + // 1) Connect to navka-server + let mut client = RelayClient::connect(self.server_addr.clone()) + .await + .with_context(|| format!("failed to connect to {}", self.server_addr))?; + + // 2) Create a bidirectional streaming stub + let (tx, rx) = mpsc::channel::(32); + // let outbound = ReceiverStream::new(rx).map(|report| Ok(report) as Result); + // let response = client.stream(Request::new(outbound)).await?; + let outbound = ReceiverStream::new(rx); + let response = client.stream(Request::new(outbound)).await?; + let mut inbound = response.into_inner(); + + // 3) Start reading from all keyboards in a background task + let mut aggregator = KeyboardAggregator::new(tx.clone()); + aggregator.init_devices()?; // discover & grab + let input_task = tokio::spawn(async move { + if let Err(e) = aggregator.run().await { + tracing::error!("KeyboardAggregator failed: {e}"); + } + }); + + // 4) Inbound loop: we do something with reports from the server, e.g. logging: + while let Some(report) = inbound.message().await? { + tracing::info!(?report.data, "echo from server"); + } + + // 5) If inbound stream ends, stop the input task + input_task.abort(); + Ok(()) + } +} diff --git a/client/src/input/keyboard.rs b/client/src/input/keyboard.rs new file mode 100644 index 0000000..40e2a11 --- /dev/null +++ b/client/src/input/keyboard.rs @@ -0,0 +1,160 @@ +use std::collections::HashSet; +use anyhow::{bail, Context, Result}; +use evdev::{Device, InputEvent, KeyCode, EventType}; +use tokio::sync::mpsc::Sender; +use tokio::time::{interval, Duration}; + +use navka_common::navka::HidReport; +use crate::input::keymap::{keycode_to_usage, is_modifier}; + +/// Up to 6 normal keys. Byte[0] = modifiers, Byte[1] = reserved, Byte[2..7] = pressed keys. +/// Magic chord example: LeftCtrl + LeftAlt + LeftShift + Esc +const MAGIC_CHORD: &[KeyCode] = &[ + KeyCode::KEY_LEFTCTRL, + KeyCode::KEY_LEFTSHIFT, + KeyCode::KEY_LEFTALT, + KeyCode::KEY_ESC, +]; + +pub struct KeyboardAggregator { + // MPSC channel to server + tx: Sender, + // The list of evdev devices we are reading + devices: Vec, + // Pressed keys for building HID reports + pressed_keys: HashSet, +} + +impl KeyboardAggregator { + pub fn new(tx: Sender) -> Self { + Self { + tx, + devices: Vec::new(), + pressed_keys: HashSet::new(), + } + } + + /// Discover all /dev/input/event* devices, filter ones with EV_KEY, and grab them. + pub fn init_devices(&mut self) -> Result<()> { + let paths = std::fs::read_dir("/dev/input") + .context("Failed to read /dev/input")?; + + for entry in paths { + let entry = entry?; + let path = entry.path(); + + // skip anything that isn't "event*" + if !path.file_name().unwrap_or_default().to_string_lossy().starts_with("event") { + continue; + } + let mut dev = Device::open(&path).with_context(|| format!("opening {:?}", path))?; + + let maybe_keys = dev.supported_keys(); + if let Some(supported) = maybe_keys { + if supported.iter().next().is_none() { + // no real keys + continue; + } + } else { + // Not a keyboard at all + continue; + } + + // Attempt to grab + dev.grab().with_context(|| format!("grabbing {:?}", path))?; + + tracing::info!("Grabbed keyboard device: {:?}", path); + self.devices.push(dev); + } + if self.devices.is_empty() { + bail!("No keyboard devices found or none grabbed successfully."); + } + Ok(()) + } + + /// Main loop: read events from all devices in a round-robin style + /// building HID reports as needed. If MAGIC_CHORD is detected, exit. + pub async fn run(&mut self) -> Result<()> { + let mut tick = interval(Duration::from_millis(10)); + + loop { + // We'll poll each device in turn + for i in 0..self.devices.len() { + // Non-blocking fetch + let evs = match self.devices[i].fetch_events() { + Ok(iter) => iter.collect::>(), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Vec::new(), + Err(e) => { + tracing::error!("Device read error: {e}"); + Vec::new() + } + }; + for ev in evs { + self.handle_event(ev)?; + } + } + if self.is_magic_chord() { + tracing::warn!("Magic chord pressed; stopping navka-client."); + // ungrab all before exit + for dev in &mut self.devices { + let _ = dev.ungrab(); + } + std::process::exit(0); + } + tick.tick().await; + } + } + + fn handle_event(&mut self, ev: InputEvent) -> Result<()> { + // We only care about KEY events + if ev.event_type() == EventType::KEY { + let code = KeyCode::new(ev.code()); + let value = ev.value(); + match value { + 1 => { self.pressed_keys.insert(code); } + 0 => { self.pressed_keys.remove(&code); } + 2 => { /* repeats, if needed */ } + _ => {} + } + + // Build new HID report + let report_bytes = self.build_hid_report(); + let _ = self.tx.try_send(HidReport { + data: report_bytes.to_vec(), + }); + } + Ok(()) + } + + fn build_hid_report(&self) -> [u8; 8] { + // Byte 0: modifiers (bitmask) + // Byte 1: reserved + // Byte 2..7: up to 6 keys + // We'll do a naive approach: gather up to 6 non-modifier codes. + let mut report = [0u8; 8]; + let mut normal_keys = Vec::new(); + let mut modifier_mask = 0u8; + + for &key in &self.pressed_keys { + if let Some(modbits) = is_modifier(key) { + // e.g. KEY_LEFTSHIFT => 0x02 + modifier_mask |= modbits; + } else if let Some(usage) = keycode_to_usage(key) { + // Normal key + normal_keys.push(usage); + } + } + + report[0] = modifier_mask; + // Byte[1] is reserved 0 + for (i, code) in normal_keys.into_iter().take(6).enumerate() { + report[2 + i] = code; + } + report + } + + fn is_magic_chord(&self) -> bool { + // All the keys in MAGIC_CHORD must be pressed + MAGIC_CHORD.iter().all(|k| self.pressed_keys.contains(k)) + } +} diff --git a/client/src/input/keymap.rs b/client/src/input/keymap.rs new file mode 100644 index 0000000..80f6210 --- /dev/null +++ b/client/src/input/keymap.rs @@ -0,0 +1,99 @@ +// client/src/input/keymap.rs + +use evdev::KeyCode; + +/// Return Some(usage) if we have a known mapping from evdev::KeyCode -> HID usage code +pub fn keycode_to_usage(key: KeyCode) -> Option { + match key { + // Letters + KeyCode::KEY_A => Some(0x04), + KeyCode::KEY_B => Some(0x05), + KeyCode::KEY_C => Some(0x06), + KeyCode::KEY_D => Some(0x07), + KeyCode::KEY_E => Some(0x08), + KeyCode::KEY_F => Some(0x09), + KeyCode::KEY_G => Some(0x0A), + KeyCode::KEY_H => Some(0x0B), + KeyCode::KEY_I => Some(0x0C), + KeyCode::KEY_J => Some(0x0D), + KeyCode::KEY_K => Some(0x0E), + KeyCode::KEY_L => Some(0x0F), + KeyCode::KEY_M => Some(0x10), + KeyCode::KEY_N => Some(0x11), + KeyCode::KEY_O => Some(0x12), + KeyCode::KEY_P => Some(0x13), + KeyCode::KEY_Q => Some(0x14), + KeyCode::KEY_R => Some(0x15), + KeyCode::KEY_S => Some(0x16), + KeyCode::KEY_T => Some(0x17), + KeyCode::KEY_U => Some(0x18), + KeyCode::KEY_V => Some(0x19), + KeyCode::KEY_W => Some(0x1A), + KeyCode::KEY_X => Some(0x1B), + KeyCode::KEY_Y => Some(0x1C), + KeyCode::KEY_Z => Some(0x1D), + + // Number row + KeyCode::KEY_1 => Some(0x1E), + KeyCode::KEY_2 => Some(0x1F), + KeyCode::KEY_3 => Some(0x20), + KeyCode::KEY_4 => Some(0x21), + KeyCode::KEY_5 => Some(0x22), + KeyCode::KEY_6 => Some(0x23), + KeyCode::KEY_7 => Some(0x24), + KeyCode::KEY_8 => Some(0x25), + KeyCode::KEY_9 => Some(0x26), + KeyCode::KEY_0 => Some(0x27), + + // Common punctuation + KeyCode::KEY_ENTER => Some(0x28), + KeyCode::KEY_ESC => Some(0x29), + KeyCode::KEY_BACKSPACE => Some(0x2A), + KeyCode::KEY_TAB => Some(0x2B), + KeyCode::KEY_SPACE => Some(0x2C), + KeyCode::KEY_MINUS => Some(0x2D), + KeyCode::KEY_EQUAL => Some(0x2E), + KeyCode::KEY_LEFTBRACE => Some(0x2F), + KeyCode::KEY_RIGHTBRACE => Some(0x30), + KeyCode::KEY_BACKSLASH => Some(0x31), + KeyCode::KEY_SEMICOLON => Some(0x33), + KeyCode::KEY_APOSTROPHE => Some(0x34), + KeyCode::KEY_GRAVE => Some(0x35), + KeyCode::KEY_COMMA => Some(0x36), + KeyCode::KEY_DOT => Some(0x37), + KeyCode::KEY_SLASH => Some(0x38), + + // Function keys + KeyCode::KEY_CAPSLOCK => Some(0x39), + KeyCode::KEY_F1 => Some(0x3A), + KeyCode::KEY_F2 => Some(0x3B), + KeyCode::KEY_F3 => Some(0x3C), + KeyCode::KEY_F4 => Some(0x3D), + KeyCode::KEY_F5 => Some(0x3E), + KeyCode::KEY_F6 => Some(0x3F), + KeyCode::KEY_F7 => Some(0x40), + KeyCode::KEY_F8 => Some(0x41), + KeyCode::KEY_F9 => Some(0x42), + KeyCode::KEY_F10 => Some(0x43), + KeyCode::KEY_F11 => Some(0x44), + KeyCode::KEY_F12 => Some(0x45), + + // We'll handle modifiers (ctrl, shift, alt, meta) in `is_modifier()` + _ => None, + } +} + +/// If a key is a modifier, return the bit(s) to set in HID byte[0]. +pub fn is_modifier(key: KeyCode) -> Option { + match key { + KeyCode::KEY_LEFTCTRL => Some(0x01), + KeyCode::KEY_LEFTSHIFT => Some(0x02), + KeyCode::KEY_LEFTALT => Some(0x04), + KeyCode::KEY_LEFTMETA => Some(0x08), + KeyCode::KEY_RIGHTCTRL => Some(0x10), + KeyCode::KEY_RIGHTSHIFT => Some(0x20), + KeyCode::KEY_RIGHTALT => Some(0x40), + KeyCode::KEY_RIGHTMETA => Some(0x80), + _ => None, + } +} diff --git a/client/src/input/mod.rs b/client/src/input/mod.rs new file mode 100644 index 0000000..fc0a761 --- /dev/null +++ b/client/src/input/mod.rs @@ -0,0 +1,2 @@ +pub mod keyboard; +pub mod keymap; diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..4402856 --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,6 @@ +#![forbid(unsafe_code)] + +pub mod app; +pub mod input; + +pub use app::NavkaClientApp; diff --git a/client/src/main.rs b/client/src/main.rs index 0545c6d..49ee1c9 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,45 +1,13 @@ -//! navka-client – forward keyboard/mouse HID reports to navka-server +//! main.rs — entry point for navka-client #![forbid(unsafe_code)] use anyhow::Result; -use navka_common::navka::{relay_client::RelayClient, HidReport}; -use tokio::{sync::mpsc, time::sleep}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::{transport::Channel, Request}; +use navka_client::NavkaClientApp; #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt::init(); - // -- server address comes from CLI arg, env or falls back to localhost - let addr = std::env::args() - .nth(1) - .or_else(|| std::env::var("NAVKA_SERVER_ADDR").ok()) - .unwrap_or_else(|| "http://127.0.0.1:50051".to_owned()); - - let channel: Channel = Channel::from_shared(addr)? - .connect() - .await?; - - // mpsc -> ReceiverStream -> bidirectional gRPC - let (tx, rx) = mpsc::channel::(32); - let outbound = ReceiverStream::new(rx); - let mut inbound = RelayClient::new(channel) - .stream(Request::new(outbound)) - .await? - .into_inner(); - - // demo: press & release ‘a’ - tokio::spawn(async move { - let press_a = HidReport { data: vec![0, 0, 0x04, 0, 0, 0, 0, 0] }; - let release = HidReport { data: vec![0; 8] }; - tx.send(press_a).await.ok(); - sleep(std::time::Duration::from_millis(100)).await; - tx.send(release).await.ok(); - }); - - while let Some(report) = inbound.message().await? { - tracing::info!(bytes=?report.data, "echo from server"); - } - Ok(()) + let mut app = NavkaClientApp::new()?; + app.run().await } diff --git a/client/src/tests/integration_test.rs b/client/src/tests/integration_test.rs new file mode 100644 index 0000000..d6a2d3e --- /dev/null +++ b/client/src/tests/integration_test.rs @@ -0,0 +1,13 @@ +#[cfg(test)] +mod tests { + use crate::input::keymap::{keycode_to_usage, is_modifier}; + use evdev::Key; + + #[test] + fn test_keycode_mapping() { + assert_eq!(keycode_to_usage(Key::KEY_A), Some(0x04)); + assert_eq!(keycode_to_usage(Key::KEY_Z), Some(0x1D)); + assert_eq!(keycode_to_usage(Key::KEY_LEFTCTRL), Some(0)); // this is "handled" by is_modifier + assert!(is_modifier(Key::KEY_LEFTCTRL).is_some()); + } +} \ No newline at end of file diff --git a/client/src/tests/mod.rs b/client/src/tests/mod.rs new file mode 100644 index 0000000..6b912ce --- /dev/null +++ b/client/src/tests/mod.rs @@ -0,0 +1 @@ +pub mod integration_test; \ No newline at end of file diff --git a/common/Cargo.toml b/common/Cargo.toml index dcd3758..2423a03 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,16 +1,16 @@ [package] -name = "navka-common" -version = "0.1.0" -edition = "2021" -build = "build.rs" +name = "navka_common" +version = "0.1.0" +edition = "2024" +build = "build.rs" [dependencies] -tonic = "0.11" -prost = "0.12" +tonic = "0.13" +prost = "0.13" [build-dependencies] -tonic-build = "0.11" +tonic-build = "0.13" [lib] -name = "navka_common" -path = "src/lib.rs" +name = "navka_common" +path = "src/lib.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index f6a9646..d0356f6 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "navka-server" -version = "0.1.0" -edition = "2024" +name = "navka_server" +version = "0.1.0" +edition = "2024" [dependencies] tokio = { version = "1.45", features = ["full", "fs"] } tokio-stream = "0.1" -tonic = { version = "0.11", features = ["transport"] } +tonic = { version = "0.13", features = ["transport"] } anyhow = "1.0" -navka-common = { path = "../common" } +navka_common = { path = "../common" } tracing = { version = "0.1", features = ["std"] } tracing-subscriber = "0.3" libc = "0.2"