kanade_shared/wire/remote.rs
1//! Wire types for the #1140 remote-assistance relay.
2//!
3//! Three planes, three shapes, for reasons the measurements in #1142 made
4//! concrete:
5//!
6//! - **Control** (`remote.ctrl.<pc_id>`, request/reply): [`RemoteCtrl`] /
7//! [`RemoteCtrlReply`]. JSON — a handful of these exist per session.
8//! - **Input** (`remote.input.<session_id>`): [`RemoteInput`]. JSON — small,
9//! structured, and bursty at human speed, so encoding overhead is noise.
10//! - **Frames** (`remote.frame.<session_id>`): [`FrameMeta`] in NATS
11//! **headers**, raw encoded image bytes as the payload.
12//!
13//! # Why frames don't use JSON
14//!
15//! Every other wire type in this crate is a JSON body, so the frame plane
16//! deliberately breaks the house style. A JSON envelope has to base64 the
17//! pixels, which costs a flat 33%. #1142 measured a typical session at
18//! ~4.0 Mbps of dirty-rect tiles, so the envelope alone would burn ~1.3
19//! Mbps per viewer to carry nothing — on the same overlay link the endpoint
20//! uses for real work. Headers keep the metadata structured and typed while
21//! the pixels travel as bytes.
22//!
23//! # Tiles, not frames
24//!
25//! One message carries **one tile** — a single changed rectangle. #1142
26//! measured mean changed area at 12.8% of the screen and 1.7–2.5 tiles per
27//! frame, so a frame is normally 2–3 messages of ~94 KB. Sending whole
28//! frames instead would have cost 32 Mbps against 4.0, which is the finding
29//! that made dirty-rect encoding a requirement rather than an optimisation.
30//!
31//! The sender is responsible for keeping a tile under [`MAX_TILE_BYTES`];
32//! a full-screen change encodes to ~700 KB and must be split.
33
34use serde::{Deserialize, Serialize};
35
36/// Largest encoded tile a single message may carry.
37///
38/// Derived from [`crate::kv::NATS_PAYLOAD_BUDGET`] rather than restating
39/// its value, so raising the broker's `max_payload` moves this without
40/// anyone remembering that it should. It stays its *own* constant because
41/// "how far may one screen tile grow" and "when does stdout spill to the
42/// Object Store" ([`crate::kv::STDOUT_INLINE_THRESHOLD`]) are different
43/// questions that merely share a broker limit — collapsing them would mean
44/// a future tile-specific ceiling (headers do consume some of the budget)
45/// silently retuning result overflow.
46///
47/// Comfortably above the ~94 KB mean tile #1142 measured; a full-screen
48/// change encodes to ~700 KB and the sender must split it.
49pub const MAX_TILE_BYTES: usize = crate::kv::NATS_PAYLOAD_BUDGET;
50
51/// How a tile's pixels are encoded.
52#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
53#[serde(rename_all = "lowercase")]
54pub enum TileEncoding {
55 /// Baseline JPEG. The default: universally decodable by a browser
56 /// `<img>`/`createImageBitmap`, and what #1142 measured against.
57 #[default]
58 Jpeg,
59 /// WebP. Smaller at equal quality, but the encoder is heavier — #1142
60 /// showed encode time is already 88% of the per-frame budget, so this
61 /// is here as a wire-format option to evaluate, not a default to adopt
62 /// blind.
63 Webp,
64}
65
66impl TileEncoding {
67 /// Header value / MIME subtype.
68 pub fn as_str(self) -> &'static str {
69 match self {
70 TileEncoding::Jpeg => "jpeg",
71 TileEncoding::Webp => "webp",
72 }
73 }
74
75 /// Parse a header value. Unknown encodings yield `None` so a receiver
76 /// can skip a tile it cannot decode instead of tearing down a session
77 /// that is otherwise fine.
78 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 /// MIME type, for handing the payload straight to a browser.
87 pub fn mime(self) -> &'static str {
88 match self {
89 TileEncoding::Jpeg => "image/jpeg",
90 TileEncoding::Webp => "image/webp",
91 }
92 }
93}
94
95/// Metadata for one tile, carried in NATS headers alongside the raw image
96/// payload.
97///
98/// Also `Serialize`/`Deserialize`: the same metadata travels as JSON over
99/// the agent's in-session IPC pipe, where there are no NATS headers to put
100/// it in. One type describing a tile, two encodings of it depending on which
101/// hop it is crossing — the alternative was a near-duplicate struct that
102/// would drift the first time a field was added.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub struct FrameMeta {
105 /// Monotonic per-session frame counter. Tiles of the same frame share
106 /// it, which is what lets a viewer decide whether it is assembling a
107 /// coherent picture or has started receiving the next one.
108 pub frame_seq: u64,
109 /// Index of this tile within its frame, `0..tile_count`.
110 pub tile_index: u16,
111 /// How many tiles this frame was split into. A viewer that has seen all
112 /// of them can present the frame; one that is missing some can still
113 /// paint what arrived — tiles are independent images, so a dropped tile
114 /// degrades to a stale rectangle rather than a corrupt screen.
115 pub tile_count: u16,
116 /// Tile origin in desktop pixels.
117 pub x: u32,
118 pub y: u32,
119 /// Tile size in pixels.
120 pub w: u32,
121 pub h: u32,
122 /// Full desktop size, repeated on every tile.
123 ///
124 /// Redundant by design: a viewer that joins mid-session, or reconnects,
125 /// can size its canvas from the first tile it happens to receive
126 /// without waiting for a control round-trip or a full frame.
127 pub screen_w: u32,
128 pub screen_h: u32,
129 /// Agent-side capture time, milliseconds since the Unix epoch.
130 ///
131 /// For measuring one-way latency (#1140 risk 2 — whether control across
132 /// the overlay is usable at all). Clocks are not synchronised between
133 /// endpoint and backend, so treat the absolute value as untrustworthy;
134 /// the *differences* between consecutive frames from one agent are what
135 /// carry signal.
136 pub captured_at_ms: u64,
137}
138
139/// NATS header names for [`FrameMeta`]. Namespaced so they cannot collide
140/// with anything the broker or a future feature adds.
141pub mod header {
142 /// Which kind of message this is: a tile, or a gap in the stream.
143 /// Present on every frame-plane message so a viewer can dispatch before
144 /// trying to parse geometry that a gap does not have.
145 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/// What a frame-plane message carries.
161///
162/// The gap variant is why this enum exists. A locked workstation, a UAC
163/// prompt or a display mode change stops capture dead (see the agent's
164/// `screen_capture`), and a viewer only ever told about tiles cannot
165/// distinguish "the picture is still because nothing is changing" from "we
166/// cannot see the screen at all". Those need different words on screen, so
167/// they need different messages on the wire.
168///
169/// Gaps ride the frame plane rather than the control plane because they are
170/// part of what the viewer is *showing*, not a request or a reply — and
171/// because arriving in order with the tiles is what makes them meaningful.
172/// A gap delivered out of band could easily be painted after the frames
173/// that already resumed.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum FrameKind {
176 /// Headers carry a [`FrameMeta`]; payload is the encoded image.
177 Tile,
178 /// Headers carry nothing else; payload is a UTF-8 reason string.
179 Gap,
180 /// Capture works again, but there is nothing new to show yet. No
181 /// headers beyond the kind, no payload.
182 ///
183 /// Without this, recovery is only observable when the next tile
184 /// happens to arrive — and a desktop that is reachable but unchanged
185 /// produces no tiles at all. An operator would keep reading "screen
186 /// unavailable" for as long as nobody moved a window, long after
187 /// capture had recovered.
188 ///
189 /// The alternative was to force a full frame on recovery, which would
190 /// have meant sending several hundred KB to say "nothing changed" —
191 /// the opposite of the bandwidth argument that shaped this whole
192 /// format.
193 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
215/// Headers for a gap message. The reason travels as the payload, not a
216/// header, because it is free text of unbounded length and headers are a
217/// poor place for that.
218pub 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
224/// Headers for a resumed message. Carries nothing but the kind — the
225/// message exists to mark a transition, not to deliver content.
226pub 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
232/// Read the kind off a frame-plane message.
233///
234/// A message with no `Kanade-Kind` header is rejected rather than assumed:
235/// every publisher of this plane sets it, so a missing one means the
236/// message did not come from a publisher that agrees with this contract.
237pub 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/// A frame header was missing or unparseable.
245#[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 /// Write this metadata into a NATS header map, together with the
270 /// encoding (which lives outside the struct because it describes the
271 /// payload, not the geometry).
272 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 /// Parse metadata back out of a NATS header map.
293 ///
294 /// Validates geometry as well as presence: a tile that claims to fall
295 /// outside its own screen would otherwise reach a viewer's canvas draw
296 /// and either throw or silently paint nothing, far from the corrupt
297 /// header that caused it.
298 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 /// Geometry sanity. Split out so a sender can check before publishing
324 /// rather than leaving it to the receiver to reject.
325 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 // Saturating so a bogus header can't wrap into an in-bounds answer.
333 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
349/// Read one numeric header.
350fn 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/// Backend → agent control message on `remote.ctrl.<pc_id>`.
362#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
363#[serde(tag = "op", rename_all = "snake_case")]
364pub enum RemoteCtrl {
365 /// Begin streaming. The backend mints `session_id`; the agent uses it
366 /// to derive its publish and input subjects.
367 Start {
368 session_id: String,
369 /// Display output to capture, 0 = primary.
370 #[serde(default)]
371 output_index: u32,
372 /// JPEG/WebP quality, 1–100.
373 #[serde(default = "default_quality")]
374 quality: u8,
375 /// Upper bound on frames per second. The agent may deliver fewer
376 /// (an idle desktop produces nothing); it must not exceed this.
377 #[serde(default = "default_max_fps")]
378 max_fps: u8,
379 /// Whether this session may inject input, decided by the backend
380 /// from the operator's features — **not** something the agent
381 /// infers. An endpoint must never have to reason about who is
382 /// allowed to drive it.
383 #[serde(default)]
384 allow_input: bool,
385 },
386 /// Stop streaming and tear the session down. Idempotent: stopping an
387 /// unknown session succeeds, so a retry after a lost reply is safe.
388 Stop { session_id: String },
389 /// Adjust a live session without restarting it. Absent fields keep
390 /// their current value.
391 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/// Agent → backend reply to a [`RemoteCtrl`].
409#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
410#[serde(default)]
411pub struct RemoteCtrlReply {
412 /// The agent acted on the request.
413 pub accepted: bool,
414 /// Why not, when `accepted` is false — no capture-capable session, the
415 /// user declined consent, another session already holds the output.
416 /// Surfaced to the operator verbatim, so it must read as an
417 /// explanation rather than an error code.
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub reason: Option<String>,
420 /// Desktop size, when known — which **in practice is never, on accept**.
421 ///
422 /// The agent answers `Start` as soon as it has spawned its capture
423 /// child, before that child has taken a frame, deliberately: blocking the
424 /// accept on a display round-trip would stall the operator's click. So it
425 /// sends `None` here and the size reaches the viewer on the first tile
426 /// instead, which is why [`FrameMeta`] repeats `screen_w` / `screen_h` on
427 /// every one of them.
428 ///
429 /// The fields remain because an agent that already knows the geometry —
430 /// one resuming a session, say — may report it, and a viewer that gets it
431 /// can size its canvas a frame earlier. Treat a value as an
432 /// optimisation; never wait for it.
433 #[serde(skip_serializing_if = "Option::is_none")]
434 pub screen_w: Option<u32>,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 pub screen_h: Option<u32>,
437}
438
439impl RemoteCtrlReply {
440 /// Accepted, with the geometry the operator's canvas needs.
441 pub fn accepted(screen_w: u32, screen_h: u32) -> Self {
442 Self {
443 accepted: true,
444 reason: None,
445 screen_w: Some(screen_w),
446 screen_h: Some(screen_h),
447 }
448 }
449
450 /// Refused, with a human-readable reason.
451 pub fn refused(reason: impl Into<String>) -> Self {
452 Self {
453 accepted: false,
454 reason: Some(reason.into()),
455 screen_w: None,
456 screen_h: None,
457 }
458 }
459}
460
461/// Which mouse button an event refers to.
462#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
463#[serde(rename_all = "lowercase")]
464pub enum MouseButton {
465 Left,
466 Middle,
467 Right,
468}
469
470/// Backend → agent input event on `remote.input.<session_id>`.
471///
472/// Coordinates are **desktop pixels**, not viewer pixels: the SPA scales a
473/// 3840x1600 desktop into whatever canvas it has, and letting the endpoint
474/// receive viewer-space coordinates would make correctness depend on the
475/// browser window size at the far end of a lossy link.
476#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
477#[serde(tag = "kind", rename_all = "snake_case")]
478pub enum RemoteInput {
479 MouseMove {
480 x: u32,
481 y: u32,
482 },
483 MouseButton {
484 button: MouseButton,
485 /// True on press, false on release. Sent as separate events rather
486 /// than as clicks so drag, and any modifier held across the
487 /// gesture, survive the trip.
488 down: bool,
489 x: u32,
490 y: u32,
491 },
492 MouseWheel {
493 /// Wheel delta in the platform's native units.
494 delta: i32,
495 /// Horizontal scroll rather than vertical.
496 #[serde(default)]
497 horizontal: bool,
498 },
499 /// A key transition, addressed by Windows virtual-key code.
500 ///
501 /// Deliberately not a character: shortcuts (Ctrl+C, Alt+Tab) are about
502 /// physical keys, and a character-only channel cannot express them.
503 Key {
504 vk: u16,
505 down: bool,
506 },
507 /// Literal text, for IME composition and anything else a virtual-key
508 /// stream cannot express. Complements [`RemoteInput::Key`] rather than
509 /// replacing it — Japanese input is the case that forces this to exist.
510 Text {
511 text: String,
512 },
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn meta() -> FrameMeta {
520 FrameMeta {
521 frame_seq: 42,
522 tile_index: 1,
523 tile_count: 3,
524 x: 100,
525 y: 200,
526 w: 300,
527 h: 400,
528 screen_w: 3840,
529 screen_h: 1600,
530 captured_at_ms: 1_753_500_000_000,
531 }
532 }
533
534 #[test]
535 fn frame_meta_round_trips_through_headers() {
536 let m = meta();
537 let h = m.to_headers(TileEncoding::Jpeg);
538 let (back, enc) = FrameMeta::from_headers(&h).expect("parse");
539 assert_eq!(back, m);
540 assert_eq!(enc, TileEncoding::Jpeg);
541 }
542
543 #[test]
544 fn frame_meta_round_trips_webp() {
545 let h = meta().to_headers(TileEncoding::Webp);
546 let (_, enc) = FrameMeta::from_headers(&h).expect("parse");
547 assert_eq!(enc, TileEncoding::Webp);
548 }
549
550 #[test]
551 fn missing_header_is_named_in_the_error() {
552 let mut h = meta().to_headers(TileEncoding::Jpeg);
553 // async-nats has no remove(); rebuild without the one under test.
554 let mut stripped = async_nats::HeaderMap::new();
555 for name in [
556 header::FRAME_SEQ,
557 header::TILE_INDEX,
558 header::TILE_COUNT,
559 header::TILE_X,
560 header::TILE_Y,
561 header::TILE_W,
562 header::TILE_H,
563 header::SCREEN_W,
564 header::SCREEN_H,
565 header::CAPTURED_AT,
566 ] {
567 if let Some(v) = h.get(name) {
568 stripped.insert(name, v.as_str());
569 }
570 }
571 // ENCODING deliberately omitted.
572 h = stripped;
573 assert_eq!(
574 FrameMeta::from_headers(&h).unwrap_err(),
575 FrameMetaError::Missing(header::ENCODING)
576 );
577 }
578
579 #[test]
580 fn non_numeric_header_reports_name_and_value() {
581 let mut h = meta().to_headers(TileEncoding::Jpeg);
582 h.insert(header::TILE_W, "wide");
583 assert_eq!(
584 FrameMeta::from_headers(&h).unwrap_err(),
585 FrameMetaError::NotANumber {
586 name: header::TILE_W,
587 value: "wide".to_string(),
588 }
589 );
590 }
591
592 #[test]
593 fn unknown_encoding_is_rejected_with_its_value() {
594 let mut h = meta().to_headers(TileEncoding::Jpeg);
595 h.insert(header::ENCODING, "avif");
596 assert_eq!(
597 FrameMeta::from_headers(&h).unwrap_err(),
598 FrameMetaError::UnknownEncoding("avif".to_string())
599 );
600 }
601
602 #[test]
603 fn tile_outside_the_screen_is_rejected() {
604 let mut m = meta();
605 m.x = 3800;
606 m.w = 100; // 3900 > 3840
607 assert!(matches!(
608 m.validate(),
609 Err(FrameMetaError::OutOfBounds { .. })
610 ));
611 }
612
613 #[test]
614 fn tile_flush_with_the_screen_edge_is_accepted() {
615 // Off-by-one guard: right == screen_w is in bounds, not out.
616 let mut m = meta();
617 m.x = 3540;
618 m.w = 300;
619 m.y = 1200;
620 m.h = 400;
621 assert!(m.validate().is_ok(), "{:?}", m.validate());
622 }
623
624 #[test]
625 fn zero_sized_tile_is_rejected() {
626 let mut m = meta();
627 m.w = 0;
628 assert!(matches!(
629 m.validate(),
630 Err(FrameMetaError::OutOfBounds { .. })
631 ));
632 }
633
634 #[test]
635 fn overflowing_geometry_cannot_wrap_into_bounds() {
636 // x + w would wrap to a small number without saturating arithmetic,
637 // letting a corrupt header pass validation.
638 let mut m = meta();
639 m.x = u32::MAX;
640 m.w = 100;
641 assert!(matches!(
642 m.validate(),
643 Err(FrameMetaError::OutOfBounds { .. })
644 ));
645 }
646
647 #[test]
648 fn tile_index_past_the_count_is_rejected() {
649 let mut m = meta();
650 m.tile_index = 3;
651 m.tile_count = 3;
652 assert!(matches!(
653 m.validate(),
654 Err(FrameMetaError::IndexOutOfRange { .. })
655 ));
656 }
657
658 #[test]
659 fn zero_tile_count_is_rejected() {
660 let mut m = meta();
661 m.tile_index = 0;
662 m.tile_count = 0;
663 assert!(matches!(
664 m.validate(),
665 Err(FrameMetaError::IndexOutOfRange { .. })
666 ));
667 }
668
669 #[test]
670 fn from_headers_validates_geometry_not_just_presence() {
671 let mut m = meta();
672 m.w = 9000;
673 let h = m.to_headers(TileEncoding::Jpeg);
674 assert!(matches!(
675 FrameMeta::from_headers(&h),
676 Err(FrameMetaError::OutOfBounds { .. })
677 ));
678 }
679
680 #[test]
681 fn tile_headers_declare_their_kind() {
682 let h = meta().to_headers(TileEncoding::Jpeg);
683 assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Tile);
684 }
685
686 #[test]
687 fn gap_headers_declare_their_kind_and_carry_no_geometry() {
688 let h = gap_headers();
689 assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Gap);
690 // A gap has no tile to describe; asking for one must fail cleanly
691 // rather than yield a zero-sized tile a viewer would try to paint.
692 assert!(FrameMeta::from_headers(&h).is_err());
693 }
694
695 #[test]
696 fn a_message_without_a_kind_is_rejected() {
697 // Not defaulted to Tile: a missing kind means the publisher does not
698 // share this contract, and guessing would hand geometry parsing a
699 // message that may have none.
700 let h = async_nats::HeaderMap::new();
701 assert_eq!(
702 frame_kind(&h).unwrap_err(),
703 FrameMetaError::Missing(header::KIND)
704 );
705 }
706
707 #[test]
708 fn an_unknown_kind_is_rejected_with_its_value() {
709 let mut h = async_nats::HeaderMap::new();
710 h.insert(header::KIND, "cursor");
711 assert_eq!(
712 frame_kind(&h).unwrap_err(),
713 FrameMetaError::UnknownKind("cursor".to_string())
714 );
715 }
716
717 #[test]
718 fn frame_kind_keys_round_trip() {
719 for k in [FrameKind::Tile, FrameKind::Gap, FrameKind::Resumed] {
720 assert_eq!(FrameKind::parse(k.as_str()), Some(k));
721 }
722 assert_eq!(FrameKind::parse("tiles"), None);
723 }
724
725 #[test]
726 fn resumed_headers_declare_their_kind_and_nothing_else() {
727 let h = resumed_headers();
728 assert_eq!(frame_kind(&h).expect("kind"), FrameKind::Resumed);
729 // Like a gap, it has no tile to describe.
730 assert!(FrameMeta::from_headers(&h).is_err());
731 }
732
733 #[test]
734 fn ctrl_start_round_trips_with_defaults() {
735 let json = r#"{"op":"start","session_id":"s1"}"#;
736 let c: RemoteCtrl = serde_json::from_str(json).expect("parse");
737 assert_eq!(
738 c,
739 RemoteCtrl::Start {
740 session_id: "s1".into(),
741 output_index: 0,
742 quality: 75,
743 max_fps: 10,
744 allow_input: false,
745 }
746 );
747 }
748
749 #[test]
750 fn ctrl_start_defaults_to_view_only() {
751 // The safe default matters: a control message that forgot the field
752 // must not hand over the keyboard.
753 let c: RemoteCtrl = serde_json::from_str(r#"{"op":"start","session_id":"s1"}"#).unwrap();
754 match c {
755 RemoteCtrl::Start { allow_input, .. } => assert!(!allow_input),
756 other => panic!("expected Start, got {other:?}"),
757 }
758 }
759
760 #[test]
761 fn ctrl_variants_round_trip() {
762 for c in [
763 RemoteCtrl::Start {
764 session_id: "s1".into(),
765 output_index: 1,
766 quality: 60,
767 max_fps: 15,
768 allow_input: true,
769 },
770 RemoteCtrl::Stop {
771 session_id: "s1".into(),
772 },
773 RemoteCtrl::Tune {
774 session_id: "s1".into(),
775 quality: Some(50),
776 max_fps: None,
777 },
778 ] {
779 let s = serde_json::to_string(&c).expect("encode");
780 assert_eq!(serde_json::from_str::<RemoteCtrl>(&s).expect("decode"), c);
781 }
782 }
783
784 #[test]
785 fn tune_omits_absent_fields() {
786 let s = serde_json::to_string(&RemoteCtrl::Tune {
787 session_id: "s1".into(),
788 quality: Some(50),
789 max_fps: None,
790 })
791 .unwrap();
792 assert!(!s.contains("max_fps"), "{s}");
793 }
794
795 #[test]
796 fn ctrl_reply_constructors_are_consistent() {
797 let ok = RemoteCtrlReply::accepted(1920, 1080);
798 assert!(ok.accepted && ok.reason.is_none());
799 assert_eq!((ok.screen_w, ok.screen_h), (Some(1920), Some(1080)));
800
801 let no = RemoteCtrlReply::refused("user declined");
802 assert!(!no.accepted);
803 assert_eq!(no.reason.as_deref(), Some("user declined"));
804 assert_eq!((no.screen_w, no.screen_h), (None, None));
805 }
806
807 #[test]
808 fn input_variants_round_trip() {
809 for i in [
810 RemoteInput::MouseMove { x: 10, y: 20 },
811 RemoteInput::MouseButton {
812 button: MouseButton::Right,
813 down: true,
814 x: 1,
815 y: 2,
816 },
817 RemoteInput::MouseWheel {
818 delta: -120,
819 horizontal: false,
820 },
821 RemoteInput::Key {
822 vk: 0x41,
823 down: true,
824 },
825 RemoteInput::Text {
826 text: "こんにちは".into(),
827 },
828 ] {
829 let s = serde_json::to_string(&i).expect("encode");
830 assert_eq!(serde_json::from_str::<RemoteInput>(&s).expect("decode"), i);
831 }
832 }
833
834 #[test]
835 fn input_text_survives_multibyte() {
836 // The IME case this variant exists for.
837 let i = RemoteInput::Text {
838 text: "日本語入力".into(),
839 };
840 let s = serde_json::to_string(&i).unwrap();
841 assert_eq!(serde_json::from_str::<RemoteInput>(&s).unwrap(), i);
842 }
843
844 #[test]
845 fn tile_encoding_key_round_trips() {
846 for e in [TileEncoding::Jpeg, TileEncoding::Webp] {
847 assert_eq!(TileEncoding::parse(e.as_str()), Some(e));
848 }
849 assert_eq!(TileEncoding::parse("png"), None);
850 }
851
852 #[test]
853 fn max_tile_bytes_derives_from_the_shared_budget() {
854 // Against the constant, never the literal: an earlier draft asserted
855 // `== 256 * 1024`, which passes just as happily when the two
856 // constants have drifted apart — the exact failure the assertion was
857 // supposed to be guarding.
858 assert_eq!(MAX_TILE_BYTES, crate::kv::NATS_PAYLOAD_BUDGET);
859 assert_eq!(MAX_TILE_BYTES, crate::kv::STDOUT_INLINE_THRESHOLD);
860 // Compile-time, not runtime: a budget that outgrew the broker's
861 // publish ceiling should fail the build, not one test run.
862 const { assert!(MAX_TILE_BYTES < crate::kv::NATS_DEFAULT_MAX_PAYLOAD) };
863 }
864}