Skip to main content

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. Lets the SPA size its canvas before the
421    /// first tile arrives.
422    #[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    /// Accepted, with the geometry the operator's canvas needs.
430    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    /// Refused, with a human-readable reason.
440    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/// Which mouse button an event refers to.
451#[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/// Backend → agent input event on `remote.input.<session_id>`.
460///
461/// Coordinates are **desktop pixels**, not viewer pixels: the SPA scales a
462/// 3840x1600 desktop into whatever canvas it has, and letting the endpoint
463/// receive viewer-space coordinates would make correctness depend on the
464/// browser window size at the far end of a lossy link.
465#[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        /// True on press, false on release. Sent as separate events rather
475        /// than as clicks so drag, and any modifier held across the
476        /// gesture, survive the trip.
477        down: bool,
478        x: u32,
479        y: u32,
480    },
481    MouseWheel {
482        /// Wheel delta in the platform's native units.
483        delta: i32,
484        /// Horizontal scroll rather than vertical.
485        #[serde(default)]
486        horizontal: bool,
487    },
488    /// A key transition, addressed by Windows virtual-key code.
489    ///
490    /// Deliberately not a character: shortcuts (Ctrl+C, Alt+Tab) are about
491    /// physical keys, and a character-only channel cannot express them.
492    Key {
493        vk: u16,
494        down: bool,
495    },
496    /// Literal text, for IME composition and anything else a virtual-key
497    /// stream cannot express. Complements [`RemoteInput::Key`] rather than
498    /// replacing it — Japanese input is the case that forces this to exist.
499    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        // async-nats has no remove(); rebuild without the one under test.
543        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        // ENCODING deliberately omitted.
561        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; // 3900 > 3840
596        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        // Off-by-one guard: right == screen_w is in bounds, not out.
605        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        // x + w would wrap to a small number without saturating arithmetic,
626        // letting a corrupt header pass validation.
627        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        // A gap has no tile to describe; asking for one must fail cleanly
680        // rather than yield a zero-sized tile a viewer would try to paint.
681        assert!(FrameMeta::from_headers(&h).is_err());
682    }
683
684    #[test]
685    fn a_message_without_a_kind_is_rejected() {
686        // Not defaulted to Tile: a missing kind means the publisher does not
687        // share this contract, and guessing would hand geometry parsing a
688        // message that may have none.
689        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        // Like a gap, it has no tile to describe.
719        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        // The safe default matters: a control message that forgot the field
741        // must not hand over the keyboard.
742        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        // The IME case this variant exists for.
826        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        // Against the constant, never the literal: an earlier draft asserted
844        // `== 256 * 1024`, which passes just as happily when the two
845        // constants have drifted apart — the exact failure the assertion was
846        // supposed to be guarding.
847        assert_eq!(MAX_TILE_BYTES, crate::kv::NATS_PAYLOAD_BUDGET);
848        assert_eq!(MAX_TILE_BYTES, crate::kv::STDOUT_INLINE_THRESHOLD);
849        // Compile-time, not runtime: a budget that outgrew the broker's
850        // publish ceiling should fail the build, not one test run.
851        const { assert!(MAX_TILE_BYTES < crate::kv::NATS_DEFAULT_MAX_PAYLOAD) };
852    }
853}