cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/architecture-review-findings
goq / crates / sigil-host / src / input / mod.rs
mod.rs 11.9K
1use std::fmt;2#[cfg(target_os = "linux")]3use std::sync::Arc;4#[cfg(any(target_os = "linux", test))]5use std::sync::Mutex;67#[cfg(target_os = "linux")]8use anyhow::Context;9use anyhow::{Result, bail};10use sigil_protocol::{Capability, InputEvent};1112use crate::config::{HostConfig, InputMode};1314mod acl;15mod keymap;16#[cfg(target_os = "linux")]17mod linux;1819const ACK_ONLY_CAPABILITIES: &[Capability] = &[Capability::InputAck];20const UINPUT_CAPABILITIES: &[Capability] = &[21 Capability::RelativePointer,22 Capability::Keyboard,23 Capability::Gamepad,24 Capability::InputAck,25];2627#[cfg(any(target_os = "linux", test))]28// This Linux input identity tuple is an external ABI consumed by Gamescope,29// udev/libinput rules, and hardware evidence. Preserve it exactly; see30// docs/compatibility-identifiers.md.31const UINPUT_BUS_TYPE: u16 = 0x06; // Linux BUS_VIRTUAL32#[cfg(any(target_os = "linux", test))]33const UINPUT_VENDOR_ID: u16 = 0x5347;34#[cfg(any(target_os = "linux", test))]35const UINPUT_DEVICE_VERSION: u16 = 1;36#[cfg(any(target_os = "linux", test))]37const POINTER_PRODUCT_ID: u16 = 1;38#[cfg(any(target_os = "linux", test))]39const GAMEPAD_PRODUCT_ID: u16 = 2;40#[cfg(any(target_os = "linux", test))]41const KEYBOARD_PRODUCT_ID: u16 = 3;42#[cfg(any(target_os = "linux", test))]43const POINTER_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Pointer";44#[cfg(any(target_os = "linux", test))]45const KEYBOARD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Keyboard";46#[cfg(any(target_os = "linux", test))]47const GAMEPAD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Gamepad";4849#[derive(Clone, Copy, Debug, PartialEq, Eq)]50pub enum InputDisposition {51 Probed,52 Disabled,53 Observed,54 #[cfg(target_os = "linux")]55 Injected,56 TextIgnored,57}5859#[derive(Clone)]60pub struct InputBackend {61 mode: InputMode,62 #[cfg(target_os = "linux")]63 device: Option<Arc<std::sync::Mutex<linux::UinputDevice>>>,64}6566impl fmt::Debug for InputBackend {67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {68 formatter69 .debug_struct("InputBackend")70 .field("mode", &self.mode)71 .finish_non_exhaustive()72 }73}7475impl InputBackend {76 /// Construct the backend before the network endpoint is exposed. For77 /// uinput this opens, validates, and registers the virtual device.78 pub fn initialize(config: &HostConfig) -> Result<Self> {79 match config.input_mode {80 InputMode::Disabled | InputMode::Log => Ok(Self {81 mode: config.input_mode.clone(),82 #[cfg(target_os = "linux")]83 device: None,84 }),85 InputMode::Uinput => {86 #[cfg(target_os = "linux")]87 {88 let settings = config89 .uinput90 .as_ref()91 .context("missing validated uinput configuration")?;92 let device = linux::UinputDevice::open(settings)?;93 Ok(Self {94 mode: InputMode::Uinput,95 device: Some(Arc::new(std::sync::Mutex::new(device))),96 })97 }98 #[cfg(not(target_os = "linux"))]99 {100 let _ = config;101 bail!("input_mode uinput is supported only on Linux")102 }103 }104 }105 }106107 pub fn capabilities(&self) -> &'static [Capability] {108 capabilities_for_mode(&self.mode)109 }110111 pub fn apply(&self, event: &InputEvent, negotiated: &[Capability]) -> Result<InputDisposition> {112 ensure_event_was_negotiated(&self.mode, event, negotiated)?;113 if matches!(event, InputEvent::Probe) {114 return Ok(InputDisposition::Probed);115 }116 match self.mode {117 InputMode::Disabled => Ok(InputDisposition::Disabled),118 InputMode::Log => Ok(InputDisposition::Observed),119 InputMode::Uinput => {120 if matches!(event, InputEvent::Text { .. }) {121 return Ok(InputDisposition::TextIgnored);122 }123 #[cfg(target_os = "linux")]124 {125 let device = self126 .device127 .as_ref()128 .context("uinput backend was not initialized")?;129 device130 .lock()131 .map_err(|_| anyhow::anyhow!("uinput backend lock poisoned"))?132 .apply(event)?;133 Ok(InputDisposition::Injected)134 }135 #[cfg(not(target_os = "linux"))]136 {137 let _ = event;138 bail!("uinput backend is unavailable on this platform")139 }140 }141 }142 }143144 /// Release any transitions held by the ending input session so a dropped145 /// connection cannot leave a key or mouse button pressed.146 pub fn reset_session(&self) -> Result<()> {147 #[cfg(target_os = "linux")]148 if let Some(device) = &self.device {149 reset_through_poisoned_lock(device, linux::UinputDevice::release_all)?;150 }151 Ok(())152 }153}154155#[cfg(any(target_os = "linux", test))]156fn reset_through_poisoned_lock<T>(157 device: &Mutex<T>,158 reset: impl FnOnce(&mut T) -> Result<()>,159) -> Result<()> {160 let mut device = match device.lock() {161 Ok(device) => device,162 Err(poisoned) => {163 // A panic while applying input poisons this mutex. Teardown must164 // still recover the device solely to release held transitions;165 // ordinary input continues to reject the poisoned lock.166 tracing::warn!("recovering poisoned uinput lock for session reset");167 poisoned.into_inner()168 }169 };170 reset(&mut device)171}172173fn ensure_event_was_negotiated(174 mode: &InputMode,175 event: &InputEvent,176 negotiated: &[Capability],177) -> Result<()> {178 if !matches!(mode, InputMode::Uinput) {179 return Ok(());180 }181 let required = match event {182 InputEvent::Probe => Some(Capability::InputAck),183 InputEvent::MouseMove { .. } => Some(Capability::AbsolutePointer),184 InputEvent::MouseMoveRelative { .. }185 | InputEvent::MousePositionSync { .. }186 | InputEvent::MouseClick { .. }187 | InputEvent::MouseDown { .. }188 | InputEvent::MouseUp { .. }189 | InputEvent::MouseScroll { .. } => Some(Capability::RelativePointer),190 InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } | InputEvent::KeyClick { .. } => {191 Some(Capability::Keyboard)192 }193 InputEvent::Gamepad { .. } => Some(Capability::Gamepad),194 // Text remains an explicit content-free no-op for compatibility with195 // clients that emit it alongside negotiated physical key transitions.196 InputEvent::Text { .. } => None,197 };198 if required.is_some_and(|capability| !negotiated.contains(&capability)) {199 bail!("input event class was not negotiated for this session");200 }201 Ok(())202}203204fn capabilities_for_mode(mode: &InputMode) -> &'static [Capability] {205 match mode {206 InputMode::Disabled | InputMode::Log => ACK_ONLY_CAPABILITIES,207 InputMode::Uinput => UINPUT_CAPABILITIES,208 }209}210211#[cfg(test)]212mod tests {213 use std::panic::{AssertUnwindSafe, catch_unwind};214 use std::sync::Arc;215216 use sigil_protocol::GamepadState;217218 use super::*;219220 #[test]221 fn virtual_input_topology_has_stable_distinct_identities() {222 assert_eq!(UINPUT_BUS_TYPE, 0x06);223 #[cfg(target_os = "linux")]224 assert_eq!(UINPUT_BUS_TYPE, input_linux::sys::BUS_VIRTUAL);225 assert_eq!(UINPUT_VENDOR_ID, 0x5347);226 assert_eq!(UINPUT_DEVICE_VERSION, 1);227 assert_eq!(POINTER_DEVICE_NAME, b"Sigil Spark Virtual Pointer");228 assert_eq!(KEYBOARD_DEVICE_NAME, b"Sigil Spark Virtual Keyboard");229 assert_eq!(GAMEPAD_DEVICE_NAME, b"Sigil Spark Virtual Gamepad");230 assert_eq!(POINTER_PRODUCT_ID, 1);231 assert_eq!(GAMEPAD_PRODUCT_ID, 2);232 assert_eq!(KEYBOARD_PRODUCT_ID, 3);233 assert_ne!(POINTER_PRODUCT_ID, KEYBOARD_PRODUCT_ID);234 assert_ne!(POINTER_PRODUCT_ID, GAMEPAD_PRODUCT_ID);235 assert_ne!(KEYBOARD_PRODUCT_ID, GAMEPAD_PRODUCT_ID);236 }237238 #[test]239 fn session_reset_recovers_a_lock_poisoned_while_applying_input() {240 #[derive(Debug, Default)]241 struct DeviceState {242 released: bool,243 }244245 let device = Arc::new(Mutex::new(DeviceState::default()));246 let panic_device = Arc::clone(&device);247 let panic_result = catch_unwind(AssertUnwindSafe(move || {248 let _device = panic_device.lock().unwrap();249 panic!("simulate panic while applying uinput");250 }));251 assert!(panic_result.is_err());252 assert!(device.lock().is_err());253254 reset_through_poisoned_lock(device.as_ref(), |device| {255 device.released = true;256 Ok(())257 })258 .unwrap();259260 let device = device.lock().unwrap_err().into_inner();261 assert!(device.released);262 }263264 #[test]265 fn content_free_probe_requires_ack_and_never_reaches_uinput() {266 assert!(ensure_event_was_negotiated(&InputMode::Uinput, &InputEvent::Probe, &[]).is_err());267 assert!(268 ensure_event_was_negotiated(269 &InputMode::Uinput,270 &InputEvent::Probe,271 &[Capability::InputAck]272 )273 .is_ok()274 );275 }276277 #[test]278 fn relative_pointer_events_require_relative_pointer_negotiation() {279 let movement = InputEvent::MouseMoveRelative { dx: 4, dy: -3 };280 let synchronization = InputEvent::MousePositionSync { x: 123, y: 456 };281 let click = InputEvent::MouseClick { b: 1 };282 let legacy = InputEvent::MouseMove { x: 10, y: 20 };283 for event in [&movement, &synchronization, &click] {284 assert!(ensure_event_was_negotiated(&InputMode::Uinput, event, &[]).is_err());285 assert!(286 ensure_event_was_negotiated(287 &InputMode::Uinput,288 event,289 &[Capability::AbsolutePointer]290 )291 .is_err()292 );293 assert!(294 ensure_event_was_negotiated(295 &InputMode::Uinput,296 event,297 &[Capability::RelativePointer]298 )299 .is_ok()300 );301 }302 assert!(303 ensure_event_was_negotiated(304 &InputMode::Uinput,305 &legacy,306 &[Capability::RelativePointer]307 )308 .is_err()309 );310 }311312 #[test]313 fn uinput_rejects_unnegotiated_gamepad_snapshots() {314 let event = InputEvent::Gamepad {315 state: GamepadState::default(),316 };317 assert!(ensure_event_was_negotiated(&InputMode::Uinput, &event, &[]).is_err());318 assert!(319 ensure_event_was_negotiated(&InputMode::Uinput, &event, &[Capability::Gamepad]).is_ok()320 );321 assert!(ensure_event_was_negotiated(&InputMode::Log, &event, &[]).is_ok());322 }323324 #[test]325 fn advertised_capabilities_follow_operational_mode() {326 let disabled = InputBackend {327 mode: InputMode::Disabled,328 #[cfg(target_os = "linux")]329 device: None,330 };331 assert_eq!(disabled.capabilities(), &[Capability::InputAck]);332333 let log = InputBackend {334 mode: InputMode::Log,335 #[cfg(target_os = "linux")]336 device: None,337 };338 assert_eq!(log.capabilities(), &[Capability::InputAck]);339 assert!(!log.capabilities().contains(&Capability::Keyboard));340 assert_eq!(341 capabilities_for_mode(&InputMode::Uinput),342 &[343 Capability::RelativePointer,344 Capability::Keyboard,345 Capability::Gamepad,346 Capability::InputAck,347 ]348 );349 assert!(!capabilities_for_mode(&InputMode::Uinput).contains(&Capability::AbsolutePointer));350 }351}