mirror of
https://github.com/CloudNebulaProject/wayray.git
synced 2026-04-10 13:10:41 +00:00
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.
This commit is contained in:
parent
8d8af48676
commit
0762cb1fa3
6 changed files with 409 additions and 0 deletions
114
Cargo.lock
generated
114
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,3 +5,6 @@ version.workspace = true
|
|||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
|
|||
156
crates/wayray-protocol/src/codec.rs
Normal file
156
crates/wayray-protocol/src/codec.rs
Normal file
|
|
@ -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<T: Serialize>(msg: &T) -> Result<Vec<u8>, 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<T: DeserializeOwned>(data: &[u8]) -> Result<T, CodecError> {
|
||||
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<T: DeserializeOwned>(
|
||||
buf: &[u8],
|
||||
) -> Result<Option<(T, usize)>, 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
125
crates/wayray-protocol/src/messages.rs
Normal file
125
crates/wayray-protocol/src/messages.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FrameUpdate {
|
||||
pub sequence: u64,
|
||||
pub regions: Vec<DamageRegion>,
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue