cat / goq
clone

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

ci/pipeline-ergonomics
goq / crates / sigil-protocol / src / input.rs
input.rs 18.9K
1use serde::{Deserialize, Serialize};2use tokio::io::{AsyncRead, AsyncWrite};34use crate::framing::{read_json, write_json};5use crate::{MAX_INPUT_MESSAGE_LEN, ProtocolError, Result};67const MAX_KEY_IDENTIFIER_UTF8_BYTES: usize = 64;8const MAX_TEXT_LEN: usize = 1_024;910/// Symmetric per-message relative pointer range. Large accumulated movement11/// must be split into bounded messages before it crosses the trust boundary.12pub const RELATIVE_POINTER_DELTA_MIN: i32 = -32_767;13pub const RELATIVE_POINTER_DELTA_MAX: i32 = 32_767;14/// Normalized logical pointer-position range used to periodically resynchronize15/// a relative-only host pointer with the client canvas.16pub const POINTER_POSITION_MIN: i32 = 0;17pub const POINTER_POSITION_MAX: i32 = 32_767;1819/// Symmetric normalized stick range. `i16::MIN` is deliberately excluded so20/// negating an axis never overflows and both directions have equal magnitude.21pub const GAMEPAD_AXIS_MIN: i16 = -32_767;22pub const GAMEPAD_AXIS_MAX: i16 = 32_767;23/// Normalized analog-trigger range, from released through fully pressed.24pub const GAMEPAD_TRIGGER_MAX: u16 = 32_767;2526/// Complete state for the one virtual gamepad. Each message replaces the27/// previous state; no button or axis has an independent queue.28#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]29#[serde(deny_unknown_fields)]30pub struct GamepadState {31 pub a: bool,32 pub b: bool,33 pub x: bool,34 pub y: bool,35 pub left_shoulder: bool,36 pub right_shoulder: bool,37 pub back: bool,38 pub start: bool,39 pub guide: bool,40 pub left_stick: bool,41 pub right_stick: bool,42 pub dpad_up: bool,43 pub dpad_down: bool,44 pub dpad_left: bool,45 pub dpad_right: bool,46 pub left_x: i16,47 pub left_y: i16,48 pub right_x: i16,49 pub right_y: i16,50 pub left_trigger: u16,51 pub right_trigger: u16,52}5354impl GamepadState {55 pub fn validate(&self) -> Result<()> {56 if [self.left_x, self.left_y, self.right_x, self.right_y]57 .into_iter()58 .any(|axis| !(GAMEPAD_AXIS_MIN..=GAMEPAD_AXIS_MAX).contains(&axis))59 {60 return Err(ProtocolError::InvalidMessage {61 message_type: "gamepad state",62 reason: "stick axes must be between -32767 and 32767",63 });64 }65 if self.left_trigger > GAMEPAD_TRIGGER_MAX || self.right_trigger > GAMEPAD_TRIGGER_MAX {66 return Err(ProtocolError::InvalidMessage {67 message_type: "gamepad state",68 reason: "triggers must be between 0 and 32767",69 });70 }71 if (self.dpad_up && self.dpad_down) || (self.dpad_left && self.dpad_right) {72 return Err(ProtocolError::InvalidMessage {73 message_type: "gamepad state",74 reason: "d-pad cannot hold opposing directions",75 });76 }77 Ok(())78 }79}8081/// Input messages retain the inherited compact JSON tags during the v182/// migration. On the v1 ALPN each JSON value is prefixed by a big-endian u3283/// length rather than delimited by a newline.84#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]85#[serde(tag = "t", deny_unknown_fields)]86pub enum InputEvent {87 /// Content-free liveness probe. A host may accept this only when it also88 /// negotiated `InputAck`; it never reaches an operating-system device.89 #[serde(rename = "ip")]90 Probe,91 #[serde(rename = "mm")]92 MouseMove { x: i32, y: i32 },93 #[serde(rename = "mr")]94 MouseMoveRelative { dx: i32, dy: i32 },95 #[serde(rename = "mp")]96 MousePositionSync { x: i32, y: i32 },97 #[serde(rename = "mc")]98 MouseClick { b: u8 },99 #[serde(rename = "md")]100 MouseDown { b: u8 },101 #[serde(rename = "mu")]102 MouseUp { b: u8 },103 #[serde(rename = "ms")]104 MouseScroll { dx: i32, dy: i32 },105 /// Bounded physical key identifier. New clients send the supported106 /// `KeyboardEvent.code` subset; legacy logical key names remain decodable.107 #[serde(rename = "kd")]108 KeyDown { k: String },109 #[serde(rename = "ku")]110 KeyUp { k: String },111 #[serde(rename = "kt")]112 KeyClick { k: String },113 #[serde(rename = "tx")]114 Text { s: String },115 #[serde(rename = "gp")]116 Gamepad { state: GamepadState },117}118119/// Host-observed pointer location in the compositor's native coordinate space.120#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]121#[serde(deny_unknown_fields)]122pub struct PointerPosition {123 pub x: i32,124 pub y: i32,125}126127impl PointerPosition {128 pub fn new(x: i32, y: i32) -> Result<Self> {129 let position = Self { x, y };130 position.validate()?;131 Ok(position)132 }133134 pub fn validate(&self) -> Result<()> {135 if !(POINTER_POSITION_MIN..=POINTER_POSITION_MAX).contains(&self.x)136 || !(POINTER_POSITION_MIN..=POINTER_POSITION_MAX).contains(&self.y)137 {138 return Err(ProtocolError::InvalidMessage {139 message_type: "pointer position",140 reason: "coordinates must be between 0 and 32767",141 });142 }143 Ok(())144 }145}146147/// Bounded host response used by probes to prove that the input stream is148/// serviced independently while media is active. Pointer coordinates are149/// present only when both peers negotiated `PointerPositionFeedback`.150#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]151#[serde(deny_unknown_fields)]152pub struct InputAck {153 pub sequence: u64,154 #[serde(default, skip_serializing_if = "Option::is_none")]155 pub pointer_position: Option<PointerPosition>,156 /// Whether the host compositor is currently presenting its pointer image.157 ///158 /// This is optional for wire compatibility with v1 hosts that encoded159 /// visibility by omitting `pointer_position` while the cursor was hidden.160 #[serde(default, skip_serializing_if = "Option::is_none")]161 pub pointer_visible: Option<bool>,162}163164impl InputAck {165 pub fn validate(&self) -> Result<()> {166 if let Some(position) = self.pointer_position {167 position.validate()?;168 }169 if self.pointer_visible == Some(true) && self.pointer_position.is_none() {170 return Err(ProtocolError::InvalidMessage {171 message_type: "input acknowledgment",172 reason: "a visible pointer requires a pointer position",173 });174 }175 Ok(())176 }177}178179impl InputEvent {180 pub fn validate(&self) -> Result<()> {181 match self {182 Self::MouseMoveRelative { dx, dy }183 if !(RELATIVE_POINTER_DELTA_MIN..=RELATIVE_POINTER_DELTA_MAX).contains(dx)184 || !(RELATIVE_POINTER_DELTA_MIN..=RELATIVE_POINTER_DELTA_MAX).contains(dy) =>185 {186 Err(ProtocolError::InvalidMessage {187 message_type: "input event",188 reason: "relative pointer deltas must be between -32767 and 32767",189 })190 }191 Self::MousePositionSync { x, y }192 if !(POINTER_POSITION_MIN..=POINTER_POSITION_MAX).contains(x)193 || !(POINTER_POSITION_MIN..=POINTER_POSITION_MAX).contains(y) =>194 {195 Err(ProtocolError::InvalidMessage {196 message_type: "input event",197 reason: "pointer synchronization coordinates must be between 0 and 32767",198 })199 }200 Self::MouseClick { b } | Self::MouseDown { b } | Self::MouseUp { b }201 if !(1..=3).contains(b) =>202 {203 Err(ProtocolError::InvalidMessage {204 message_type: "input event",205 reason: "mouse button must be in 1..=3",206 })207 }208 Self::KeyDown { k } | Self::KeyUp { k } | Self::KeyClick { k }209 if k.is_empty() || k.len() > MAX_KEY_IDENTIFIER_UTF8_BYTES =>210 {211 Err(ProtocolError::InvalidMessage {212 message_type: "input event",213 reason: "key must contain 1..=64 UTF-8 bytes",214 })215 }216 Self::Text { s } if s.is_empty() || s.len() > MAX_TEXT_LEN => {217 Err(ProtocolError::InvalidMessage {218 message_type: "input event",219 reason: "text must contain 1..=1024 UTF-8 bytes",220 })221 }222 Self::Gamepad { state } => state.validate(),223 _ => Ok(()),224 }225 }226}227228/// Read one v1 length-prefixed input event. Clean EOF returns `None`.229pub async fn read_input_event<R>(reader: &mut R) -> Result<Option<InputEvent>>230where231 R: AsyncRead + Unpin,232{233 let event: Option<InputEvent> = read_json(reader, MAX_INPUT_MESSAGE_LEN).await?;234 if let Some(event) = &event {235 event.validate()?;236 }237 Ok(event)238}239240/// Validate and write one v1 length-prefixed input event.241pub async fn write_input_event<W>(writer: &mut W, event: &InputEvent) -> Result<()>242where243 W: AsyncWrite + Unpin,244{245 event.validate()?;246 write_json(writer, event, MAX_INPUT_MESSAGE_LEN).await247}248249pub async fn read_input_ack<R>(reader: &mut R) -> Result<Option<InputAck>>250where251 R: AsyncRead + Unpin,252{253 let response: Option<InputAck> = read_json(reader, MAX_INPUT_MESSAGE_LEN).await?;254 if let Some(response) = &response {255 response.validate()?;256 }257 Ok(response)258}259260pub async fn write_input_ack<W>(writer: &mut W, ack: &InputAck) -> Result<()>261where262 W: AsyncWrite + Unpin,263{264 ack.validate()?;265 write_json(writer, ack, MAX_INPUT_MESSAGE_LEN).await266}267268#[cfg(test)]269mod tests {270 use tokio::io::{AsyncWriteExt, duplex};271272 use super::*;273274 #[test]275 fn inherited_json_tags_are_golden() {276 assert_eq!(277 serde_json::to_string(&InputEvent::Probe).unwrap(),278 r#"{"t":"ip"}"#279 );280 assert_eq!(281 serde_json::to_string(&InputEvent::MouseMove { x: 10, y: -20 }).unwrap(),282 r#"{"t":"mm","x":10,"y":-20}"#283 );284 assert_eq!(285 serde_json::to_string(&InputEvent::MouseMoveRelative { dx: 10, dy: -20 }).unwrap(),286 r#"{"t":"mr","dx":10,"dy":-20}"#287 );288 assert_eq!(289 serde_json::to_string(&InputEvent::MousePositionSync { x: 123, y: 456 }).unwrap(),290 r#"{"t":"mp","x":123,"y":456}"#291 );292 assert_eq!(293 serde_json::to_string(&InputEvent::KeyDown { k: "Enter".into() }).unwrap(),294 r#"{"t":"kd","k":"Enter"}"#295 );296 assert_eq!(297 serde_json::to_string(&InputEvent::Text { s: "hi".into() }).unwrap(),298 r#"{"t":"tx","s":"hi"}"#299 );300 let gamepad = InputEvent::Gamepad {301 state: GamepadState {302 a: true,303 dpad_left: true,304 left_x: GAMEPAD_AXIS_MIN,305 right_trigger: GAMEPAD_TRIGGER_MAX,306 ..GamepadState::default()307 },308 };309 assert_eq!(310 serde_json::to_string(&gamepad).unwrap(),311 r#"{"t":"gp","state":{"a":true,"b":false,"x":false,"y":false,"left_shoulder":false,"right_shoulder":false,"back":false,"start":false,"guide":false,"left_stick":false,"right_stick":false,"dpad_up":false,"dpad_down":false,"dpad_left":true,"dpad_right":false,"left_x":-32767,"left_y":0,"right_x":0,"right_y":0,"left_trigger":0,"right_trigger":32767}}"#312 );313 }314315 #[tokio::test]316 async fn input_event_round_trips_and_eof_is_clean() {317 let event = InputEvent::MouseScroll { dx: -1, dy: 2 };318 let (mut sender, mut receiver) = duplex(256);319 write_input_event(&mut sender, &event).await.unwrap();320 sender.shutdown().await.unwrap();321322 assert_eq!(read_input_event(&mut receiver).await.unwrap(), Some(event));323 assert_eq!(read_input_event(&mut receiver).await.unwrap(), None);324 }325326 #[tokio::test]327 async fn physical_keyboard_down_up_and_click_tokens_round_trip() {328 for event in [329 InputEvent::KeyDown { k: "F1".into() },330 InputEvent::KeyUp {331 k: "PrintScreen".into(),332 },333 InputEvent::KeyClick { k: "KeyQ".into() },334 ] {335 let (mut sender, mut receiver) = duplex(256);336 write_input_event(&mut sender, &event).await.unwrap();337 sender.shutdown().await.unwrap();338 assert_eq!(read_input_event(&mut receiver).await.unwrap(), Some(event));339 }340 }341342 #[tokio::test]343 async fn full_gamepad_snapshot_round_trips() {344 let event = InputEvent::Gamepad {345 state: GamepadState {346 a: true,347 right_shoulder: true,348 dpad_up: true,349 dpad_right: true,350 left_x: -12_345,351 left_y: 23_456,352 right_x: GAMEPAD_AXIS_MAX,353 right_y: GAMEPAD_AXIS_MIN,354 left_trigger: 1,355 right_trigger: GAMEPAD_TRIGGER_MAX,356 ..GamepadState::default()357 },358 };359 let (mut sender, mut receiver) = duplex(1024);360 write_input_event(&mut sender, &event).await.unwrap();361 sender.shutdown().await.unwrap();362 assert_eq!(read_input_event(&mut receiver).await.unwrap(), Some(event));363 }364365 #[tokio::test]366 async fn input_ack_round_trips_and_eof_is_clean() {367 let ack = InputAck {368 sequence: 42,369 pointer_position: Some(PointerPosition::new(1_280, 800).unwrap()),370 pointer_visible: Some(false),371 };372 let (mut sender, mut receiver) = duplex(256);373 write_input_ack(&mut sender, &ack).await.unwrap();374 sender.shutdown().await.unwrap();375376 assert_eq!(read_input_ack(&mut receiver).await.unwrap(), Some(ack));377 assert_eq!(read_input_ack(&mut receiver).await.unwrap(), None);378 }379380 #[test]381 fn new_reader_accepts_old_ack_and_feedback_is_omitted_when_absent() {382 let old = r#"{"sequence":42}"#;383 let ack: InputAck = serde_json::from_str(old).unwrap();384 assert_eq!(ack.pointer_position, None);385 assert_eq!(ack.pointer_visible, None);386 assert_eq!(serde_json::to_string(&ack).unwrap(), old);387 assert!(388 serde_json::from_str::<InputAck>(r#"{"sequence":42,"pointer_visible":true}"#)389 .unwrap()390 .validate()391 .is_err()392 );393 assert!(PointerPosition::new(-1, 0).is_err());394 assert!(PointerPosition::new(0, POINTER_POSITION_MAX + 1).is_err());395 }396397 #[tokio::test]398 async fn peer_controlled_input_length_is_bounded_before_allocation() {399 let (mut sender, mut receiver) = duplex(16);400 sender401 .write_all(&((MAX_INPUT_MESSAGE_LEN as u32) + 1).to_be_bytes())402 .await403 .unwrap();404405 assert!(matches!(406 read_input_event(&mut receiver).await,407 Err(ProtocolError::InvalidMessageLength { .. })408 ));409 }410411 #[tokio::test]412 async fn peer_controlled_relative_deltas_are_bounded_after_decode() {413 let payload = br#"{"t":"mr","dx":32768,"dy":0}"#;414 let (mut sender, mut receiver) = duplex(256);415 sender416 .write_all(&(payload.len() as u32).to_be_bytes())417 .await418 .unwrap();419 sender.write_all(payload).await.unwrap();420 sender.shutdown().await.unwrap();421422 assert!(matches!(423 read_input_event(&mut receiver).await,424 Err(ProtocolError::InvalidMessage { .. })425 ));426 }427428 #[tokio::test]429 async fn peer_controlled_pointer_sync_coordinates_are_bounded_after_decode() {430 let payload = br#"{"t":"mp","x":32768,"y":0}"#;431 let (mut sender, mut receiver) = duplex(256);432 sender433 .write_all(&(payload.len() as u32).to_be_bytes())434 .await435 .unwrap();436 sender.write_all(payload).await.unwrap();437 sender.shutdown().await.unwrap();438439 assert!(matches!(440 read_input_event(&mut receiver).await,441 Err(ProtocolError::InvalidMessage { .. })442 ));443 }444445 #[test]446 fn rejects_unknown_fields_and_invalid_values() {447 assert!(serde_json::from_str::<InputEvent>(r#"{"t":"mm","x":1,"y":2,"z":3}"#).is_err());448 assert!(449 InputEvent::MouseMoveRelative {450 dx: RELATIVE_POINTER_DELTA_MAX + 1,451 dy: 0,452 }453 .validate()454 .is_err()455 );456 assert!(457 InputEvent::MouseMoveRelative {458 dx: 0,459 dy: RELATIVE_POINTER_DELTA_MIN - 1,460 }461 .validate()462 .is_err()463 );464 assert!(465 InputEvent::MouseMoveRelative {466 dx: RELATIVE_POINTER_DELTA_MIN,467 dy: RELATIVE_POINTER_DELTA_MAX,468 }469 .validate()470 .is_ok()471 );472 assert!(473 InputEvent::MousePositionSync {474 x: POINTER_POSITION_MIN,475 y: POINTER_POSITION_MAX,476 }477 .validate()478 .is_ok()479 );480 assert!(481 InputEvent::MousePositionSync {482 x: POINTER_POSITION_MIN - 1,483 y: 0,484 }485 .validate()486 .is_err()487 );488 assert!(489 InputEvent::MousePositionSync {490 x: 0,491 y: POINTER_POSITION_MAX + 1,492 }493 .validate()494 .is_err()495 );496 assert!(InputEvent::MouseDown { b: 9 }.validate().is_err());497 assert!(InputEvent::KeyUp { k: String::new() }.validate().is_err());498 // This exercises the UTF-8 byte bound only; protocol validation does499 // not imply that arbitrary Unicode is an operational keyboard input.500 assert!(501 InputEvent::KeyDown {502 k: "é".repeat(MAX_KEY_IDENTIFIER_UTF8_BYTES / 2)503 }504 .validate()505 .is_ok()506 );507 assert!(508 InputEvent::KeyUp {509 k: "é".repeat((MAX_KEY_IDENTIFIER_UTF8_BYTES / 2) + 1)510 }511 .validate()512 .is_err()513 );514 assert!(515 InputEvent::Text {516 s: "x".repeat(MAX_TEXT_LEN + 1)517 }518 .validate()519 .is_err()520 );521522 let mut state = GamepadState {523 left_x: i16::MIN,524 ..GamepadState::default()525 };526 assert!(state.validate().is_err());527 state.left_x = 0;528 state.left_trigger = GAMEPAD_TRIGGER_MAX + 1;529 assert!(state.validate().is_err());530 state.left_trigger = 0;531 state.dpad_up = true;532 state.dpad_down = true;533 assert!(state.validate().is_err());534535 assert!(536 serde_json::from_str::<InputEvent>(537 r#"{"t":"gp","state":{"a":false,"b":false,"x":false,"y":false,"left_shoulder":false,"right_shoulder":false,"back":false,"start":false,"guide":false,"left_stick":false,"right_stick":false,"dpad_up":false,"dpad_down":false,"dpad_left":false,"dpad_right":false,"left_x":0,"left_y":0,"right_x":0,"right_y":0,"left_trigger":0,"right_trigger":0,"extra":true}}"#538 )539 .is_err()540 );541 }542}