1use serde::{Deserialize, Serialize};
35
36pub const MAX_TILE_BYTES: usize = crate::kv::NATS_PAYLOAD_BUDGET;
50
51#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
53#[serde(rename_all = "lowercase")]
54pub enum TileEncoding {
55 #[default]
58 Jpeg,
59 Webp,
64}
65
66impl TileEncoding {
67 pub fn as_str(self) -> &'static str {
69 match self {
70 TileEncoding::Jpeg => "jpeg",
71 TileEncoding::Webp => "webp",
72 }
73 }
74
75 pub fn parse(s: &str) -> Option<Self> {
79 match s {
80 "jpeg" => Some(TileEncoding::Jpeg),
81 "webp" => Some(TileEncoding::Webp),
82 _ => None,
83 }
84 }
85
86 pub fn mime(self) -> &'static str {
88 match self {
89 TileEncoding::Jpeg => "image/jpeg",
90 TileEncoding::Webp => "image/webp",
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub struct FrameMeta {
105 pub frame_seq: u64,
109 pub tile_index: u16,
111 pub tile_count: u16,
116 pub x: u32,
118 pub y: u32,
119 pub w: u32,
121 pub h: u32,
122 pub screen_w: u32,
128 pub screen_h: u32,
129 pub captured_at_ms: u64,
137}
138
139pub mod header {
142 pub const KIND: &str = "Kanade-Kind";
146
147 pub const FRAME_SEQ: &str = "Kanade-Frame-Seq";
148 pub const TILE_INDEX: &str = "Kanade-Tile-Index";
149 pub const TILE_COUNT: &str = "Kanade-Tile-Count";
150 pub const TILE_X: &str = "Kanade-Tile-X";
151 pub const TILE_Y: &str = "Kanade-Tile-Y";
152 pub const TILE_W: &str = "Kanade-Tile-W";
153 pub const TILE_H: &str = "Kanade-Tile-H";
154 pub const SCREEN_W: &str = "Kanade-Screen-W";
155 pub const SCREEN_H: &str = "Kanade-Screen-H";
156 pub const ENCODING: &str = "Kanade-Encoding";
157 pub const CAPTURED_AT: &str = "Kanade-Captured-At";
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum FrameKind {
176 Tile,
178 Gap,
180 Resumed,
194}
195
196impl FrameKind {
197 pub fn as_str(self) -> &'static str {
198 match self {
199 FrameKind::Tile => "tile",
200 FrameKind::Gap => "gap",
201 FrameKind::Resumed => "resumed",
202 }
203 }
204
205 pub fn parse(s: &str) -> Option<Self> {
206 match s {
207 "tile" => Some(FrameKind::Tile),
208 "gap" => Some(FrameKind::Gap),
209 "resumed" => Some(FrameKind::Resumed),
210 _ => None,
211 }
212 }
213}
214
215pub fn gap_headers() -> async_nats::HeaderMap {
219 let mut h = async_nats::HeaderMap::new();
220 h.insert(header::KIND, FrameKind::Gap.as_str());
221 h
222}
223
224pub fn resumed_headers() -> async_nats::HeaderMap {
227 let mut h = async_nats::HeaderMap::new();
228 h.insert(header::KIND, FrameKind::Resumed.as_str());
229 h
230}
231
232pub fn frame_kind(h: &async_nats::HeaderMap) -> Result<FrameKind, FrameMetaError> {
238 let v = h
239 .get(header::KIND)
240 .ok_or(FrameMetaError::Missing(header::KIND))?;
241 FrameKind::parse(v.as_str()).ok_or_else(|| FrameMetaError::UnknownKind(v.as_str().to_string()))
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
246pub enum FrameMetaError {
247 #[error("missing frame header `{0}`")]
248 Missing(&'static str),
249 #[error("frame header `{name}` is not a valid number: {value:?}")]
250 NotANumber { name: &'static str, value: String },
251 #[error("unknown tile encoding {0:?}")]
252 UnknownEncoding(String),
253 #[error("unknown frame kind {0:?}")]
254 UnknownKind(String),
255 #[error("tile {w}x{h} at ({x},{y}) does not fit a {screen_w}x{screen_h} screen")]
256 OutOfBounds {
257 x: u32,
258 y: u32,
259 w: u32,
260 h: u32,
261 screen_w: u32,
262 screen_h: u32,
263 },
264 #[error("tile_index {index} is out of range for tile_count {count}")]
265 IndexOutOfRange { index: u16, count: u16 },
266}
267
268impl FrameMeta {
269 pub fn to_headers(&self, encoding: TileEncoding) -> async_nats::HeaderMap {
273 let mut h = async_nats::HeaderMap::new();
274 h.insert(header::KIND, FrameKind::Tile.as_str());
275 h.insert(header::FRAME_SEQ, self.frame_seq.to_string().as_str());
276 h.insert(header::TILE_INDEX, self.tile_index.to_string().as_str());
277 h.insert(header::TILE_COUNT, self.tile_count.to_string().as_str());
278 h.insert(header::TILE_X, self.x.to_string().as_str());
279 h.insert(header::TILE_Y, self.y.to_string().as_str());
280 h.insert(header::TILE_W, self.w.to_string().as_str());
281 h.insert(header::TILE_H, self.h.to_string().as_str());
282 h.insert(header::SCREEN_W, self.screen_w.to_string().as_str());
283 h.insert(header::SCREEN_H, self.screen_h.to_string().as_str());
284 h.insert(header::ENCODING, encoding.as_str());
285 h.insert(
286 header::CAPTURED_AT,
287 self.captured_at_ms.to_string().as_str(),
288 );
289 h
290 }
291
292 pub fn from_headers(h: &async_nats::HeaderMap) -> Result<(Self, TileEncoding), FrameMetaError> {
299 let meta = Self {
300 frame_seq: num(h, header::FRAME_SEQ)?,
301 tile_index: num(h, header::TILE_INDEX)?,
302 tile_count: num(h, header::TILE_COUNT)?,
303 x: num(h, header::TILE_X)?,
304 y: num(h, header::TILE_Y)?,
305 w: num(h, header::TILE_W)?,
306 h: num(h, header::TILE_H)?,
307 screen_w: num(h, header::SCREEN_W)?,
308 screen_h: num(h, header::SCREEN_H)?,
309 captured_at_ms: num(h, header::CAPTURED_AT)?,
310 };
311
312 let enc_raw = h
313 .get(header::ENCODING)
314 .ok_or(FrameMetaError::Missing(header::ENCODING))?
315 .as_str();
316 let encoding = TileEncoding::parse(enc_raw)
317 .ok_or_else(|| FrameMetaError::UnknownEncoding(enc_raw.to_string()))?;
318
319 meta.validate()?;
320 Ok((meta, encoding))
321 }
322
323 pub fn validate(&self) -> Result<(), FrameMetaError> {
326 if self.tile_count == 0 || self.tile_index >= self.tile_count {
327 return Err(FrameMetaError::IndexOutOfRange {
328 index: self.tile_index,
329 count: self.tile_count,
330 });
331 }
332 let right = self.x.saturating_add(self.w);
334 let bottom = self.y.saturating_add(self.h);
335 if self.w == 0 || self.h == 0 || right > self.screen_w || bottom > self.screen_h {
336 return Err(FrameMetaError::OutOfBounds {
337 x: self.x,
338 y: self.y,
339 w: self.w,
340 h: self.h,
341 screen_w: self.screen_w,
342 screen_h: self.screen_h,
343 });
344 }
345 Ok(())
346 }
347}
348
349fn num<T: std::str::FromStr>(
351 h: &async_nats::HeaderMap,
352 name: &'static str,
353) -> Result<T, FrameMetaError> {
354 let raw = h.get(name).ok_or(FrameMetaError::Missing(name))?.as_str();
355 raw.parse().map_err(|_| FrameMetaError::NotANumber {
356 name,
357 value: raw.to_string(),
358 })
359}
360
361#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
363#[serde(tag = "op", rename_all = "snake_case")]
364pub enum RemoteCtrl {
365 Start {
368 session_id: String,
369 #[serde(default)]
371 output_index: u32,
372 #[serde(default = "default_quality")]
374 quality: u8,
375 #[serde(default = "default_max_fps")]
378 max_fps: u8,
379 #[serde(default)]
384 allow_input: bool,
385 },
386 Stop { session_id: String },
389 Tune {
392 session_id: String,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 quality: Option<u8>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 max_fps: Option<u8>,
397 },
398}
399
400fn default_quality() -> u8 {
401 75
402}
403
404fn default_max_fps() -> u8 {
405 10
406}
407
408#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
410#[serde(default)]
411pub struct RemoteCtrlReply {
412 pub accepted: bool,
414 #[serde(skip_serializing_if = "Option::is_none")]
419 pub reason: Option<String>,
420 #[serde(skip_serializing_if = "Option::is_none")]
423 pub screen_w: Option<u32>,
424 #[serde(skip_serializing_if = "Option::is_none")]
425 pub screen_h: Option<u32>,
426}
427
428impl RemoteCtrlReply {
429 pub fn accepted(screen_w: u32, screen_h: u32) -> Self {
431 Self {
432 accepted: true,
433 reason: None,
434 screen_w: Some(screen_w),
435 screen_h: Some(screen_h),
436 }
437 }
438
439 pub fn refused(reason: impl Into<String>) -> Self {
441 Self {
442 accepted: false,
443 reason: Some(reason.into()),
444 screen_w: None,
445 screen_h: None,
446 }
447 }
448}
449
450#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
452#[serde(rename_all = "lowercase")]
453pub enum MouseButton {
454 Left,
455 Middle,
456 Right,
457}
458
459#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
466#[serde(tag = "kind", rename_all = "snake_case")]
467pub enum RemoteInput {
468 MouseMove {
469 x: u32,
470 y: u32,
471 },
472 MouseButton {
473 button: MouseButton,
474 down: bool,
478 x: u32,
479 y: u32,
480 },
481 MouseWheel {
482 delta: i32,
484 #[serde(default)]
486 horizontal: bool,
487 },
488 Key {
493 vk: u16,
494 down: bool,
495 },
496 Text {
500 text: String,
501 },
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 fn meta() -> FrameMeta {
509 FrameMeta {
510 frame_seq: 42,
511 tile_index: 1,
512 tile_count: 3,
513 x: 100,
514 y: 200,
515 w: 300,
516 h: 400,
517 screen_w: 3840,
518 screen_h: 1600,
519 captured_at_ms: 1_753_500_000_000,
520 }
521 }
522
523 #[test]
524 fn frame_meta_round_trips_through_headers() {
525 let m = meta();
526 let h = m.to_headers(TileEncoding::Jpeg);
527 let (back, enc) = FrameMeta::from_headers(&h).expect("parse");
528 assert_eq!(back, m);
529 assert_eq!(enc, TileEncoding::Jpeg);
530 }
531
532 #[test]
533 fn frame_meta_round_trips_webp() {
534 let h = meta().to_headers(TileEncoding::Webp);
535 let (_, enc) = FrameMeta::from_headers(&h).expect("parse");
536 assert_eq!(enc, TileEncoding::Webp);
537 }
538
539 #[test]
540 fn missing_header_is_named_in_the_error() {
541 let mut h = meta().to_headers(TileEncoding::Jpeg);
542 let mut stripped = async_nats::HeaderMap::new();
544 for name in [
545 header::FRAME_SEQ,
546 header::TILE_INDEX,
547 header::TILE_COUNT,
548 header::TILE_X,
549 header::TILE_Y,
550 header::TILE_W,
551 header::TILE_H,
552 header::SCREEN_W,
553 header::SCREEN_H,
554 header::CAPTURED_AT,
555 ] {
556 if let Some(v) = h.get(name) {
557 stripped.insert(name, v.as_str());
558 }
559 }
560 h = stripped;
562 assert_eq!(
563 FrameMeta::from_headers(&h).unwrap_err(),
564 FrameMetaError::Missing(header::ENCODING)
565 );
566 }
567
568 #[test]
569 fn non_numeric_header_reports_name_and_value() {
570 let mut h = meta().to_headers(TileEncoding::Jpeg);
571 h.insert(header::TILE_W, "wide");
572 assert_eq!(
573 FrameMeta::from_headers(&h).unwrap_err(),
574 FrameMetaError::NotANumber {
575 name: header::TILE_W,
576 value: "wide".to_string(),
577 }
578 );
579 }
580
581 #[test]
582 fn unknown_encoding_is_rejected_with_its_value() {
583 let mut h = meta().to_headers(TileEncoding::Jpeg);
584 h.insert(header::ENCODING, "avif");
585 assert_eq!(
586 FrameMeta::from_headers(&h).unwrap_err(),
587 FrameMetaError::UnknownEncoding("avif".to_string())
588 );
589 }
590
591 #[test]
592 fn tile_outside_the_screen_is_rejected() {
593 let mut m = meta();
594 m.x = 3800;
595 m.w = 100; assert!(matches!(
597 m.validate(),
598 Err(FrameMetaError::OutOfBounds { .. })
599 ));
600 }
601
602 #[test]
603 fn tile_flush_with_the_screen_edge_is_accepted() {
604 let mut m = meta();
606 m.x = 3540;
607 m.w = 300;
608 m.y = 1200;
609 m.h = 400;
610 assert!(m.validate().is_ok(), "{:?}", m.validate());
611 }
612
613 #[test]
614 fn zero_sized_tile_is_rejected() {
615 let mut m = meta();
616 m.w = 0;
617 assert!(matches!(
618 m.validate(),
619 Err(FrameMetaError::OutOfBounds { .. })
620 ));
621 }
622
623 #[test]
624 fn overflowing_geometry_cannot_wrap_into_bounds() {
625 let mut m = meta();
628 m.x = u32::MAX;
629 m.w = 100;
630 assert!(matches!(
631 m.validate(),
632 Err(FrameMetaError::OutOfBounds { .. })
633 ));
634 }
635
636 #[test]
637 fn tile_index_past_the_count_is_rejected() {
638 let mut m = meta();
639 m.tile_index = 3;
640 m.tile_count = 3;
641 assert!(matches!(
642 m.validate(),
643 Err(FrameMetaError::IndexOutOfRange { .. })
644 ));
645 }
646
647 #[test]
648 fn zero_tile_count_is_rejected() {
649 let mut m = meta();
650 m.tile_index = 0;
651 m.tile_count = 0;
652 assert!(matches!(
653 m.validate(),
654 Err(FrameMetaError::IndexOutOfRange { .. })
655 ));
656 }
657
658 #[test]
659 fn from_headers_validates_geometry_not_just_presence() {
660 let mut m = meta();
661 m.w = 9000;
662 let h = m.to_headers(TileEncoding::Jpeg);
663 assert!(matches!(
664 FrameMeta::from_headers(&h),
665 Err(FrameMetaError::OutOfBounds { .. })
666 ));
667 }
668
669 #[test]
670 fn tile_headers_declare_their_kind() {
671 let h = meta().to_headers(TileEncoding::Jpeg);
672 assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Tile);
673 }
674
675 #[test]
676 fn gap_headers_declare_their_kind_and_carry_no_geometry() {
677 let h = gap_headers();
678 assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Gap);
679 assert!(FrameMeta::from_headers(&h).is_err());
682 }
683
684 #[test]
685 fn a_message_without_a_kind_is_rejected() {
686 let h = async_nats::HeaderMap::new();
690 assert_eq!(
691 frame_kind(&h).unwrap_err(),
692 FrameMetaError::Missing(header::KIND)
693 );
694 }
695
696 #[test]
697 fn an_unknown_kind_is_rejected_with_its_value() {
698 let mut h = async_nats::HeaderMap::new();
699 h.insert(header::KIND, "cursor");
700 assert_eq!(
701 frame_kind(&h).unwrap_err(),
702 FrameMetaError::UnknownKind("cursor".to_string())
703 );
704 }
705
706 #[test]
707 fn frame_kind_keys_round_trip() {
708 for k in [FrameKind::Tile, FrameKind::Gap, FrameKind::Resumed] {
709 assert_eq!(FrameKind::parse(k.as_str()), Some(k));
710 }
711 assert_eq!(FrameKind::parse("tiles"), None);
712 }
713
714 #[test]
715 fn resumed_headers_declare_their_kind_and_nothing_else() {
716 let h = resumed_headers();
717 assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Resumed);
718 assert!(FrameMeta::from_headers(&h).is_err());
720 }
721
722 #[test]
723 fn ctrl_start_round_trips_with_defaults() {
724 let json = r#"{"op":"start","session_id":"s1"}"#;
725 let c: RemoteCtrl = serde_json::from_str(json).expect("parse");
726 assert_eq!(
727 c,
728 RemoteCtrl::Start {
729 session_id: "s1".into(),
730 output_index: 0,
731 quality: 75,
732 max_fps: 10,
733 allow_input: false,
734 }
735 );
736 }
737
738 #[test]
739 fn ctrl_start_defaults_to_view_only() {
740 let c: RemoteCtrl = serde_json::from_str(r#"{"op":"start","session_id":"s1"}"#).unwrap();
743 match c {
744 RemoteCtrl::Start { allow_input, .. } => assert!(!allow_input),
745 other => panic!("expected Start, got {other:?}"),
746 }
747 }
748
749 #[test]
750 fn ctrl_variants_round_trip() {
751 for c in [
752 RemoteCtrl::Start {
753 session_id: "s1".into(),
754 output_index: 1,
755 quality: 60,
756 max_fps: 15,
757 allow_input: true,
758 },
759 RemoteCtrl::Stop {
760 session_id: "s1".into(),
761 },
762 RemoteCtrl::Tune {
763 session_id: "s1".into(),
764 quality: Some(50),
765 max_fps: None,
766 },
767 ] {
768 let s = serde_json::to_string(&c).expect("encode");
769 assert_eq!(serde_json::from_str::<RemoteCtrl>(&s).expect("decode"), c);
770 }
771 }
772
773 #[test]
774 fn tune_omits_absent_fields() {
775 let s = serde_json::to_string(&RemoteCtrl::Tune {
776 session_id: "s1".into(),
777 quality: Some(50),
778 max_fps: None,
779 })
780 .unwrap();
781 assert!(!s.contains("max_fps"), "{s}");
782 }
783
784 #[test]
785 fn ctrl_reply_constructors_are_consistent() {
786 let ok = RemoteCtrlReply::accepted(1920, 1080);
787 assert!(ok.accepted && ok.reason.is_none());
788 assert_eq!((ok.screen_w, ok.screen_h), (Some(1920), Some(1080)));
789
790 let no = RemoteCtrlReply::refused("user declined");
791 assert!(!no.accepted);
792 assert_eq!(no.reason.as_deref(), Some("user declined"));
793 assert_eq!((no.screen_w, no.screen_h), (None, None));
794 }
795
796 #[test]
797 fn input_variants_round_trip() {
798 for i in [
799 RemoteInput::MouseMove { x: 10, y: 20 },
800 RemoteInput::MouseButton {
801 button: MouseButton::Right,
802 down: true,
803 x: 1,
804 y: 2,
805 },
806 RemoteInput::MouseWheel {
807 delta: -120,
808 horizontal: false,
809 },
810 RemoteInput::Key {
811 vk: 0x41,
812 down: true,
813 },
814 RemoteInput::Text {
815 text: "こんにちは".into(),
816 },
817 ] {
818 let s = serde_json::to_string(&i).expect("encode");
819 assert_eq!(serde_json::from_str::<RemoteInput>(&s).expect("decode"), i);
820 }
821 }
822
823 #[test]
824 fn input_text_survives_multibyte() {
825 let i = RemoteInput::Text {
827 text: "日本語入力".into(),
828 };
829 let s = serde_json::to_string(&i).unwrap();
830 assert_eq!(serde_json::from_str::<RemoteInput>(&s).unwrap(), i);
831 }
832
833 #[test]
834 fn tile_encoding_key_round_trips() {
835 for e in [TileEncoding::Jpeg, TileEncoding::Webp] {
836 assert_eq!(TileEncoding::parse(e.as_str()), Some(e));
837 }
838 assert_eq!(TileEncoding::parse("png"), None);
839 }
840
841 #[test]
842 fn max_tile_bytes_derives_from_the_shared_budget() {
843 assert_eq!(MAX_TILE_BYTES, crate::kv::NATS_PAYLOAD_BUDGET);
848 assert_eq!(MAX_TILE_BYTES, crate::kv::STDOUT_INLINE_THRESHOLD);
849 const { assert!(MAX_TILE_BYTES < crate::kv::NATS_DEFAULT_MAX_PAYLOAD) };
852 }
853}