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 — 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}