diff --git a/apps/nescope/src/input.rs b/apps/nescope/src/input.rs index ac66d84e..ae02abff 100644 --- a/apps/nescope/src/input.rs +++ b/apps/nescope/src/input.rs @@ -340,6 +340,8 @@ pub fn decode_wire_event(data: &[u8]) -> Option { } // Not input: the IPC source hands it to the output before this. DecodedInput::OutputSize { .. } => None, + // Box-internal window messages; nescope does not act on them yet. + DecodedInput::WindowOutputSize { .. } | DecodedInput::WindowFocus { .. } => None, } } diff --git a/crates/nesprotocol/src/datagram.rs b/crates/nesprotocol/src/datagram.rs index bac7523f..4f6669a7 100644 --- a/crates/nesprotocol/src/datagram.rs +++ b/crates/nesprotocol/src/datagram.rs @@ -41,8 +41,11 @@ /// kind(1) + frame seq(2) + fragment index(2) + fragment count(2) + sent_us(8). pub const DGRAM_HDR_LEN: usize = 15; -/// Datagram kinds. These share the numbering of the stream types they replace, -/// so a frame's identity does not change with the transport under it. +/// Datagram kinds, a numbering of their own. Video and audio happen to share +/// the numbers of the stream types they replace; later kinds +/// ([`DGRAM_FEEDBACK`], [`crate::window::DGRAM_VIDEO_WINDOW`]) do not follow +/// the stream numbering, and a kind and a stream type with the same value are +/// unrelated. pub const DGRAM_VIDEO: u8 = 0; pub const DGRAM_AUDIO: u8 = 1; /// PyroWave, whose datagrams have a header of their own: see diff --git a/crates/nesprotocol/src/input.rs b/crates/nesprotocol/src/input.rs index b66bc4e8..6b6e71f5 100644 --- a/crates/nesprotocol/src/input.rs +++ b/crates/nesprotocol/src/input.rs @@ -9,6 +9,16 @@ pub const INPUT_MOUSE_WHEEL: u8 = 3; /// Not input, but it rides the input path because that is the one road that /// already reaches the compositor. pub const INPUT_OUTPUT_SIZE: u8 = 4; +/// `[5][window u16][width u16][height u16]`: one window's output is now this +/// size. Box-internal (hub → nescope): the hub turns a client's +/// [`INPUT_OUTPUT_SIZE`] into this for the window the client is subscribed to, +/// and still sends the old form for window 0. A client never sends it. +/// See [`crate::window`]. +pub const INPUT_WINDOW_OUTPUT_SIZE: u8 = 5; +/// `[6][window u16]`: the events after this, until the next one, came from a +/// client showing this window. Box-internal (hub → nescope); see +/// [`crate::window`]. +pub const INPUT_WINDOW_FOCUS: u8 = 6; pub const INPUT_KEY_UP: u8 = 0; pub const INPUT_KEY_DOWN: u8 = 1; @@ -115,6 +125,8 @@ pub enum DecodedInput { MouseButton { button: u8, down: bool }, MouseWheel { dx: i16, dy: i16 }, OutputSize { width: u16, height: u16 }, + WindowOutputSize { window: u16, width: u16, height: u16 }, + WindowFocus { window: u16 }, } /// Decode a single input event from raw wire bytes. @@ -125,6 +137,8 @@ pub enum DecodedInput { /// - type=2 INPUT_MOUSE_BUTTON: payload=[button:1B][down/up:1B] (0=left, 1=middle, 2=right) /// - type=3 INPUT_MOUSE_WHEEL: payload=[dx:2B LE][dy:2B LE] /// - type=4 INPUT_OUTPUT_SIZE: payload=[width:2B LE][height:2B LE] +/// - type=5 INPUT_WINDOW_OUTPUT_SIZE: payload=[window:2B LE][width:2B LE][height:2B LE] +/// - type=6 INPUT_WINDOW_FOCUS: payload=[window:2B LE] /// /// Returns `None` if the buffer is too short for the given event type or the /// type byte is unknown. @@ -177,6 +191,21 @@ pub fn decode_input_event(data: &[u8]) -> Option { let height = u16::from_le_bytes([data[3], data[4]]); Some(DecodedInput::OutputSize { width, height }) } + INPUT_WINDOW_OUTPUT_SIZE => { + if data.len() < 7 { + return None; + } + let window = u16::from_le_bytes([data[1], data[2]]); + let width = u16::from_le_bytes([data[3], data[4]]); + let height = u16::from_le_bytes([data[5], data[6]]); + Some(DecodedInput::WindowOutputSize { window, width, height }) + } + INPUT_WINDOW_FOCUS => { + if data.len() < 3 { + return None; + } + Some(DecodedInput::WindowFocus { window: u16::from_le_bytes([data[1], data[2]]) }) + } _ => None, } } @@ -219,6 +248,21 @@ pub fn encode_output_size(buf: &mut Vec, width: u16, height: u16) { buf.extend_from_slice(&height.to_le_bytes()); } +/// Encode a window's output size (hub → nescope only): `[5][window][w][h]`. +pub fn encode_window_output_size(buf: &mut Vec, window: u16, width: u16, height: u16) { + buf.push(INPUT_WINDOW_OUTPUT_SIZE); + buf.extend_from_slice(&window.to_le_bytes()); + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); +} + +/// Encode a window tag for the events that follow (hub → nescope only): +/// `[6][window]`. +pub fn encode_window_focus(buf: &mut Vec, window: u16) { + buf.push(INPUT_WINDOW_FOCUS); + buf.extend_from_slice(&window.to_le_bytes()); +} + /// Mapping from `KeyboardEvent.code` strings to Linux input keycodes. /// Covers all essential gaming keys. Use `keymap_lookup(code)` to resolve. pub fn keymap_lookup(code: &str) -> Option { diff --git a/crates/nesprotocol/src/lib.rs b/crates/nesprotocol/src/lib.rs index 95023b53..5ec0f0aa 100644 --- a/crates/nesprotocol/src/lib.rs +++ b/crates/nesprotocol/src/lib.rs @@ -13,6 +13,7 @@ pub mod lifecycle; pub mod pyrowave; pub mod reliable; pub mod stats; +pub mod window; /// One connection per kind of traffic, not one connection for everything. /// diff --git a/crates/nesprotocol/src/window.rs b/crates/nesprotocol/src/window.rs new file mode 100644 index 00000000..c239c4ee --- /dev/null +++ b/crates/nesprotocol/src/window.rs @@ -0,0 +1,734 @@ +//! Windows: one box, several pictures, ref(d-0085). +//! +//! A box runs a person's launches, and every window a launch opens is a video +//! stream of its own. A client connection shows one window: nesktop runs one +//! process per local window, each with its own connections to the hub, each +//! subscribed to one remote window. +//! +//! # Window ids +//! +//! A [`WindowId`] is a `u16`, minted by the box and never reused within one run +//! of it. Two bytes because it rides on every video datagram, next to a frame +//! sequence number that is also two bytes; 65,535 windows in one run of a box +//! is not a limit a person reaches by opening games. Window `0` is *the* window: +//! the first one, and the only one any component knew about before windows had +//! ids. Everything about window `0` is encoded exactly as it was before this +//! module existed, which is the whole compatibility story: +//! +//! - **Old client, new hub.** An old client never sends [`MSG_WINDOW_SUBSCRIBE`], +//! so the hub treats it as subscribed to window `0`, sends it only window +//! `0`'s frames, and those are byte-identical to what it always received. It +//! is never sent a [`STREAM_WINDOWS`] stream either: the hub opens one only +//! for a client that has subscribed. +//! - **New client, old hub.** The old hub logs [`MSG_WINDOW_SUBSCRIBE`] as an +//! unknown control message and carries on, sends window `0` in the old +//! encoding, and never opens a windows stream. A new client therefore treats +//! "no windows stream" as "a single-window box" and shows what arrives. +//! +//! # Video +//! +//! Frames of window `0` keep [`DGRAM_VIDEO`] and [`MSG_DATA`]. Any other window +//! uses a sibling kind that says "a window id follows": +//! +//! ```text +//! datagram, window 0: [0x00 DGRAM_VIDEO] [2B seq][2B index][2B total][8B sent_us] [fragment] +//! datagram, window n: [0x04 DGRAM_VIDEO_WINDOW] [2B seq][2B index][2B total][8B sent_us][2B window] [fragment] +//! +//! frame body, window 0: [0x00 MSG_DATA] [2B seq] [video payload] +//! frame body, window n: [0x01 MSG_WINDOW_DATA] [2B seq][2B window] [video payload] +//! ``` +//! +//! All integers little-endian. The window id is in both, for the same reason the +//! sequence number is: reassembly needs it before the body exists, and the +//! keyframe stream (see [`reliable`](crate::reliable)) carries a body with no +//! datagram header around it. Sequence numbers count per window. +//! +//! # Window lifecycle (box → hub → client) +//! +//! On a uni stream of its own, `[1B STREAM_WINDOWS][1B STREAM_VERSION]` then +//! length-prefixed frames ([`encode_frame`](crate::encode_frame)), whose type +//! byte is one of the `MSG_WINDOW_*` below and whose seq is unused (0). The hub +//! opens it on the video connection of a client that has subscribed, and starts +//! it with an [`WindowEvent::Opened`] for every window already open, so a +//! client that arrives late learns the box as it stands. Every payload starts +//! with the window id: +//! +//! ```text +//! OPENED 0x20: [2B window][2B width][2B height][2B len][launch id][2B len][title][2B len][app id] +//! TITLE 0x21: [2B window][2B len][title] +//! ICON 0x22: [2B window][2B width][2B height][4B rgba len][rgba, width*height*4] +//! SIZE 0x23: [2B window][2B width][2B height] +//! CLOSED 0x24: [2B window] +//! ``` +//! +//! Strings are UTF-8. The launch id is the `lifecycle::LaunchId` the caller minted, so a +//! client knows which launch a window belongs to; the app id is the Wayland +//! `app_id` (X11 `WM_CLASS` under Xwayland). The icon is straight RGBA, the way +//! [`CURSOR_IMAGE`](crate::input::CURSOR_IMAGE) carries a cursor. +//! +//! # What carries no window id +//! +//! - **PyroWave** has a datagram header of its own (see +//! [`pyrowave`](crate::pyrowave)) and no windowed form: for now it streams +//! window `0` only. A windowed PyroWave format is future work. +//! - **The keyframe stream** carries the window id only in the body. A client +//! drops any body, from a datagram or a keyframe stream, whose window id is +//! not its subscription. +//! - **Feedback, receiver reports and keyframe requests** ([`DGRAM_FEEDBACK`], +//! [`MSG_RECEIVER_REPORT`], [`MSG_IDR_REQUEST`]) always mean the +//! connection's subscribed window. A resubscribe resets the connection's +//! reassembly, loss and rate state: none of it describes the new window. +//! +//! [`DGRAM_FEEDBACK`]: crate::datagram::DGRAM_FEEDBACK +//! [`MSG_RECEIVER_REPORT`]: crate::MSG_RECEIVER_REPORT +//! [`MSG_IDR_REQUEST`]: crate::MSG_IDR_REQUEST +//! +//! # Fragmenting +//! +//! A windowed datagram's header is two bytes longer, so a sender sizes +//! fragments with [`max_fragment_payload`] for the window it is sending, never +//! from `DGRAM_HDR_LEN` alone, or the datagram outgrows the path. +//! +//! # Lifetime of ids and subscriptions +//! +//! Ids do not wrap within a run of a box: a box that has minted 65,535 window +//! ids refuses to open more. When the box restarts its ids mean nothing, and +//! the hub tears down every subscription. +//! +//! # Forward compatibility +//! +//! Every decoder here accepts trailing bytes after the fields it knows, so a +//! later version may append fields to any message. +//! +//! # Subscribing (client → hub) +//! +//! [`MSG_WINDOW_SUBSCRIBE`] `[2B window]` on the control stream +//! ([`BIDI_CONTROL`](crate::BIDI_CONTROL)). A connection that never sends it is +//! subscribed to window `0`. Sending it again moves the subscription. Order on +//! a resubscribe: the hub switches the connection to the new window first, then +//! requests a keyframe for it, since nothing the client holds predicts it; a +//! keyframe requested before the switch could arrive for the old window. +//! +//! # Input +//! +//! Input events stay untagged on the wire from the client: a connection's +//! input belongs to the window that client is subscribed to, and the hub knows +//! which that is. Toward the box the hub tags it, by sending +//! [`INPUT_WINDOW_FOCUS`](crate::input::INPUT_WINDOW_FOCUS) `[0x06][2B window]` +//! in the input stream before any event that comes from a different window than +//! the last one it forwarded. Every event after it, until the next, belongs to +//! that window. Three rules follow: +//! +//! - Nothing that merges events (mouse motion coalescing, batching) may merge +//! across a focus tag: the events on either side belong to different windows. +//! - Gamepad messages go to the focused window. +//! - nescope resets its current window to `0` when its input IPC reconnects, +//! so a hub that predates tags is still understood. +//! +//! A client's [`INPUT_OUTPUT_SIZE`](crate::input::INPUT_OUTPUT_SIZE) is +//! rewritten the same way: the hub sends the box +//! [`INPUT_WINDOW_OUTPUT_SIZE`](crate::input::INPUT_WINDOW_OUTPUT_SIZE) +//! `[0x05][2B window][2B width][2B height]`. For window `0` it sends the old +//! `INPUT_OUTPUT_SIZE` instead, so a compositor that predates windows keeps +//! resizing its one output. A client never sends the windowed form. +//! +//! # Cursor +//! +//! No change. The cursor stream goes to a connection and describes the pointer +//! as that connection's window sees it, in that window's coordinates; a +//! client shows one window, so it has one cursor. The hub forwards a cursor +//! update to the clients subscribed to the window it came from, which is the +//! box's business to say on its internal path, not the client's to decode. + +use crate::datagram::{decode_datagram, DatagramHeader, DGRAM_HDR_LEN, DGRAM_VIDEO}; +use crate::MSG_DATA; + +/// Names one window for one run of a box. See the module docs. +pub type WindowId = u16; + +/// The window every component knew about before windows had ids. +pub const WINDOW_DEFAULT: WindowId = 0; + +/// A video datagram of a window other than `0`: [`DGRAM_VIDEO`] with a window +/// id after the common header. +pub const DGRAM_VIDEO_WINDOW: u8 = 4; + +/// Header length of a [`DGRAM_VIDEO_WINDOW`] datagram. +pub const DGRAM_WINDOW_HDR_LEN: usize = DGRAM_HDR_LEN + 2; + +/// A video frame body of a window other than `0`: [`MSG_DATA`] with a window +/// id before the payload. +pub const MSG_WINDOW_DATA: u8 = 0x01; + +/// Which window this connection shows (client → hub, control stream). +pub const MSG_WINDOW_SUBSCRIBE: u8 = 0x17; + +/// Uni stream (hub → client) carrying the `MSG_WINDOW_*` lifecycle messages. +pub const STREAM_WINDOWS: u8 = 8; + +pub const MSG_WINDOW_OPENED: u8 = 0x20; +pub const MSG_WINDOW_TITLE: u8 = 0x21; +pub const MSG_WINDOW_ICON: u8 = 0x22; +pub const MSG_WINDOW_SIZE: u8 = 0x23; +pub const MSG_WINDOW_CLOSED: u8 = 0x24; + +/// Largest icon accepted, per side. A window icon is a few dozen pixels; this +/// bounds the allocation a corrupt length could ask for. +pub const MAX_ICON_SIDE: u16 = 512; + +/// The most fragment bytes a video datagram of `window` can carry when the +/// path takes datagrams of at most `max_dgram` bytes. Zero if not even the +/// header fits. +pub fn max_fragment_payload(window: WindowId, max_dgram: usize) -> usize { + max_dgram.saturating_sub(video_datagram_header_len(window)) +} + +/// How long the header of a video datagram of `window` is. +pub fn video_datagram_header_len(window: WindowId) -> usize { + if window == WINDOW_DEFAULT { + DGRAM_HDR_LEN + } else { + DGRAM_WINDOW_HDR_LEN + } +} + +/// Write a video datagram header for `window` into the start of `buf`, and +/// return its length. `header.kind` is ignored: the kind follows the window. +/// Window `0` writes exactly what +/// [`write_datagram_header`](crate::datagram::write_datagram_header) writes for +/// [`DGRAM_VIDEO`]. +/// +/// The header is two bytes longer for any other window: size the fragment +/// after it with [`max_fragment_payload`], not from `DGRAM_HDR_LEN`. +/// +/// # Panics +/// If `buf` is shorter than [`video_datagram_header_len`]. +pub fn write_video_datagram_header( + buf: &mut [u8], + header: &DatagramHeader, + window: WindowId, +) -> usize { + let kind = if window == WINDOW_DEFAULT { + DGRAM_VIDEO + } else { + DGRAM_VIDEO_WINDOW + }; + crate::datagram::write_datagram_header(buf, &DatagramHeader { kind, ..*header }); + if window == WINDOW_DEFAULT { + DGRAM_HDR_LEN + } else { + buf[DGRAM_HDR_LEN..DGRAM_WINDOW_HDR_LEN].copy_from_slice(&window.to_le_bytes()); + DGRAM_WINDOW_HDR_LEN + } +} + +/// Split a video datagram of any window into header, window and fragment. +/// +/// Accepts [`DGRAM_VIDEO`] (window `0`) and [`DGRAM_VIDEO_WINDOW`], and reports +/// both with `kind == DGRAM_VIDEO` so a receiver has one kind to handle. `None` +/// for any other kind, and for anything [`decode_datagram`] rejects. +pub fn decode_video_datagram(buf: &[u8]) -> Option<(DatagramHeader, WindowId, &[u8])> { + let (mut header, rest) = decode_datagram(buf)?; + match header.kind { + DGRAM_VIDEO => Some((header, WINDOW_DEFAULT, rest)), + DGRAM_VIDEO_WINDOW => { + if rest.len() < 2 { + return None; + } + header.kind = DGRAM_VIDEO; + Some((header, u16::from_le_bytes([rest[0], rest[1]]), &rest[2..])) + } + _ => None, + } +} + +/// Build a video frame body for `window`. Window `0` is exactly +/// [`encode_frame_body`](crate::encode_frame_body) with [`MSG_DATA`]. +pub fn encode_video_frame_body(buf: &mut Vec, window: WindowId, seq: u16, payload: &[u8]) { + if window == WINDOW_DEFAULT { + crate::encode_frame_body(buf, MSG_DATA, seq, payload); + } else { + buf.reserve(5 + payload.len()); + buf.push(MSG_WINDOW_DATA); + buf.extend_from_slice(&seq.to_le_bytes()); + buf.extend_from_slice(&window.to_le_bytes()); + buf.extend_from_slice(payload); + } +} + +/// Parse a video frame body of any window: `(window, seq, payload)`. `None` for +/// a body of another type or one too short to hold its header. +pub fn decode_video_frame_body(body: &[u8]) -> Option<(WindowId, u16, &[u8])> { + let (msg_type, seq, rest) = crate::decode_frame(body)?; + match msg_type { + MSG_DATA => Some((WINDOW_DEFAULT, seq, rest)), + MSG_WINDOW_DATA if rest.len() >= 2 => { + Some((u16::from_le_bytes([rest[0], rest[1]]), seq, &rest[2..])) + } + _ => None, + } +} + +/// `[2B window]`, the payload of [`MSG_WINDOW_SUBSCRIBE`]. +pub fn encode_subscribe(buf: &mut Vec, window: WindowId) { + buf.extend_from_slice(&window.to_le_bytes()); +} + +pub fn decode_subscribe(payload: &[u8]) -> Option { + Some(u16::from_le_bytes(payload.get(..2)?.try_into().ok()?)) +} + +/// What the box says about a window. See the module docs for the bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowEvent { + Opened { + window: WindowId, + width: u16, + height: u16, + launch_id: String, + title: String, + app_id: String, + }, + Title { + window: WindowId, + title: String, + }, + Icon { + window: WindowId, + width: u16, + height: u16, + /// `width * height * 4` bytes, row-major, straight (not premultiplied) + /// alpha. + rgba: Vec, + }, + Size { + window: WindowId, + width: u16, + height: u16, + }, + Closed { + window: WindowId, + }, +} + +impl WindowEvent { + pub fn window(&self) -> WindowId { + match self { + WindowEvent::Opened { window, .. } + | WindowEvent::Title { window, .. } + | WindowEvent::Icon { window, .. } + | WindowEvent::Size { window, .. } + | WindowEvent::Closed { window } => *window, + } + } + + /// The frame type byte this event travels under. + pub fn msg_type(&self) -> u8 { + match self { + WindowEvent::Opened { .. } => MSG_WINDOW_OPENED, + WindowEvent::Title { .. } => MSG_WINDOW_TITLE, + WindowEvent::Icon { .. } => MSG_WINDOW_ICON, + WindowEvent::Size { .. } => MSG_WINDOW_SIZE, + WindowEvent::Closed { .. } => MSG_WINDOW_CLOSED, + } + } + + /// Append the payload (no type byte). Strings longer than 65,535 bytes are + /// cut at a character boundary; an icon must be `width * height * 4` bytes + /// and no wider than [`MAX_ICON_SIDE`], or the receiver drops it. + pub fn encode_payload(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.window().to_le_bytes()); + match self { + WindowEvent::Opened { + width, + height, + launch_id, + title, + app_id, + .. + } => { + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); + put_str(buf, launch_id); + put_str(buf, title); + put_str(buf, app_id); + } + WindowEvent::Title { title, .. } => put_str(buf, title), + WindowEvent::Icon { + width, + height, + rgba, + .. + } => { + debug_assert!(*width <= MAX_ICON_SIDE && *height <= MAX_ICON_SIDE); + debug_assert_eq!(rgba.len(), *width as usize * *height as usize * 4); + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); + buf.extend_from_slice(&(rgba.len() as u32).to_le_bytes()); + buf.extend_from_slice(rgba); + } + WindowEvent::Size { width, height, .. } => { + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); + } + WindowEvent::Closed { .. } => {} + } + } + + /// Append a whole length-prefixed frame, as written on [`STREAM_WINDOWS`]. + pub fn encode_frame(&self, buf: &mut Vec) { + let mut payload = Vec::new(); + self.encode_payload(&mut payload); + crate::encode_frame(buf, self.msg_type(), 0, &payload); + } + + /// Parse a payload of the given frame type. `None` for another type, a + /// truncated payload, a string that is not UTF-8, or an icon whose size + /// does not match its pixels. + pub fn decode(msg_type: u8, payload: &[u8]) -> Option { + let mut r = Reader(payload); + let window = r.u16()?; + Some(match msg_type { + MSG_WINDOW_OPENED => WindowEvent::Opened { + window, + width: r.u16()?, + height: r.u16()?, + launch_id: r.str()?, + title: r.str()?, + app_id: r.str()?, + }, + MSG_WINDOW_TITLE => WindowEvent::Title { + window, + title: r.str()?, + }, + MSG_WINDOW_ICON => { + let width = r.u16()?; + let height = r.u16()?; + let len = r.u32()? as usize; + if width > MAX_ICON_SIDE + || height > MAX_ICON_SIDE + || len != width as usize * height as usize * 4 + { + return None; + } + WindowEvent::Icon { + window, + width, + height, + rgba: r.take(len)?.to_vec(), + } + } + MSG_WINDOW_SIZE => WindowEvent::Size { + window, + width: r.u16()?, + height: r.u16()?, + }, + MSG_WINDOW_CLOSED => WindowEvent::Closed { window }, + _ => return None, + }) + } +} + +fn put_str(buf: &mut Vec, s: &str) { + let mut end = s.len().min(u16::MAX as usize); + while !s.is_char_boundary(end) { + end -= 1; + } + buf.extend_from_slice(&(end as u16).to_le_bytes()); + buf.extend_from_slice(&s.as_bytes()[..end]); +} + +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + if self.0.len() < n { + return None; + } + let (head, tail) = self.0.split_at(n); + self.0 = tail; + Some(head) + } + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?)) + } + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + fn str(&mut self) -> Option { + let n = self.u16()? as usize; + String::from_utf8(self.take(n)?.to_vec()).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::datagram::write_datagram_header; + + fn header() -> DatagramHeader { + DatagramHeader { + kind: DGRAM_VIDEO, + seq: 0xBEEF, + index: 2, + total: 7, + sent_us: 0x0123_4567_89AB_CDEF, + } + } + + #[test] + fn window_zero_datagram_is_the_old_bytes() { + let mut old = [0u8; DGRAM_HDR_LEN]; + write_datagram_header(&mut old, &header()); + let mut new = [0xAAu8; DGRAM_WINDOW_HDR_LEN]; + let n = write_video_datagram_header(&mut new, &header(), WINDOW_DEFAULT); + assert_eq!(n, DGRAM_HDR_LEN); + assert_eq!(video_datagram_header_len(0), DGRAM_HDR_LEN); + assert_eq!(&new[..n], &old[..]); + } + + #[test] + fn video_datagram_round_trips_for_any_window() { + for window in [0u16, 1, 0x1234, u16::MAX] { + let mut buf = vec![0u8; video_datagram_header_len(window)]; + let n = write_video_datagram_header(&mut buf, &header(), window); + assert_eq!(n, buf.len()); + buf.extend_from_slice(&[9, 8, 7]); + let (h, w, frag) = decode_video_datagram(&buf).expect("decodes"); + assert_eq!(h, header()); + assert_eq!(w, window); + assert_eq!(frag, &[9, 8, 7]); + } + } + + #[test] + fn windowed_datagram_is_unknown_to_old_kind_checks() { + let mut buf = [0u8; DGRAM_WINDOW_HDR_LEN]; + write_video_datagram_header(&mut buf, &header(), 3); + assert_eq!(buf[0], DGRAM_VIDEO_WINDOW); + assert_ne!(buf[0], DGRAM_VIDEO); + assert_ne!(buf[0], crate::datagram::DGRAM_AUDIO); + assert_ne!(buf[0], crate::datagram::DGRAM_PYROWAVE); + assert_ne!(buf[0], crate::datagram::DGRAM_FEEDBACK); + } + + #[test] + fn truncated_video_datagrams_are_rejected() { + let mut buf = [0u8; DGRAM_WINDOW_HDR_LEN]; + write_video_datagram_header(&mut buf, &header(), 3); + assert!(decode_video_datagram(&buf[..DGRAM_HDR_LEN + 1]).is_none()); + assert!(decode_video_datagram(&buf[..DGRAM_HDR_LEN - 1]).is_none()); + // Audio is not video. + let mut audio = [0u8; DGRAM_HDR_LEN]; + write_datagram_header( + &mut audio, + &DatagramHeader { + kind: crate::datagram::DGRAM_AUDIO, + ..header() + }, + ); + assert!(decode_video_datagram(&audio).is_none()); + } + + #[test] + fn window_zero_body_is_the_old_bytes() { + let mut old = Vec::new(); + crate::encode_frame_body(&mut old, MSG_DATA, 42, &[1, 2, 3]); + let mut new = Vec::new(); + encode_video_frame_body(&mut new, 0, 42, &[1, 2, 3]); + assert_eq!(new, old); + } + + #[test] + fn video_body_round_trips_for_any_window() { + for window in [0u16, 7, u16::MAX] { + let mut body = Vec::new(); + encode_video_frame_body(&mut body, window, 42, &[1, 2, 3]); + assert_eq!(decode_video_frame_body(&body), Some((window, 42, &[1u8, 2, 3][..]))); + } + let mut body = Vec::new(); + encode_video_frame_body(&mut body, 7, 42, &[]); + assert_eq!(decode_video_frame_body(&body), Some((7, 42, &[][..]))); + assert_eq!(decode_video_frame_body(&body[..4]), None); + assert_eq!(decode_video_frame_body(&body[..2]), None); + assert_eq!(decode_video_frame_body(&[crate::MSG_IDR_REQUEST, 0, 0]), None); + } + + #[test] + fn subscribe_round_trips() { + let mut b = Vec::new(); + encode_subscribe(&mut b, 0x0302); + assert_eq!(b, [0x02, 0x03]); + assert_eq!(decode_subscribe(&b), Some(0x0302)); + assert_eq!(decode_subscribe(&b[..1]), None); + } + + fn events() -> Vec { + vec![ + WindowEvent::Opened { + window: 5, + width: 1920, + height: 1080, + launch_id: "launch-1".into(), + title: "Dome Keeper — ünïcode".into(), + app_id: "steam_app_1637320".into(), + }, + WindowEvent::Title { + window: 5, + title: "Paused".into(), + }, + WindowEvent::Icon { + window: 5, + width: 2, + height: 3, + rgba: (0..24).collect(), + }, + WindowEvent::Size { + window: 5, + width: 1280, + height: 720, + }, + WindowEvent::Closed { window: 5 }, + ] + } + + #[test] + fn window_events_round_trip_and_reject_truncation() { + for ev in events() { + let mut payload = Vec::new(); + ev.encode_payload(&mut payload); + assert_eq!(WindowEvent::decode(ev.msg_type(), &payload), Some(ev.clone())); + for cut in 0..payload.len() { + assert_eq!( + WindowEvent::decode(ev.msg_type(), &payload[..cut]), + None, + "{ev:?} cut at {cut}" + ); + } + + let mut frame = Vec::new(); + ev.encode_frame(&mut frame); + let len = u32::from_le_bytes(frame[..4].try_into().unwrap()) as usize; + assert_eq!(len, frame.len() - 4); + let (t, seq, p) = crate::decode_frame(&frame[4..]).unwrap(); + assert_eq!((t, seq), (ev.msg_type(), 0)); + assert_eq!(WindowEvent::decode(t, p), Some(ev)); + } + } + + #[test] + fn window_event_bytes_are_as_documented() { + let mut p = Vec::new(); + WindowEvent::Size { + window: 0x0102, + width: 0x0304, + height: 0x0506, + } + .encode_payload(&mut p); + assert_eq!(p, [2, 1, 4, 3, 6, 5]); + let mut p = Vec::new(); + WindowEvent::Title { + window: 1, + title: "ab".into(), + } + .encode_payload(&mut p); + assert_eq!(p, [1, 0, 2, 0, b'a', b'b']); + } + + #[test] + fn bad_icons_and_strings_are_rejected() { + // Pixel count disagrees with the size. + let mut p = vec![1, 0, 2, 0, 2, 0, 15, 0, 0, 0]; + p.extend_from_slice(&[0; 15]); + assert_eq!(WindowEvent::decode(MSG_WINDOW_ICON, &p), None); + // Implausibly large. + let mut p = Vec::new(); + p.extend_from_slice(&1u16.to_le_bytes()); + p.extend_from_slice(&(MAX_ICON_SIDE + 1).to_le_bytes()); + p.extend_from_slice(&1u16.to_le_bytes()); + p.extend_from_slice(&((MAX_ICON_SIDE as u32 + 1) * 4).to_le_bytes()); + assert_eq!(WindowEvent::decode(MSG_WINDOW_ICON, &p), None); + // Not UTF-8. + assert_eq!( + WindowEvent::decode(MSG_WINDOW_TITLE, &[1, 0, 1, 0, 0xFF]), + None + ); + // Unknown type. + assert_eq!(WindowEvent::decode(0x2F, &[1, 0]), None); + } + + #[test] + fn long_strings_are_cut_on_a_char_boundary() { + let title = "é".repeat(40_000); // 80,000 bytes + let mut p = Vec::new(); + WindowEvent::Title { window: 1, title }.encode_payload(&mut p); + match WindowEvent::decode(MSG_WINDOW_TITLE, &p) { + Some(WindowEvent::Title { title, .. }) => { + assert!(title.len() <= u16::MAX as usize); + assert_eq!(title.len() % 2, 0); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn box_internal_input_round_trips() { + use crate::input::*; + let mut b = Vec::new(); + encode_window_output_size(&mut b, 3, 1280, 720); + assert_eq!(b, [5, 3, 0, 0x00, 0x05, 0xD0, 0x02]); + match decode_input_event(&b) { + Some(DecodedInput::WindowOutputSize { window: 3, width: 1280, height: 720 }) => {} + other => panic!("{other:?}"), + } + assert!(decode_input_event(&b[..6]).is_none()); + + let mut b = Vec::new(); + encode_window_focus(&mut b, 0x0102); + assert_eq!(b, [6, 2, 1]); + assert!(matches!( + decode_input_event(&b), + Some(DecodedInput::WindowFocus { window: 0x0102 }) + )); + assert!(decode_input_event(&b[..2]).is_none()); + + // The client's own resize event is unchanged. + let mut b = Vec::new(); + encode_output_size(&mut b, 1280, 720); + assert_eq!(b, [4, 0x00, 0x05, 0xD0, 0x02]); + } + + #[test] + fn fragments_leave_room_for_the_window_id() { + assert_eq!(max_fragment_payload(0, 1200), 1200 - DGRAM_HDR_LEN); + assert_eq!(max_fragment_payload(9, 1200), 1200 - DGRAM_WINDOW_HDR_LEN); + assert_eq!(max_fragment_payload(9, 10), 0); + // A full fragment plus its header fits exactly. + let n = max_fragment_payload(9, 1200); + let mut buf = vec![0u8; video_datagram_header_len(9)]; + write_video_datagram_header(&mut buf, &header(), 9); + buf.resize(buf.len() + n, 0); + assert_eq!(buf.len(), 1200); + } + + #[test] + fn decoders_accept_trailing_bytes() { + let mut p = Vec::new(); + WindowEvent::Size { window: 1, width: 2, height: 3 }.encode_payload(&mut p); + p.extend_from_slice(&[0xEE, 0xEE]); + assert_eq!( + WindowEvent::decode(MSG_WINDOW_SIZE, &p), + Some(WindowEvent::Size { window: 1, width: 2, height: 3 }) + ); + assert_eq!(decode_subscribe(&[4, 0, 0xEE]), Some(4)); + } + + #[test] + #[should_panic] + #[cfg(debug_assertions)] + fn encoding_a_bad_icon_is_a_bug() { + let mut p = Vec::new(); + WindowEvent::Icon { window: 1, width: 2, height: 2, rgba: vec![0; 15] } + .encode_payload(&mut p); + } +}