From 0762cb1fa39684cf4f702b1365499ff4c4199abc Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Tue, 7 Apr 2026 15:35:48 +0200 Subject: [PATCH] Implement wire protocol messages and codec in wayray-protocol Message types for control, display, and input channels with serde derives. Length-prefixed postcard codec with encode/decode/framing. Seven round-trip tests covering all message types and edge cases. --- Cargo.lock | 114 ++++++++++++++++++ Cargo.toml | 3 + crates/wayray-protocol/Cargo.toml | 3 + crates/wayray-protocol/src/codec.rs | 156 +++++++++++++++++++++++++ crates/wayray-protocol/src/lib.rs | 8 ++ crates/wayray-protocol/src/messages.rs | 125 ++++++++++++++++++++ 6 files changed, 409 insertions(+) create mode 100644 crates/wayray-protocol/src/codec.rs create mode 100644 crates/wayray-protocol/src/messages.rs diff --git a/Cargo.lock b/Cargo.lock index a2327a9..7359475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -175,6 +184,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -254,6 +269,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "combine" version = "4.6.7" @@ -322,6 +346,12 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -387,6 +417,18 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "equivalent" version = "1.0.2" @@ -534,6 +576,15 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -549,6 +600,20 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -729,6 +794,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1160,6 +1234,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1378,6 +1465,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1391,6 +1484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1552,6 +1646,21 @@ dependencies = [ "serde", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "supports-color" version = "3.0.2" @@ -2079,6 +2188,11 @@ dependencies = [ [[package]] name = "wayray-protocol" version = "0.1.0" +dependencies = [ + "postcard", + "serde", + "thiserror 2.0.18", +] [[package]] name = "web-sys" diff --git a/Cargo.toml b/Cargo.toml index 572a15a..f9c2264 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } miette = { version = "7", features = ["fancy"] } thiserror = "2" +serde = { version = "1", features = ["derive"] } +postcard = { version = "1", features = ["alloc"] } +zstd = "0.13" diff --git a/crates/wayray-protocol/Cargo.toml b/crates/wayray-protocol/Cargo.toml index b52baf4..78bf046 100644 --- a/crates/wayray-protocol/Cargo.toml +++ b/crates/wayray-protocol/Cargo.toml @@ -5,3 +5,6 @@ version.workspace = true license.workspace = true [dependencies] +serde.workspace = true +postcard.workspace = true +thiserror.workspace = true diff --git a/crates/wayray-protocol/src/codec.rs b/crates/wayray-protocol/src/codec.rs new file mode 100644 index 0000000..16bd6b2 --- /dev/null +++ b/crates/wayray-protocol/src/codec.rs @@ -0,0 +1,156 @@ +//! Length-prefixed message framing for QUIC streams. +//! +//! Each message is encoded as: 4-byte LE length prefix + postcard payload. + +use serde::{Serialize, de::DeserializeOwned}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CodecError { + #[error("serialization failed: {0}")] + Serialize(#[from] postcard::Error), + + #[error("incomplete message: need {needed} bytes, have {available}")] + Incomplete { needed: usize, available: usize }, +} + +/// Encode a message to a length-prefixed byte vector. +/// +/// Format: [4-byte LE length][postcard payload] +pub fn encode(msg: &T) -> Result, CodecError> { + let payload = postcard::to_allocvec(msg)?; + let len = (payload.len() as u32).to_le_bytes(); + let mut buf = Vec::with_capacity(4 + payload.len()); + buf.extend_from_slice(&len); + buf.extend_from_slice(&payload); + Ok(buf) +} + +/// Decode a message from a payload byte slice (after length prefix is removed). +pub fn decode(data: &[u8]) -> Result { + Ok(postcard::from_bytes(data)?) +} + +/// Read a length prefix from a byte slice. +/// +/// Returns `Some((length, remaining_slice))` if at least 4 bytes are available, +/// `None` otherwise. +pub fn read_length_prefix(data: &[u8]) -> Option<(u32, &[u8])> { + if data.len() < 4 { + return None; + } + let len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + Some((len, &data[4..])) +} + +/// Try to extract a complete framed message from a buffer. +/// +/// Returns `Some((message, bytes_consumed))` if a complete message is available, +/// `None` if more data is needed. +pub fn try_decode_framed( + buf: &[u8], +) -> Result, CodecError> { + let Some((len, rest)) = read_length_prefix(buf) else { + return Ok(None); + }; + let len = len as usize; + if rest.len() < len { + return Ok(None); + } + let msg = decode(&rest[..len])?; + Ok(Some((msg, 4 + len))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::messages::*; + + #[test] + fn control_message_roundtrip() { + let msg = ControlMessage::ClientHello(ClientHello { + version: 1, + capabilities: vec!["display".to_string()], + }); + let encoded = encode(&msg).unwrap(); + let (len, payload) = read_length_prefix(&encoded).unwrap(); + let decoded: ControlMessage = decode(&payload[..len as usize]).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn display_message_roundtrip() { + let msg = DisplayMessage::FrameUpdate(FrameUpdate { + sequence: 42, + regions: vec![DamageRegion { + x: 10, + y: 20, + width: 100, + height: 50, + data: vec![0, 1, 2, 3], + }], + }); + let encoded = encode(&msg).unwrap(); + let (len, payload) = read_length_prefix(&encoded).unwrap(); + let decoded: DisplayMessage = decode(&payload[..len as usize]).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn input_message_roundtrip() { + let msg = InputMessage::Keyboard(KeyboardEvent { + keycode: 38, + state: KeyState::Pressed, + time: 12345, + }); + let encoded = encode(&msg).unwrap(); + let (len, payload) = read_length_prefix(&encoded).unwrap(); + let decoded: InputMessage = decode(&payload[..len as usize]).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn try_decode_framed_complete() { + let msg = ControlMessage::Ping(Ping { timestamp: 999 }); + let encoded = encode(&msg).unwrap(); + let result: Option<(ControlMessage, usize)> = try_decode_framed(&encoded).unwrap(); + let (decoded, consumed) = result.unwrap(); + assert_eq!(decoded, msg); + assert_eq!(consumed, encoded.len()); + } + + #[test] + fn try_decode_framed_incomplete() { + let msg = ControlMessage::Ping(Ping { timestamp: 999 }); + let encoded = encode(&msg).unwrap(); + // Only provide half the data + let partial = &encoded[..encoded.len() / 2]; + let result: Option<(ControlMessage, usize)> = try_decode_framed(partial).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn empty_frame_update() { + let msg = DisplayMessage::FrameUpdate(FrameUpdate { + sequence: 0, + regions: vec![], + }); + let encoded = encode(&msg).unwrap(); + let (len, payload) = read_length_prefix(&encoded).unwrap(); + let decoded: DisplayMessage = decode(&payload[..len as usize]).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn pointer_motion_roundtrip() { + let msg = InputMessage::PointerMotion(PointerMotion { + x: 100.5, + y: 200.75, + time: 54321, + }); + let encoded = encode(&msg).unwrap(); + let (len, payload) = read_length_prefix(&encoded).unwrap(); + let decoded: InputMessage = decode(&payload[..len as usize]).unwrap(); + assert_eq!(decoded, msg); + } +} diff --git a/crates/wayray-protocol/src/lib.rs b/crates/wayray-protocol/src/lib.rs index bc233a1..9f77b66 100644 --- a/crates/wayray-protocol/src/lib.rs +++ b/crates/wayray-protocol/src/lib.rs @@ -1,3 +1,11 @@ //! WayRay wire protocol definitions. //! //! Shared between wrsrvd (server) and wrclient (client). +//! Messages are serialized with postcard and framed with a 4-byte +//! length prefix for transmission over QUIC streams. + +pub mod codec; +pub mod messages; + +/// Current protocol version. Incremented on breaking changes. +pub const PROTOCOL_VERSION: u32 = 1; diff --git a/crates/wayray-protocol/src/messages.rs b/crates/wayray-protocol/src/messages.rs new file mode 100644 index 0000000..b5b5bd3 --- /dev/null +++ b/crates/wayray-protocol/src/messages.rs @@ -0,0 +1,125 @@ +//! WayRay wire protocol message types. +//! +//! Organized by channel: control (bidirectional), display (server→client), +//! and input (client→server). Serialized with postcard over QUIC streams. + +use serde::{Deserialize, Serialize}; + +// ── Control channel (bidirectional) ───────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ClientHello { + pub version: u32, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ServerHello { + pub version: u32, + pub session_id: u64, + pub output_width: u32, + pub output_height: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Ping { + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Pong { + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FrameAck { + pub sequence: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ControlMessage { + ClientHello(ClientHello), + ServerHello(ServerHello), + Ping(Ping), + Pong(Pong), + FrameAck(FrameAck), +} + +// ── Display channel (server → client, unidirectional) ─────────────── + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DamageRegion { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + /// zstd-compressed XOR diff pixel data for this region. + pub data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FrameUpdate { + pub sequence: u64, + pub regions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DisplayMessage { + FrameUpdate(FrameUpdate), +} + +// ── Input channel (client → server, unidirectional) ───────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum KeyState { + Pressed, + Released, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ButtonState { + Pressed, + Released, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Axis { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct KeyboardEvent { + pub keycode: u32, + pub state: KeyState, + pub time: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PointerMotion { + pub x: f64, + pub y: f64, + pub time: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PointerButton { + pub button: u32, + pub state: ButtonState, + pub time: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PointerAxis { + pub axis: Axis, + pub value: f64, + pub time: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum InputMessage { + Keyboard(KeyboardEvent), + PointerMotion(PointerMotion), + PointerButton(PointerButton), + PointerAxis(PointerAxis), +}