use serde::{Deserialize, Serialize};
pub const MAX_TILE_BYTES: usize = crate::kv::NATS_PAYLOAD_BUDGET;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum TileEncoding {
#[default]
Jpeg,
Webp,
}
impl TileEncoding {
pub fn as_str(self) -> &'static str {
match self {
TileEncoding::Jpeg => "jpeg",
TileEncoding::Webp => "webp",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"jpeg" => Some(TileEncoding::Jpeg),
"webp" => Some(TileEncoding::Webp),
_ => None,
}
}
pub fn mime(self) -> &'static str {
match self {
TileEncoding::Jpeg => "image/jpeg",
TileEncoding::Webp => "image/webp",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameMeta {
pub frame_seq: u64,
pub tile_index: u16,
pub tile_count: u16,
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
pub screen_w: u32,
pub screen_h: u32,
pub captured_at_ms: u64,
}
pub mod header {
pub const KIND: &str = "Kanade-Kind";
pub const FRAME_SEQ: &str = "Kanade-Frame-Seq";
pub const TILE_INDEX: &str = "Kanade-Tile-Index";
pub const TILE_COUNT: &str = "Kanade-Tile-Count";
pub const TILE_X: &str = "Kanade-Tile-X";
pub const TILE_Y: &str = "Kanade-Tile-Y";
pub const TILE_W: &str = "Kanade-Tile-W";
pub const TILE_H: &str = "Kanade-Tile-H";
pub const SCREEN_W: &str = "Kanade-Screen-W";
pub const SCREEN_H: &str = "Kanade-Screen-H";
pub const ENCODING: &str = "Kanade-Encoding";
pub const CAPTURED_AT: &str = "Kanade-Captured-At";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameKind {
Tile,
Gap,
Resumed,
}
impl FrameKind {
pub fn as_str(self) -> &'static str {
match self {
FrameKind::Tile => "tile",
FrameKind::Gap => "gap",
FrameKind::Resumed => "resumed",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"tile" => Some(FrameKind::Tile),
"gap" => Some(FrameKind::Gap),
"resumed" => Some(FrameKind::Resumed),
_ => None,
}
}
}
pub fn gap_headers() -> async_nats::HeaderMap {
let mut h = async_nats::HeaderMap::new();
h.insert(header::KIND, FrameKind::Gap.as_str());
h
}
pub fn resumed_headers() -> async_nats::HeaderMap {
let mut h = async_nats::HeaderMap::new();
h.insert(header::KIND, FrameKind::Resumed.as_str());
h
}
pub fn frame_kind(h: &async_nats::HeaderMap) -> Result<FrameKind, FrameMetaError> {
let v = h
.get(header::KIND)
.ok_or(FrameMetaError::Missing(header::KIND))?;
FrameKind::parse(v.as_str()).ok_or_else(|| FrameMetaError::UnknownKind(v.as_str().to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FrameMetaError {
#[error("missing frame header `{0}`")]
Missing(&'static str),
#[error("frame header `{name}` is not a valid number: {value:?}")]
NotANumber { name: &'static str, value: String },
#[error("unknown tile encoding {0:?}")]
UnknownEncoding(String),
#[error("unknown frame kind {0:?}")]
UnknownKind(String),
#[error("tile {w}x{h} at ({x},{y}) does not fit a {screen_w}x{screen_h} screen")]
OutOfBounds {
x: u32,
y: u32,
w: u32,
h: u32,
screen_w: u32,
screen_h: u32,
},
#[error("tile_index {index} is out of range for tile_count {count}")]
IndexOutOfRange { index: u16, count: u16 },
}
impl FrameMeta {
pub fn to_headers(&self, encoding: TileEncoding) -> async_nats::HeaderMap {
let mut h = async_nats::HeaderMap::new();
h.insert(header::KIND, FrameKind::Tile.as_str());
h.insert(header::FRAME_SEQ, self.frame_seq.to_string().as_str());
h.insert(header::TILE_INDEX, self.tile_index.to_string().as_str());
h.insert(header::TILE_COUNT, self.tile_count.to_string().as_str());
h.insert(header::TILE_X, self.x.to_string().as_str());
h.insert(header::TILE_Y, self.y.to_string().as_str());
h.insert(header::TILE_W, self.w.to_string().as_str());
h.insert(header::TILE_H, self.h.to_string().as_str());
h.insert(header::SCREEN_W, self.screen_w.to_string().as_str());
h.insert(header::SCREEN_H, self.screen_h.to_string().as_str());
h.insert(header::ENCODING, encoding.as_str());
h.insert(
header::CAPTURED_AT,
self.captured_at_ms.to_string().as_str(),
);
h
}
pub fn from_headers(h: &async_nats::HeaderMap) -> Result<(Self, TileEncoding), FrameMetaError> {
let meta = Self {
frame_seq: num(h, header::FRAME_SEQ)?,
tile_index: num(h, header::TILE_INDEX)?,
tile_count: num(h, header::TILE_COUNT)?,
x: num(h, header::TILE_X)?,
y: num(h, header::TILE_Y)?,
w: num(h, header::TILE_W)?,
h: num(h, header::TILE_H)?,
screen_w: num(h, header::SCREEN_W)?,
screen_h: num(h, header::SCREEN_H)?,
captured_at_ms: num(h, header::CAPTURED_AT)?,
};
let enc_raw = h
.get(header::ENCODING)
.ok_or(FrameMetaError::Missing(header::ENCODING))?
.as_str();
let encoding = TileEncoding::parse(enc_raw)
.ok_or_else(|| FrameMetaError::UnknownEncoding(enc_raw.to_string()))?;
meta.validate()?;
Ok((meta, encoding))
}
pub fn validate(&self) -> Result<(), FrameMetaError> {
if self.tile_count == 0 || self.tile_index >= self.tile_count {
return Err(FrameMetaError::IndexOutOfRange {
index: self.tile_index,
count: self.tile_count,
});
}
let right = self.x.saturating_add(self.w);
let bottom = self.y.saturating_add(self.h);
if self.w == 0 || self.h == 0 || right > self.screen_w || bottom > self.screen_h {
return Err(FrameMetaError::OutOfBounds {
x: self.x,
y: self.y,
w: self.w,
h: self.h,
screen_w: self.screen_w,
screen_h: self.screen_h,
});
}
Ok(())
}
}
fn num<T: std::str::FromStr>(
h: &async_nats::HeaderMap,
name: &'static str,
) -> Result<T, FrameMetaError> {
let raw = h.get(name).ok_or(FrameMetaError::Missing(name))?.as_str();
raw.parse().map_err(|_| FrameMetaError::NotANumber {
name,
value: raw.to_string(),
})
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum RemoteCtrl {
Start {
session_id: String,
#[serde(default)]
output_index: u32,
#[serde(default = "default_quality")]
quality: u8,
#[serde(default = "default_max_fps")]
max_fps: u8,
#[serde(default)]
allow_input: bool,
},
Stop { session_id: String },
Tune {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
quality: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_fps: Option<u8>,
},
}
fn default_quality() -> u8 {
75
}
fn default_max_fps() -> u8 {
10
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(default)]
pub struct RemoteCtrlReply {
pub accepted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_w: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_h: Option<u32>,
}
impl RemoteCtrlReply {
pub fn accepted(screen_w: u32, screen_h: u32) -> Self {
Self {
accepted: true,
reason: None,
screen_w: Some(screen_w),
screen_h: Some(screen_h),
}
}
pub fn refused(reason: impl Into<String>) -> Self {
Self {
accepted: false,
reason: Some(reason.into()),
screen_w: None,
screen_h: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MouseButton {
Left,
Middle,
Right,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RemoteInput {
MouseMove {
x: u32,
y: u32,
},
MouseButton {
button: MouseButton,
down: bool,
x: u32,
y: u32,
},
MouseWheel {
delta: i32,
#[serde(default)]
horizontal: bool,
},
Key {
vk: u16,
down: bool,
},
Text {
text: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn meta() -> FrameMeta {
FrameMeta {
frame_seq: 42,
tile_index: 1,
tile_count: 3,
x: 100,
y: 200,
w: 300,
h: 400,
screen_w: 3840,
screen_h: 1600,
captured_at_ms: 1_753_500_000_000,
}
}
#[test]
fn frame_meta_round_trips_through_headers() {
let m = meta();
let h = m.to_headers(TileEncoding::Jpeg);
let (back, enc) = FrameMeta::from_headers(&h).expect("parse");
assert_eq!(back, m);
assert_eq!(enc, TileEncoding::Jpeg);
}
#[test]
fn frame_meta_round_trips_webp() {
let h = meta().to_headers(TileEncoding::Webp);
let (_, enc) = FrameMeta::from_headers(&h).expect("parse");
assert_eq!(enc, TileEncoding::Webp);
}
#[test]
fn missing_header_is_named_in_the_error() {
let mut h = meta().to_headers(TileEncoding::Jpeg);
let mut stripped = async_nats::HeaderMap::new();
for name in [
header::FRAME_SEQ,
header::TILE_INDEX,
header::TILE_COUNT,
header::TILE_X,
header::TILE_Y,
header::TILE_W,
header::TILE_H,
header::SCREEN_W,
header::SCREEN_H,
header::CAPTURED_AT,
] {
if let Some(v) = h.get(name) {
stripped.insert(name, v.as_str());
}
}
h = stripped;
assert_eq!(
FrameMeta::from_headers(&h).unwrap_err(),
FrameMetaError::Missing(header::ENCODING)
);
}
#[test]
fn non_numeric_header_reports_name_and_value() {
let mut h = meta().to_headers(TileEncoding::Jpeg);
h.insert(header::TILE_W, "wide");
assert_eq!(
FrameMeta::from_headers(&h).unwrap_err(),
FrameMetaError::NotANumber {
name: header::TILE_W,
value: "wide".to_string(),
}
);
}
#[test]
fn unknown_encoding_is_rejected_with_its_value() {
let mut h = meta().to_headers(TileEncoding::Jpeg);
h.insert(header::ENCODING, "avif");
assert_eq!(
FrameMeta::from_headers(&h).unwrap_err(),
FrameMetaError::UnknownEncoding("avif".to_string())
);
}
#[test]
fn tile_outside_the_screen_is_rejected() {
let mut m = meta();
m.x = 3800;
m.w = 100; assert!(matches!(
m.validate(),
Err(FrameMetaError::OutOfBounds { .. })
));
}
#[test]
fn tile_flush_with_the_screen_edge_is_accepted() {
let mut m = meta();
m.x = 3540;
m.w = 300;
m.y = 1200;
m.h = 400;
assert!(m.validate().is_ok(), "{:?}", m.validate());
}
#[test]
fn zero_sized_tile_is_rejected() {
let mut m = meta();
m.w = 0;
assert!(matches!(
m.validate(),
Err(FrameMetaError::OutOfBounds { .. })
));
}
#[test]
fn overflowing_geometry_cannot_wrap_into_bounds() {
let mut m = meta();
m.x = u32::MAX;
m.w = 100;
assert!(matches!(
m.validate(),
Err(FrameMetaError::OutOfBounds { .. })
));
}
#[test]
fn tile_index_past_the_count_is_rejected() {
let mut m = meta();
m.tile_index = 3;
m.tile_count = 3;
assert!(matches!(
m.validate(),
Err(FrameMetaError::IndexOutOfRange { .. })
));
}
#[test]
fn zero_tile_count_is_rejected() {
let mut m = meta();
m.tile_index = 0;
m.tile_count = 0;
assert!(matches!(
m.validate(),
Err(FrameMetaError::IndexOutOfRange { .. })
));
}
#[test]
fn from_headers_validates_geometry_not_just_presence() {
let mut m = meta();
m.w = 9000;
let h = m.to_headers(TileEncoding::Jpeg);
assert!(matches!(
FrameMeta::from_headers(&h),
Err(FrameMetaError::OutOfBounds { .. })
));
}
#[test]
fn tile_headers_declare_their_kind() {
let h = meta().to_headers(TileEncoding::Jpeg);
assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Tile);
}
#[test]
fn gap_headers_declare_their_kind_and_carry_no_geometry() {
let h = gap_headers();
assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Gap);
assert!(FrameMeta::from_headers(&h).is_err());
}
#[test]
fn a_message_without_a_kind_is_rejected() {
let h = async_nats::HeaderMap::new();
assert_eq!(
frame_kind(&h).unwrap_err(),
FrameMetaError::Missing(header::KIND)
);
}
#[test]
fn an_unknown_kind_is_rejected_with_its_value() {
let mut h = async_nats::HeaderMap::new();
h.insert(header::KIND, "cursor");
assert_eq!(
frame_kind(&h).unwrap_err(),
FrameMetaError::UnknownKind("cursor".to_string())
);
}
#[test]
fn frame_kind_keys_round_trip() {
for k in [FrameKind::Tile, FrameKind::Gap, FrameKind::Resumed] {
assert_eq!(FrameKind::parse(k.as_str()), Some(k));
}
assert_eq!(FrameKind::parse("tiles"), None);
}
#[test]
fn resumed_headers_declare_their_kind_and_nothing_else() {
let h = resumed_headers();
assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Resumed);
assert!(FrameMeta::from_headers(&h).is_err());
}
#[test]
fn ctrl_start_round_trips_with_defaults() {
let json = r#"{"op":"start","session_id":"s1"}"#;
let c: RemoteCtrl = serde_json::from_str(json).expect("parse");
assert_eq!(
c,
RemoteCtrl::Start {
session_id: "s1".into(),
output_index: 0,
quality: 75,
max_fps: 10,
allow_input: false,
}
);
}
#[test]
fn ctrl_start_defaults_to_view_only() {
let c: RemoteCtrl = serde_json::from_str(r#"{"op":"start","session_id":"s1"}"#).unwrap();
match c {
RemoteCtrl::Start { allow_input, .. } => assert!(!allow_input),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn ctrl_variants_round_trip() {
for c in [
RemoteCtrl::Start {
session_id: "s1".into(),
output_index: 1,
quality: 60,
max_fps: 15,
allow_input: true,
},
RemoteCtrl::Stop {
session_id: "s1".into(),
},
RemoteCtrl::Tune {
session_id: "s1".into(),
quality: Some(50),
max_fps: None,
},
] {
let s = serde_json::to_string(&c).expect("encode");
assert_eq!(serde_json::from_str::<RemoteCtrl>(&s).expect("decode"), c);
}
}
#[test]
fn tune_omits_absent_fields() {
let s = serde_json::to_string(&RemoteCtrl::Tune {
session_id: "s1".into(),
quality: Some(50),
max_fps: None,
})
.unwrap();
assert!(!s.contains("max_fps"), "{s}");
}
#[test]
fn ctrl_reply_constructors_are_consistent() {
let ok = RemoteCtrlReply::accepted(1920, 1080);
assert!(ok.accepted && ok.reason.is_none());
assert_eq!((ok.screen_w, ok.screen_h), (Some(1920), Some(1080)));
let no = RemoteCtrlReply::refused("user declined");
assert!(!no.accepted);
assert_eq!(no.reason.as_deref(), Some("user declined"));
assert_eq!((no.screen_w, no.screen_h), (None, None));
}
#[test]
fn input_variants_round_trip() {
for i in [
RemoteInput::MouseMove { x: 10, y: 20 },
RemoteInput::MouseButton {
button: MouseButton::Right,
down: true,
x: 1,
y: 2,
},
RemoteInput::MouseWheel {
delta: -120,
horizontal: false,
},
RemoteInput::Key {
vk: 0x41,
down: true,
},
RemoteInput::Text {
text: "こんにちは".into(),
},
] {
let s = serde_json::to_string(&i).expect("encode");
assert_eq!(serde_json::from_str::<RemoteInput>(&s).expect("decode"), i);
}
}
#[test]
fn input_text_survives_multibyte() {
let i = RemoteInput::Text {
text: "日本語入力".into(),
};
let s = serde_json::to_string(&i).unwrap();
assert_eq!(serde_json::from_str::<RemoteInput>(&s).unwrap(), i);
}
#[test]
fn tile_encoding_key_round_trips() {
for e in [TileEncoding::Jpeg, TileEncoding::Webp] {
assert_eq!(TileEncoding::parse(e.as_str()), Some(e));
}
assert_eq!(TileEncoding::parse("png"), None);
}
#[test]
fn max_tile_bytes_derives_from_the_shared_budget() {
assert_eq!(MAX_TILE_BYTES, crate::kv::NATS_PAYLOAD_BUDGET);
assert_eq!(MAX_TILE_BYTES, crate::kv::STDOUT_INLINE_THRESHOLD);
const { assert!(MAX_TILE_BYTES < crate::kv::NATS_DEFAULT_MAX_PAYLOAD) };
}
}