From 866aaf740e10fcfce169aaf81380ea0257fbe272 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 21 May 2026 20:19:54 +0300 Subject: refactor: separate server client boundaries --- src/protocol/mod.rs | 6 + src/protocol/render_ansi.rs | 1430 ++++++++++++++++++++++++++++++++++++++++ src/protocol/wire.rs | 1513 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 2949 insertions(+) create mode 100644 src/protocol/mod.rs create mode 100644 src/protocol/render_ansi.rs create mode 100644 src/protocol/wire.rs (limited to 'src/protocol') diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 00000000..0cdeecbb --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,6 @@ +//! Shared wire protocol and presentation encoding code. + +pub(crate) mod render_ansi; +mod wire; + +pub use wire::*; diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs new file mode 100644 index 00000000..8caa98d0 --- /dev/null +++ b/src/protocol/render_ansi.rs @@ -0,0 +1,1430 @@ +//! Frame blitting — renders FrameData to the terminal using diff-based updates. +//! +//! The blitting strategy: +//! 1. On the first frame, write the entire buffer (full redraw). +//! 2. On subsequent frames, diff against the last frame and only write +//! the cells that changed. +//! 3. Wrap each frame in synchronized output so terminals that support it do +//! not expose intermediate cursor positions while the frame is painted. +//! 4. Before writing any cells, hide the cursor to avoid stray cursor +//! artifacts on terminals that render the hardware cursor at intermediate +//! `CUP` positions during the frame stream. +//! 5. After writing all changed cells, restore the final cursor visibility +//! and position from `frame.cursor`. +//! 6. After ending synchronized output, repeat the final cursor anchor so +//! external IMEs can place candidate windows at the real input position. +//! +//! Escape sequences used: +//! - `CSI H` (CUP) — move cursor to (row, col) +//! - `CSI m` (SGR) — set graphic rendition (colors, bold, etc.) +//! - `CSI ? 2026 h/l` — begin/end synchronized output +//! - `CSI Ps SP q` — DECSCUSR cursor shape +//! - `ESC ] 52 ; c ; BEL` — OSC 52 clipboard write +//! +//! The goal is minimal output: skip unchanged cells, batch adjacent changes, +//! and minimize cursor movement. + +use std::cmp; +use std::io::Write; + +use unicode_width::UnicodeWidthStr; + +use crate::protocol::{CellData, FrameData}; + +/// Bytes produced by a [`BlitEncoder`] for one terminal frame. +pub(crate) struct EncodedBlit { + /// Terminal escape bytes ready to write to the host terminal. + pub(crate) bytes: Vec, + /// Whether this frame was encoded as a full redraw. + pub(crate) full: bool, + next_last_visible_cursor: Option<(u16, u16)>, + next_last_cursor_shape: u8, +} + +/// Stateful encoder that diffs semantic frames into terminal ANSI bytes. +#[derive(Default)] +pub(crate) struct BlitEncoder { + last_frame: Option, + last_visible_cursor: Option<(u16, u16)>, + last_cursor_shape: u8, +} + +impl BlitEncoder { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn encode(&self, frame: &FrameData, force_full: bool) -> EncodedBlit { + let prev = if force_full { + None + } else { + self.last_frame.as_ref() + }; + let full = force_full + || prev.is_none() + || prev.is_some_and(|p| p.width != frame.width || p.height != frame.height); + let mut bytes = Vec::new(); + let mut next_last_visible_cursor = self.last_visible_cursor; + let mut next_last_cursor_shape = self.last_cursor_shape; + blit_frame_to_with_cursor_memory( + &mut bytes, + frame, + prev, + &mut next_last_visible_cursor, + &mut next_last_cursor_shape, + ); + EncodedBlit { + bytes, + full, + next_last_visible_cursor, + next_last_cursor_shape, + } + } + + pub(crate) fn commit(&mut self, frame: FrameData, encoded: EncodedBlit) { + self.last_visible_cursor = encoded.next_last_visible_cursor; + self.last_cursor_shape = encoded.next_last_cursor_shape; + self.last_frame = Some(frame); + } + + pub(crate) fn is_current(&self, frame: &FrameData) -> bool { + self.last_frame.as_ref() == Some(frame) + } +} + +// --------------------------------------------------------------------------- +// Color → escape sequence +// --------------------------------------------------------------------------- + +/// Converts a packed u32 color to an SGR escape sequence fragment. +/// +/// Returns a string like `38;5;123` (indexed) or `38;2;255;128;64` (RGB) +/// or `39` (reset), without the leading `\x1b[` or trailing `m`. +fn color_to_sgr_fg(val: u32) -> String { + match val >> 24 { + 0x00 => match val & 0xFF { + 0x00 => "39".to_owned(), // Reset + 0x01 => "30".to_owned(), // Black + 0x02 => "31".to_owned(), // Red + 0x03 => "32".to_owned(), // Green + 0x04 => "33".to_owned(), // Yellow + 0x05 => "34".to_owned(), // Blue + 0x06 => "35".to_owned(), // Magenta + 0x07 => "36".to_owned(), // Cyan + 0x08 => "37".to_owned(), // Gray (light gray) + 0x09 => "90".to_owned(), // DarkGray + 0x0A => "91".to_owned(), // LightRed + 0x0B => "92".to_owned(), // LightGreen + 0x0C => "93".to_owned(), // LightYellow + 0x0D => "94".to_owned(), // LightBlue + 0x0E => "95".to_owned(), // LightMagenta + 0x0F => "96".to_owned(), // LightCyan + 0x10 => "97".to_owned(), // White + _ => "39".to_owned(), // Unknown → Reset + }, + 0x01 => format!("38;5;{}", val & 0xFF), // Indexed + 0x02 => { + // RGB + let r = (val >> 16) & 0xFF; + let g = (val >> 8) & 0xFF; + let b = val & 0xFF; + format!("38;2;{r};{g};{b}") + } + _ => "39".to_owned(), // Unknown → Reset + } +} + +/// Converts a packed u32 color to a background SGR fragment. +fn color_to_sgr_bg(val: u32) -> String { + match val >> 24 { + 0x00 => match val & 0xFF { + 0x00 => "49".to_owned(), // Reset + 0x01 => "40".to_owned(), // Black + 0x02 => "41".to_owned(), // Red + 0x03 => "42".to_owned(), // Green + 0x04 => "43".to_owned(), // Yellow + 0x05 => "44".to_owned(), // Blue + 0x06 => "45".to_owned(), // Magenta + 0x07 => "46".to_owned(), // Cyan + 0x08 => "47".to_owned(), // Gray (light gray) + 0x09 => "100".to_owned(), // DarkGray + 0x0A => "101".to_owned(), // LightRed + 0x0B => "102".to_owned(), // LightGreen + 0x0C => "103".to_owned(), // LightYellow + 0x0D => "104".to_owned(), // LightBlue + 0x0E => "105".to_owned(), // LightMagenta + 0x0F => "106".to_owned(), // LightCyan + 0x10 => "107".to_owned(), // White + _ => "49".to_owned(), // Unknown → Reset + }, + 0x01 => format!("48;5;{}", val & 0xFF), // Indexed + 0x02 => { + let r = (val >> 16) & 0xFF; + let g = (val >> 8) & 0xFF; + let b = val & 0xFF; + format!("48;2;{r};{g};{b}") + } + _ => "49".to_owned(), + } +} + +// --------------------------------------------------------------------------- +// Modifier → SGR +// --------------------------------------------------------------------------- + +/// Converts a u16 modifier bitmask to SGR escape sequence fragments. +/// +/// Returns a Vec of SGR parameter strings (e.g., "1" for bold, "3" for italic). +fn modifier_to_sgr_parts(val: u16) -> Vec<&'static str> { + let mut parts = Vec::new(); + + // ratatui::Modifier bits (from bitflags) + const BOLD: u16 = 1 << 0; // 0x01 + const DIM: u16 = 1 << 1; // 0x02 + const ITALIC: u16 = 1 << 2; // 0x04 + const UNDERLINED: u16 = 1 << 3; // 0x08 + const SLOW_BLINK: u16 = 1 << 4; // 0x10 + const RAPID_BLINK: u16 = 1 << 5; // 0x20 + const REVERSED: u16 = 1 << 6; // 0x40 + const HIDDEN: u16 = 1 << 7; // 0x80 + const CROSSED_OUT: u16 = 1 << 8; // 0x100 + + if val & BOLD != 0 { + parts.push("1"); + } + if val & DIM != 0 { + parts.push("2"); + } + if val & ITALIC != 0 { + parts.push("3"); + } + if val & UNDERLINED != 0 { + parts.push("4"); + } + if val & SLOW_BLINK != 0 { + parts.push("5"); + } + if val & RAPID_BLINK != 0 { + parts.push("6"); + } + if val & REVERSED != 0 { + parts.push("7"); + } + if val & HIDDEN != 0 { + parts.push("8"); + } + if val & CROSSED_OUT != 0 { + parts.push("9"); + } + + parts +} + +/// Builds a complete SGR escape sequence for a cell's style. +fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String { + let mut parts = vec!["0".to_owned()]; + parts.extend( + modifier_to_sgr_parts(modifier) + .into_iter() + .map(str::to_owned), + ); + parts.push(color_to_sgr_fg(fg)); + parts.push(color_to_sgr_bg(bg)); + format!("\x1b[{}m", parts.join(";")) +} + +// --------------------------------------------------------------------------- +// Cell comparison +// --------------------------------------------------------------------------- + +/// Checks if two cells are visually identical. +#[cfg(test)] +fn cells_equal(a: &CellData, b: &CellData) -> bool { + a.symbol == b.symbol + && a.fg == b.fg + && a.bg == b.bg + && a.modifier == b.modifier + && a.hyperlink == b.hyperlink + // Skip flag is only for ratatui internal use, not visual. +} + +// --------------------------------------------------------------------------- +// Blitting +// --------------------------------------------------------------------------- + +/// Blits a frame to a writer, diffing against the previous frame. +#[cfg(test)] +fn blit_frame_to(writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) { + let mut last_visible_cursor = None; + let mut last_cursor_shape = 0; + blit_frame_to_with_cursor_memory( + writer, + frame, + prev, + &mut last_visible_cursor, + &mut last_cursor_shape, + ); +} + +fn blit_frame_to_with_cursor_memory( + mut writer: impl Write, + frame: &FrameData, + prev: Option<&FrameData>, + last_visible_cursor: &mut Option<(u16, u16)>, + last_cursor_shape: &mut u8, +) { + // On first frame or size change, do a full redraw. + let full_redraw = + prev.is_none() || prev.is_some_and(|p| p.width != frame.width || p.height != frame.height); + + // Ask terminals that support synchronized output to apply the whole frame + // atomically. This keeps IMEs and cursor trackers from observing the + // intermediate CUP positions used while painting changed cells. + let _ = writer.write_all(b"\x1b[?2026h"); + + // Hide cursor before any cell writes to avoid stray cursor artifacts + // on terminals that render the hardware cursor at intermediate CUP positions. + let _ = writer.write_all(b"\x1b[?25l"); + + // Start each frame from a known OSC 8 state. If a previous write was + // interrupted or the outer terminal had an active hyperlink, unlinked cells + // must not inherit it. + let _ = writer.write_all(b"\x1b]8;;\x1b\\"); + + if full_redraw { + // Clear the screen and write all cells. + let _ = writer.write_all(b"\x1b[2J\x1b[H"); + write_all_cells(&mut writer, frame); + } else { + // Diff-based update: only write changed cells. + let prev = prev.unwrap(); + write_changed_cells(&mut writer, frame, prev); + } + + // Position the cursor while it is still hidden, then restore visibility. + // Showing before moving makes slow terminals and IMEs briefly observe the + // cursor at the last painted cell, which can be an animated sidebar/status + // cell rather than the focused pane's input position. When the focused pane + // hides its cursor, still park the host cursor intentionally so IMEs do not + // anchor to whichever cell happened to be painted last. + let host_cursor = resolve_host_cursor_state(frame, last_visible_cursor); + write_host_cursor_state(&mut writer, host_cursor, last_cursor_shape); + + // End the synchronized output block immediately after the final cursor + // state is emitted so supporting terminals can present the frame atomically. + let _ = writer.write_all(b"\x1b[?2026l"); + + // Some native IMEs track candidate-window placement from normal terminal + // cursor updates and may not observe cursor moves emitted inside synchronized + // output. Re-emit only the resolved final cursor anchor after the sync block; + // intermediate paint cursor positions remain hidden and the focused pane's + // requested cursor visibility is preserved. + write_ime_anchor_cursor_state(&mut writer, host_cursor); + let _ = writer.flush(); +} + +/// Writes all cells in the frame (full redraw). +fn cell_width(cell: &CellData) -> usize { + cell.symbol.width() +} + +#[derive(Clone, Copy)] +struct HostCursorState { + position: (u16, u16), + visible: bool, + /// DECSCUSR parameter (0–6). 0 means terminal default. + shape: u8, +} + +fn resolve_host_cursor_state( + frame: &FrameData, + last_visible_cursor: &mut Option<(u16, u16)>, +) -> HostCursorState { + if let Some(cursor) = &frame.cursor { + if cursor.visible { + let position = clamp_cursor_position(frame, cursor.x, cursor.y); + *last_visible_cursor = Some(position); + return HostCursorState { + position, + visible: true, + shape: normalize_cursor_shape(cursor.shape), + }; + } + + let position = clamp_cursor_position(frame, cursor.x, cursor.y); + return HostCursorState { + position, + visible: false, + shape: normalize_cursor_shape(cursor.shape), + }; + } + + let position = (*last_visible_cursor) + .map(|(x, y)| clamp_cursor_position(frame, x, y)) + .unwrap_or_else(|| default_hidden_cursor_position(frame)); + HostCursorState { + position, + visible: false, + shape: 0, + } +} + +fn normalize_cursor_shape(shape: u8) -> u8 { + if shape <= 6 { + shape + } else { + 0 + } +} + +fn default_hidden_cursor_position(frame: &FrameData) -> (u16, u16) { + ( + frame.width.saturating_sub(1), + frame.height.saturating_sub(1), + ) +} + +fn clamp_cursor_position(frame: &FrameData, x: u16, y: u16) -> (u16, u16) { + ( + x.min(frame.width.saturating_sub(1)), + y.min(frame.height.saturating_sub(1)), + ) +} + +fn write_cursor_position(writer: &mut impl Write, (x, y): (u16, u16)) { + // CUP: move cursor to (row+1, col+1) — 1-based. + let _ = write!(writer, "\x1b[{};{}H", y + 1, x + 1); +} + +fn write_host_cursor_state(writer: &mut impl Write, cursor: HostCursorState, last_shape: &mut u8) { + write_cursor_position(writer, cursor.position); + if cursor.shape != *last_shape { + let _ = write!(writer, "\x1b[{} q", cursor.shape); + *last_shape = cursor.shape; + } + if cursor.visible { + // Show cursor only after it is already at the final position. + let _ = writer.write_all(b"\x1b[?25h"); + } else { + let _ = writer.write_all(b"\x1b[?25l"); + } +} + +fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorState) { + write_cursor_position(writer, cursor.position); + if cursor.visible { + let _ = writer.write_all(b"\x1b[?25h"); + } else { + let _ = writer.write_all(b"\x1b[?25l"); + } +} + +fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { + let mut active_hyperlink = None; + for row in 0..frame.height { + let mut to_skip = 0usize; + for col in 0..frame.width { + if to_skip > 0 { + to_skip -= 1; + continue; + } + + let idx = (row as usize) * (frame.width as usize) + (col as usize); + let cell = &frame.cells[idx]; + + if cell.skip { + continue; + } + + // Move cursor to position (1-based). + let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1); + + // Set style. + let sgr = build_sgr(cell.fg, cell.bg, cell.modifier); + let _ = writer.write_all(sgr.as_bytes()); + + write_hyperlink_if_changed( + writer, + &mut active_hyperlink, + cell_hyperlink_uri(frame, cell), + ); + + // Write the symbol. + let _ = writer.write_all(cell.symbol.as_bytes()); + to_skip = cell_width(cell).saturating_sub(1); + } + } + + close_hyperlink(writer, &mut active_hyperlink); + + // Reset style at the end. + let _ = writer.write_all(b"\x1b[0m"); +} + +fn cell_hyperlink_uri<'a>(frame: &'a FrameData, cell: &CellData) -> Option<&'a str> { + let index = cell.hyperlink? as usize; + frame.hyperlinks.get(index).map(String::as_str) +} + +fn sanitized_hyperlink_uri(uri: &str) -> Option { + let sanitized: String = uri + .chars() + .filter(|ch| *ch != '\x1b' && *ch != '\x07' && !ch.is_control()) + .collect(); + (!sanitized.is_empty()).then_some(sanitized) +} + +fn sanitized_frame_hyperlinks(frame: &FrameData) -> Vec> { + frame + .hyperlinks + .iter() + .map(|uri| sanitized_hyperlink_uri(uri)) + .collect() +} + +fn sanitized_cell_hyperlink_uri<'a>( + sanitized_hyperlinks: &'a [Option], + cell: &CellData, +) -> Option<&'a str> { + let index = cell.hyperlink? as usize; + sanitized_hyperlinks.get(index)?.as_deref() +} + +fn write_hyperlink_if_changed( + writer: &mut impl Write, + active: &mut Option, + requested: Option<&str>, +) { + let requested = requested.and_then(sanitized_hyperlink_uri); + if active.as_deref() == requested.as_deref() { + return; + } + + if active.is_some() { + let _ = writer.write_all(b"\x1b]8;;\x1b\\"); + } + *active = requested; + if let Some(uri) = active.as_deref() { + let _ = write!(writer, "\x1b]8;;{uri}\x1b\\"); + } +} + +fn close_hyperlink(writer: &mut impl Write, active: &mut Option) { + if active.take().is_some() { + let _ = writer.write_all(b"\x1b]8;;\x1b\\"); + } +} + +fn write_cell( + writer: &mut impl Write, + row: u16, + col: u16, + cell: &CellData, + last_sgr: &mut String, + active_hyperlink: &mut Option, + frame: &FrameData, +) { + if cell.skip { + return; + } + + let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1); + + let sgr = build_sgr(cell.fg, cell.bg, cell.modifier); + if sgr != *last_sgr { + let _ = writer.write_all(sgr.as_bytes()); + *last_sgr = sgr; + } + + write_hyperlink_if_changed(writer, active_hyperlink, cell_hyperlink_uri(frame, cell)); + let _ = writer.write_all(cell.symbol.as_bytes()); +} + +/// Writes only the cells that changed between the previous and current frame. +fn cells_visually_equal( + sanitized_hyperlinks: &[Option], + cell: &CellData, + prev_sanitized_hyperlinks: &[Option], + prev_cell: &CellData, +) -> bool { + cell.symbol == prev_cell.symbol + && cell.fg == prev_cell.fg + && cell.bg == prev_cell.bg + && cell.modifier == prev_cell.modifier + && sanitized_cell_hyperlink_uri(sanitized_hyperlinks, cell) + == sanitized_cell_hyperlink_uri(prev_sanitized_hyperlinks, prev_cell) + // Skip flag is only for ratatui internal use, not visual. +} + +fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) { + let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes. + let mut active_hyperlink = None; + let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame); + let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev); + + for row in 0..frame.height { + let mut invalidated = 0usize; + let mut to_skip = 0usize; + + for col in 0..frame.width { + let idx = (row as usize) * (frame.width as usize) + (col as usize); + let cell = &frame.cells[idx]; + let prev_cell = &prev.cells[idx]; + + if !cell.skip + && (!cells_visually_equal( + &sanitized_hyperlinks, + cell, + &prev_sanitized_hyperlinks, + prev_cell, + ) || invalidated > 0) + && to_skip == 0 + { + write_cell( + writer, + row, + col, + cell, + &mut last_sgr, + &mut active_hyperlink, + frame, + ); + } + + to_skip = cell_width(cell).saturating_sub(1); + let affected_width = cmp::max(cell_width(cell), cell_width(prev_cell)); + invalidated = cmp::max(affected_width, invalidated).saturating_sub(1); + } + } + + close_hyperlink(writer, &mut active_hyperlink); + + // Reset style if we wrote anything. + if !last_sgr.is_empty() { + let _ = writer.write_all(b"\x1b[0m"); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{CellData, CursorState}; + + const WIDE_GRAPHEME: &str = "💡"; + + fn make_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData { + CellData { + symbol: symbol.to_owned(), + fg, + bg, + modifier, + skip: false, + hyperlink: None, + } + } + + fn make_frame(width: u16, height: u16, cells: Vec) -> FrameData { + FrameData { + cells, + width, + height, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + } + } + + fn linked_cell(symbol: &str, index: u32) -> CellData { + let mut cell = make_cell(symbol, 0, 0, 0); + cell.hyperlink = Some(index); + cell + } + + #[test] + fn color_to_sgr_fg_named_colors() { + assert_eq!(color_to_sgr_fg(0x00_00_00_00), "39"); // Reset + assert_eq!(color_to_sgr_fg(0x00_00_00_01), "30"); // Black + assert_eq!(color_to_sgr_fg(0x00_00_00_02), "31"); // Red + assert_eq!(color_to_sgr_fg(0x00_00_00_10), "97"); // White + } + + #[test] + fn color_to_sgr_fg_indexed() { + assert_eq!(color_to_sgr_fg(0x01_00_00_AB), "38;5;171"); + } + + #[test] + fn color_to_sgr_fg_rgb() { + assert_eq!(color_to_sgr_fg(0x02_FF_80_40), "38;2;255;128;64"); + } + + #[test] + fn color_to_sgr_bg_named_colors() { + assert_eq!(color_to_sgr_bg(0x00_00_00_00), "49"); // Reset + assert_eq!(color_to_sgr_bg(0x00_00_00_01), "40"); // Black + assert_eq!(color_to_sgr_bg(0x00_00_00_10), "107"); // White + } + + #[test] + fn color_to_sgr_bg_rgb() { + assert_eq!(color_to_sgr_bg(0x02_FF_80_40), "48;2;255;128;64"); + } + + #[test] + fn modifier_to_sgr_parts_bold() { + let parts = modifier_to_sgr_parts(1); // BOLD + assert!(parts.contains(&"1")); + } + + #[test] + fn modifier_to_sgr_parts_italic() { + let parts = modifier_to_sgr_parts(4); // ITALIC + assert!(parts.contains(&"3")); + } + + #[test] + fn modifier_to_sgr_parts_empty() { + let parts = modifier_to_sgr_parts(0); + assert!(parts.is_empty()); + } + + #[test] + fn build_sgr_produces_valid_sequence() { + let sgr = build_sgr(0x00_00_00_02, 0x00_00_00_01, 1); // fg=Red, bg=Black, bold + assert!(sgr.starts_with("\x1b[")); + assert!(sgr.ends_with("m")); + assert!(sgr.contains("0")); // reset existing style first + assert!(sgr.contains("1")); // bold + assert!(sgr.contains("31")); // fg red + assert!(sgr.contains("40")); // bg black + } + + #[test] + fn build_sgr_resets_previous_modifiers_when_cell_is_plain() { + assert_eq!(build_sgr(0x00_00_00_00, 0x00_00_00_00, 0), "\x1b[0;39;49m"); + } + + #[test] + fn cells_equal_identical() { + let a = make_cell("A", 2, 1, 0); + let b = make_cell("A", 2, 1, 0); + assert!(cells_equal(&a, &b)); + } + + #[test] + fn cells_equal_different_symbol() { + let a = make_cell("A", 2, 1, 0); + let b = make_cell("B", 2, 1, 0); + assert!(!cells_equal(&a, &b)); + } + + #[test] + fn cells_equal_different_color() { + let a = make_cell("A", 2, 1, 0); + let b = make_cell("A", 3, 1, 0); + assert!(!cells_equal(&a, &b)); + } + + #[test] + fn blit_frame_hides_cursor_before_full_redraw_writes() { + let frame = make_frame( + 2, + 2, + vec![ + make_cell("H", 0, 0, 0), + make_cell("i", 0, 0, 0), + make_cell("!", 0, 0, 0), + make_cell(" ", 0, 0, 0), + ], + ); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.starts_with("\x1b[?2026h\x1b[?25l"), + "should begin synchronized output and hide cursor before any cell writes during full redraw" + ); + } + + #[test] + fn blit_frame_hides_cursor_before_diff_writes() { + let prev = make_frame( + 2, + 2, + vec![ + make_cell("H", 0, 0, 0), + make_cell("i", 0, 0, 0), + make_cell("!", 0, 0, 0), + make_cell(" ", 0, 0, 0), + ], + ); + + let curr = make_frame( + 2, + 2, + vec![ + make_cell("X", 0, 0, 0), // Changed + make_cell("i", 0, 0, 0), // Same + make_cell("!", 0, 0, 0), // Same + make_cell(" ", 0, 0, 0), // Same + ], + ); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.starts_with("\x1b[?2026h\x1b[?25l"), + "should begin synchronized output and hide cursor before any cell writes during diff" + ); + } + + #[test] + fn blit_frame_wraps_frame_in_synchronized_output() { + let frame = make_frame(1, 1, vec![make_cell("A", 0, 0, 0)]); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.starts_with("\x1b[?2026h"), + "should begin synchronized output before frame writes" + ); + let sync_end = output_str + .find("\x1b[?2026l") + .expect("should end synchronized output after frame writes"); + assert!( + sync_end + "\x1b[?2026l".len() < output_str.len(), + "should end synchronized output before trailing IME cursor update" + ); + } + + #[test] + fn blit_frame_repeats_final_cursor_state_after_synchronized_output() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0); 9], + width: 3, + height: 3, + cursor: Some(CursorState { + x: 2, + y: 1, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + let sync_end = output_str + .find("\x1b[?2026l") + .expect("should end synchronized output"); + let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..]; + assert_eq!( + trailing_cursor, "\x1b[2;3H\x1b[?25h", + "should expose only the final cursor state after synchronized output" + ); + } + + #[test] + fn blit_frame_emits_cursor_shape_before_visibility_without_touching_ime_anchor() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0)], + width: 1, + height: 1, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 6, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + let final_cursor = output_str + .find("\x1b[1;1H\x1b[6 q\x1b[?25h") + .expect("should set cursor shape before showing cursor"); + let sync_end = output_str + .find("\x1b[?2026l") + .expect("should end synchronized output"); + assert!( + final_cursor < sync_end, + "shape should be part of the synchronized final cursor state" + ); + let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..]; + assert_eq!( + trailing_cursor, "\x1b[1;1H\x1b[?25h", + "IME anchor update should preserve the existing position/visibility-only contract" + ); + } + + #[test] + fn blit_frame_repeats_explicit_hidden_cursor_anchor_after_synchronized_output() { + let visible = FrameData { + cells: vec![make_cell("A", 0, 0, 0); 9], + width: 3, + height: 3, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let hidden = FrameData { + cells: vec![make_cell("B", 0, 0, 0); 9], + width: 3, + height: 3, + cursor: Some(CursorState { + x: 2, + y: 1, + visible: false, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let mut last_visible_cursor = None; + let mut last_cursor_shape = 0; + let mut output = Vec::new(); + + blit_frame_to_with_cursor_memory( + &mut output, + &visible, + None, + &mut last_visible_cursor, + &mut last_cursor_shape, + ); + output.clear(); + blit_frame_to_with_cursor_memory( + &mut output, + &hidden, + Some(&visible), + &mut last_visible_cursor, + &mut last_cursor_shape, + ); + + let output_str = String::from_utf8(output).unwrap(); + let sync_end = output_str + .find("\x1b[?2026l") + .expect("should end synchronized output"); + let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..]; + assert_eq!( + trailing_cursor, "\x1b[2;3H\x1b[?25l", + "should repeat the explicit hidden cursor position while preserving visibility" + ); + } + + #[test] + fn blit_frame_emits_osc8_for_linked_cells() { + let mut frame = make_frame( + 3, + 1, + vec![ + linked_cell("L", 0), + linked_cell("i", 0), + make_cell("!", 0, 0, 0), + ], + ); + frame.hyperlinks.push("https://example.com".to_owned()); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L")); + assert!(output_str.contains('i')); + assert!(output_str.contains("\x1b]8;;\x1b\\")); + } + + #[test] + fn blit_frame_sanitizes_hyperlink_uris() { + let mut frame = make_frame(1, 1, vec![linked_cell("L", 0)]); + frame + .hyperlinks + .push("https://exa\x1b\x07mple.com".to_owned()); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L")); + } + + #[test] + fn blit_frame_first_frame_produces_output() { + let frame = make_frame( + 2, + 2, + vec![ + make_cell("H", 0, 0, 0), + make_cell("i", 0, 0, 0), + make_cell("!", 0, 0, 0), + make_cell(" ", 0, 0, 0), + ], + ); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + // Full redraw should start with clear screen. + assert!( + output_str.contains("\x1b[2J"), + "full redraw should clear screen" + ); + assert!( + output_str.contains('H') || output_str.contains('i'), + "should contain cell content" + ); + } + + #[test] + fn blit_frame_diff_only_writes_changed_cells() { + let prev = make_frame( + 2, + 2, + vec![ + make_cell("H", 0, 0, 0), + make_cell("i", 0, 0, 0), + make_cell("!", 0, 0, 0), + make_cell(" ", 0, 0, 0), + ], + ); + + // Only the first cell changed. + let curr = make_frame( + 2, + 2, + vec![ + make_cell("X", 0, 0, 0), // Changed + make_cell("i", 0, 0, 0), // Same + make_cell("!", 0, 0, 0), // Same + make_cell(" ", 0, 0, 0), // Same + ], + ); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + + let output_str = String::from_utf8(output).unwrap(); + // Diff should NOT clear the screen. + assert!( + !output_str.contains("\x1b[2J"), + "diff should not clear screen" + ); + // Should contain the changed cell content. + assert!(output_str.contains('X'), "should contain changed cell 'X'"); + } + + #[test] + fn blit_frame_size_change_triggers_full_redraw() { + let prev = make_frame(2, 2, vec![make_cell("A", 0, 0, 0); 4]); + + let curr = make_frame(3, 2, vec![make_cell("B", 0, 0, 0); 6]); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.contains("\x1b[2J"), + "size change should trigger full redraw" + ); + } + + #[test] + fn blit_frame_positions_cursor() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0)], + width: 1, + height: 1, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.contains("\x1b[1;1H"), + "should position cursor at (1,1)" + ); + } + + #[test] + fn blit_frame_hides_cursor_when_invisible() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0)], + width: 1, + height: 1, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: false, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.contains("\x1b[?25l"), + "should hide cursor when invisible" + ); + } + + #[test] + fn blit_frame_no_cursor_hides_cursor() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0)], + width: 1, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.contains("\x1b[?25l"), + "should hide cursor when no cursor state" + ); + } + + #[test] + fn blit_frame_restores_cursor_visibility() { + // First frame: cursor hidden. + let prev = FrameData { + cells: vec![make_cell("A", 0, 0, 0)], + width: 1, + height: 1, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: false, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &prev, None); + assert!( + String::from_utf8(output).unwrap().contains("\x1b[?25l"), + "first frame should hide cursor" + ); + + // Second frame: cursor visible — should restore visibility. + let curr = FrameData { + cells: vec![make_cell("B", 0, 0, 0)], + width: 1, + height: 1, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.contains("\x1b[?25h"), + "second frame should restore cursor visibility with ?25h" + ); + assert!( + output_str.contains("\x1b[1;1H"), + "should position cursor before showing it" + ); + } + + #[test] + fn blit_frame_positions_cursor_before_showing_it() { + let prev = FrameData { + cells: vec![make_cell("A", 0, 0, 0); 9], + width: 3, + height: 3, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let mut curr = prev.clone(); + curr.cells[0] = make_cell("B", 0, 0, 0); + curr.cursor = Some(CursorState { + x: 2, + y: 2, + visible: true, + shape: 0, + }); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + let output_str = String::from_utf8(output).unwrap(); + let final_move = output_str + .rfind("\x1b[3;3H") + .expect("should move cursor to final position"); + let show = output_str + .rfind("\x1b[?25h") + .expect("should show cursor after positioning it"); + + assert!( + final_move < show, + "should move cursor to final position before showing it" + ); + } + + #[test] + fn blit_frame_parks_hidden_cursor_at_last_visible_position() { + let visible = FrameData { + cells: vec![make_cell("A", 0, 0, 0); 9], + width: 3, + height: 3, + cursor: Some(CursorState { + x: 1, + y: 1, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let hidden = FrameData { + cells: vec![make_cell("B", 0, 0, 0); 9], + width: 3, + height: 3, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let mut last_visible_cursor = None; + let mut last_cursor_shape = 0; + let mut output = Vec::new(); + + blit_frame_to_with_cursor_memory( + &mut output, + &visible, + None, + &mut last_visible_cursor, + &mut last_cursor_shape, + ); + output.clear(); + blit_frame_to_with_cursor_memory( + &mut output, + &hidden, + Some(&visible), + &mut last_visible_cursor, + &mut last_cursor_shape, + ); + + let output_str = String::from_utf8(output).unwrap(); + let park = output_str + .rfind("\x1b[2;2H") + .expect("should park hidden cursor at last visible position"); + let hide = output_str + .rfind("\x1b[?25l") + .expect("should keep hidden cursor hidden"); + assert!(park < hide, "should park cursor before hiding it"); + } + + #[test] + fn blit_frame_parks_hidden_cursor_at_bottom_right_without_history() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0); 6], + width: 3, + height: 2, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let mut last_visible_cursor = None; + let mut last_cursor_shape = 0; + let mut output = Vec::new(); + + blit_frame_to_with_cursor_memory( + &mut output, + &frame, + None, + &mut last_visible_cursor, + &mut last_cursor_shape, + ); + + let output_str = String::from_utf8(output).unwrap(); + assert!( + output_str.contains("\x1b[2;3H\x1b[?25l"), + "should park hidden cursor at bottom-right before ending the frame" + ); + } + + #[test] + fn blit_frame_hides_previous_visible_cursor_when_next_frame_has_none() { + let prev = FrameData { + cells: vec![make_cell("A", 0, 0, 0)], + width: 1, + height: 1, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let curr = FrameData { + cells: vec![make_cell("B", 0, 0, 0)], + width: 1, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + + assert!( + String::from_utf8(output).unwrap().contains("\x1b[?25l"), + "diff redraw should hide a previously visible cursor when the next frame has none" + ); + } + + #[test] + fn full_redraw_skips_trailing_cells_covered_by_wide_graphemes() { + let frame = FrameData { + cells: vec![ + make_cell(WIDE_GRAPHEME, 0, 0, 0), + make_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + let output_str = String::from_utf8(output).unwrap(); + + assert!(output_str.contains("\x1b[1;1H")); + assert!(!output_str.contains("\x1b[1;2H")); + assert!(output_str.contains("\x1b[1;3H")); + } + + #[test] + fn diff_redraw_reveals_cells_hidden_by_previous_wide_graphemes() { + let prev = FrameData { + cells: vec![ + make_cell(WIDE_GRAPHEME, 0, 0, 0), + make_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let curr = FrameData { + cells: vec![ + make_cell("A", 0, 0, 0), + make_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + let output_str = String::from_utf8(output).unwrap(); + + assert!(output_str.contains("\x1b[1;1H")); + assert!( + output_str.contains("\x1b[1;2H"), + "cells hidden by a previous wide grapheme must be redrawn when they become visible" + ); + } + + #[test] + fn diff_redraw_skips_new_trailing_cells_covered_by_wide_graphemes() { + let prev = FrameData { + cells: vec![ + make_cell("A", 0, 0, 0), + make_cell("B", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let curr = FrameData { + cells: vec![ + make_cell(WIDE_GRAPHEME, 0, 0, 0), + make_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + let output_str = String::from_utf8(output).unwrap(); + + assert!(output_str.contains("\x1b[1;1H")); + assert!(!output_str.contains("\x1b[1;2H")); + } +} diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs new file mode 100644 index 00000000..33a5f513 --- /dev/null +++ b/src/protocol/wire.rs @@ -0,0 +1,1513 @@ +//! Wire protocol for herdr server/client communication. +//! +//! Defines the message types, framing, version negotiation, and safety +//! constraints for the binary protocol over Unix domain sockets. + +use std::collections::HashMap; +use std::io::{self, Read, Write}; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Protocol constants +// --------------------------------------------------------------------------- + +/// Current protocol version. Bumped when wire format changes incompatibly. +pub const PROTOCOL_VERSION: u32 = 9; + +/// Maximum allowed frame payload size (2 MB). Frames larger than this are +/// rejected to prevent denial-of-service via oversized length prefixes. +pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024; + +/// Maximum allowed server-to-client frame payload when Kitty graphics are enabled. +/// Normal traffic keeps `MAX_FRAME_SIZE`; this larger cap is only for explicit +/// image payloads that are naturally much larger after base64 encoding. +pub const MAX_GRAPHICS_FRAME_SIZE: usize = 32 * 1024 * 1024; + +/// Maximum clipboard image payload size for remote paste bridging. +pub const MAX_CLIPBOARD_IMAGE_PAYLOAD: usize = 16 * 1024 * 1024; + +/// Length of the u32 little-endian length prefix in bytes. +const LENGTH_PREFIX_BYTES: usize = 4; + +// --------------------------------------------------------------------------- +// Client → Server messages +// --------------------------------------------------------------------------- + +/// Render payload encoding negotiated during client handshake. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RenderEncoding { + /// Send full semantic FrameData values. This is the local/default mode. + SemanticFrame, + /// Send already-diffed terminal ANSI byte streams. + TerminalAnsi, +} + +/// Keybinding profile requested by an attached app client. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientKeybindings { + /// Use the server's own keybinding config. + Server, + /// Use this attached client's normalized local `[keys]` config. + Local { keys_toml: String }, +} + +/// Messages sent from the client to the server over the client protocol socket. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientMessage { + /// Handshake: client announces its protocol version and terminal dimensions. + Hello { + /// Protocol version the client speaks. + version: u32, + /// Terminal width in columns. + cols: u16, + /// Terminal height in rows. + rows: u16, + /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. + cell_width_px: u32, + /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. + cell_height_px: u32, + /// Render encoding requested by the client. + requested_encoding: RenderEncoding, + /// Keybinding profile requested by the client. + keybindings: ClientKeybindings, + }, + + /// Raw input bytes read from the client's stdin. + Input { + /// Raw terminal input (possibly multi-byte escape sequences). + data: Vec, + }, + + /// Image bytes read from the client's local clipboard for remote paste bridging. + ClipboardImage { + /// Image file extension without a leading dot. + extension: String, + /// Raw image bytes. + data: Vec, + }, + + /// Terminal resize notification from the client. + Resize { + /// New terminal width in columns. + cols: u16, + /// New terminal height in rows. + rows: u16, + /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. + cell_width_px: u32, + /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. + cell_height_px: u32, + }, + + /// Graceful disconnect request. + Detach, + + /// Switch this connection into direct terminal attach mode. + AttachTerminal { + /// Terminal id to attach to. + terminal_id: String, + /// Replace an existing writable attach owner for this terminal. + takeover: bool, + }, +} + +// --------------------------------------------------------------------------- +// Server → Client messages +// --------------------------------------------------------------------------- + +/// A single cell in a rendered frame, serialized independently from ratatui's +/// `Cell` type to keep the wire protocol stable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CellData { + /// Grapheme cluster displayed in this cell (usually 1–2 chars). + pub symbol: String, + /// Foreground color as a packed u32 (0xAARRGGBB or ratatui Color index). + pub fg: u32, + /// Background color as a packed u32. + pub bg: u32, + /// Bitmask of style modifiers (bold, italic, etc.). + pub modifier: u16, + /// Whether this cell should be skipped during diff-based rendering. + pub skip: bool, + /// Index into `FrameData::hyperlinks` for this cell's OSC 8 target, if any. + pub hyperlink: Option, +} + +/// Cursor shape encoded as a DECSCUSR parameter. +/// +/// 0 = terminal default, 1 = blinking block, 2 = steady block, +/// 3 = blinking underline, 4 = steady underline, 5 = blinking bar, +/// 6 = steady bar. +pub type CursorShapeParam = u8; + +/// Cursor position within a rendered frame. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CursorState { + /// Column offset (0-based) of the cursor. + pub x: u16, + /// Row offset (0-based) of the cursor. + pub y: u16, + /// Whether the cursor is visible. + pub visible: bool, + /// Cursor shape as a DECSCUSR parameter. + #[serde(default)] + pub shape: CursorShapeParam, +} + +/// A rendered frame to be displayed by the client. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrameData { + /// Cells in row-major order. Length must equal `width * height`. + pub cells: Vec, + /// Frame width in columns. + pub width: u16, + /// Frame height in rows. + pub height: u16, + /// Cursor state for this frame, if applicable. + pub cursor: Option, + /// OSC 8 hyperlink URIs referenced by cells. + pub hyperlinks: Vec, + /// Kitty graphics protocol bytes to apply after the text frame. + pub graphics: Vec, +} + +impl FrameData { + /// Creates a `FrameData` from a ratatui `Buffer` and optional cursor. + /// + /// This converts ratatui's internal cell representation into the + /// wire-protocol cell format. The conversion is lossless for all + /// commonly used cell attributes. + #[cfg(test)] + pub fn from_ratatui_buffer( + buffer: &ratatui::buffer::Buffer, + cursor: Option, + ) -> Self { + Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[]) + } + + pub fn from_ratatui_buffer_with_hyperlinks( + buffer: &ratatui::buffer::Buffer, + cursor: Option, + hyperlinks: &[((u16, u16), String, String)], + ) -> Self { + let area = buffer.area; + let width = area.width; + let height = area.height; + + let mut hyperlink_uris = Vec::::new(); + let mut hyperlink_indices = HashMap::<&str, u32>::new(); + let mut hyperlink_by_position = HashMap::<(u16, u16), (&str, &str)>::new(); + for ((x, y), symbol, uri) in hyperlinks { + hyperlink_by_position.insert((*x, *y), (symbol.as_str(), uri.as_str())); + } + let mut cells = Vec::with_capacity((width as usize) * (height as usize)); + for row in 0..height { + for col in 0..width { + let cell = buffer.cell((col, row)).expect("cell within bounds"); + let hyperlink = hyperlink_by_position + .get(&(col, row)) + .and_then(|(symbol, uri)| { + if *symbol != cell.symbol() { + return None; + } + Some(*hyperlink_indices.entry(*uri).or_insert_with(|| { + let index = hyperlink_uris.len() as u32; + hyperlink_uris.push((*uri).to_owned()); + index + })) + }); + cells.push(CellData { + symbol: cell.symbol().to_owned(), + fg: color_to_u32(cell.fg), + bg: color_to_u32(cell.bg), + modifier: modifier_to_u16(cell.modifier), + skip: cell.skip, + hyperlink, + }); + } + } + + FrameData { + cells, + width, + height, + cursor, + hyperlinks: hyperlink_uris, + graphics: Vec::new(), + } + } + + /// Reconstructs a ratatui `Buffer` from this frame data. + /// + /// Returns `None` if the cells vector length doesn't match `width * height`. + #[cfg(test)] + pub fn to_ratatui_buffer(&self) -> Option { + let expected = (self.width as usize) * (self.height as usize); + if self.cells.len() != expected { + return None; + } + + let area = ratatui::layout::Rect::new(0, 0, self.width, self.height); + let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" ")); + + for row in 0..self.height { + for col in 0..self.width { + let idx = (row as usize) * (self.width as usize) + (col as usize); + let cell_data = &self.cells[idx]; + let cell = buffer.cell_mut((col, row)).expect("cell within bounds"); + cell.set_symbol(&cell_data.symbol); + cell.fg = u32_to_color(cell_data.fg); + cell.bg = u32_to_color(cell_data.bg); + cell.modifier = u16_to_modifier(cell_data.modifier); + cell.skip = cell_data.skip; + } + } + + Some(buffer) + } +} + +/// Terminal ANSI bytes encoded by the server for network-efficient clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TerminalFrame { + /// Monotonic per-client frame sequence. + pub seq: u64, + /// Frame width in columns. + pub width: u16, + /// Frame height in rows. + pub height: u16, + /// Whether bytes contain a full redraw rather than an incremental diff. + pub full: bool, + /// Terminal escape bytes ready to write directly to stdout. + pub bytes: Vec, +} + +/// Notification kind forwarded from server to client. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotifyKind { + /// Play a sound (bell/agent-done, etc.). + Sound, + /// Display a toast message through the outer terminal. + Toast, + /// Display a toast message through the host OS notification service. + SystemToast, +} + +/// Messages sent from the server to the client over the client protocol socket. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServerMessage { + /// Handshake response: server acknowledges (or rejects) the client. + Welcome { + /// Protocol version the server speaks. + version: u32, + /// Render encoding selected by the server for this connection. + encoding: RenderEncoding, + /// If present, the handshake failed and this describes why. + /// The client should exit with a clear error message. + error: Option, + }, + + /// A rendered frame to be displayed by a semantic-frame client. + Frame(FrameData), + + /// Terminal bytes to write directly for a terminal-ANSI client. + Terminal(TerminalFrame), + + /// Client-local Kitty graphics bytes to write directly to the host terminal. + Graphics { + /// Raw Kitty graphics protocol bytes. + bytes: Vec, + }, + + /// Server is shutting down. Clients should exit gracefully. + ServerShutdown { + /// Optional reason for the shutdown. + reason: Option, + }, + + /// A notification event (sound/toast) to be rendered locally by the client. + Notify { + /// What kind of notification. + kind: NotifyKind, + /// Human-readable message. + message: String, + }, + + /// OSC 52 clipboard data forwarded from a PTY through the server. + Clipboard { + /// Base64-encoded clipboard data. + data: String, + }, + + /// Client-local sound config changed on disk; refresh it without reconnecting. + ReloadSoundConfig, + + /// Whether the client should currently capture host mouse input. + MouseCapture { + /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting. + enabled: bool, + }, +} + +// --------------------------------------------------------------------------- +// Color / Modifier conversion helpers +// --------------------------------------------------------------------------- + +/// Converts a ratatui `Color` to a packed u32 for wire transport. +/// +/// Encoding: +/// - Named colors (Reset, Black, …, White) → `0x00_00_00_XX` where XX is 0..=16 +/// - Indexed palette → `0x01_00_00_XX` where XX is the palette index +/// - RGB → `0x02_RR_GG_BB` with components in the lower 3 bytes +fn color_to_u32(color: ratatui::style::Color) -> u32 { + match color { + ratatui::style::Color::Reset => 0x00_00_00_00, + ratatui::style::Color::Black => 0x00_00_00_01, + ratatui::style::Color::Red => 0x00_00_00_02, + ratatui::style::Color::Green => 0x00_00_00_03, + ratatui::style::Color::Yellow => 0x00_00_00_04, + ratatui::style::Color::Blue => 0x00_00_00_05, + ratatui::style::Color::Magenta => 0x00_00_00_06, + ratatui::style::Color::Cyan => 0x00_00_00_07, + ratatui::style::Color::Gray => 0x00_00_00_08, + ratatui::style::Color::DarkGray => 0x00_00_00_09, + ratatui::style::Color::LightRed => 0x00_00_00_0A, + ratatui::style::Color::LightGreen => 0x00_00_00_0B, + ratatui::style::Color::LightYellow => 0x00_00_00_0C, + ratatui::style::Color::LightBlue => 0x00_00_00_0D, + ratatui::style::Color::LightMagenta => 0x00_00_00_0E, + ratatui::style::Color::LightCyan => 0x00_00_00_0F, + ratatui::style::Color::White => 0x00_00_00_10, + ratatui::style::Color::Indexed(i) => 0x01_00_00_00 | (i as u32), + ratatui::style::Color::Rgb(r, g, b) => { + 0x02_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32) + } + } +} + +/// Converts a packed u32 back to a ratatui `Color`. +#[cfg(test)] +fn u32_to_color(val: u32) -> ratatui::style::Color { + match val >> 24 { + 0x00 => match val & 0xFF { + 0x00 => ratatui::style::Color::Reset, + 0x01 => ratatui::style::Color::Black, + 0x02 => ratatui::style::Color::Red, + 0x03 => ratatui::style::Color::Green, + 0x04 => ratatui::style::Color::Yellow, + 0x05 => ratatui::style::Color::Blue, + 0x06 => ratatui::style::Color::Magenta, + 0x07 => ratatui::style::Color::Cyan, + 0x08 => ratatui::style::Color::Gray, + 0x09 => ratatui::style::Color::DarkGray, + 0x0A => ratatui::style::Color::LightRed, + 0x0B => ratatui::style::Color::LightGreen, + 0x0C => ratatui::style::Color::LightYellow, + 0x0D => ratatui::style::Color::LightBlue, + 0x0E => ratatui::style::Color::LightMagenta, + 0x0F => ratatui::style::Color::LightCyan, + 0x10 => ratatui::style::Color::White, + _ => ratatui::style::Color::Reset, // unknown named → Reset + }, + 0x01 => ratatui::style::Color::Indexed((val & 0xFF) as u8), + 0x02 => { + let r = ((val >> 16) & 0xFF) as u8; + let g = ((val >> 8) & 0xFF) as u8; + let b = (val & 0xFF) as u8; + ratatui::style::Color::Rgb(r, g, b) + } + _ => ratatui::style::Color::Reset, // unknown tag → Reset + } +} + +/// Converts a ratatui `Modifier` bitmask to a u16 for wire transport. +fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 { + modifier.bits() +} + +/// Converts a u16 back to a ratatui `Modifier`. +#[cfg(test)] +fn u16_to_modifier(val: u16) -> ratatui::style::Modifier { + ratatui::style::Modifier::from_bits_truncate(val) +} + +// --------------------------------------------------------------------------- +// Framing: length-prefixed binary messages +// --------------------------------------------------------------------------- + +/// Errors that can occur during framing operations. +#[derive(Debug)] +pub enum FramingError { + /// The decoded payload length exceeds the configured maximum frame size. + Oversized { claimed: usize, max: usize }, + /// An I/O error occurred while reading or writing. + Io(io::Error), + /// Bincode serialization or deserialization failed. + Bincode(String), + /// The connection was closed before a complete frame could be read. + UnexpectedEof, +} + +impl std::fmt::Display for FramingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FramingError::Oversized { claimed, max } => { + write!(f, "frame size {claimed} exceeds maximum {max}") + } + FramingError::Io(e) => write!(f, "I/O error: {e}"), + FramingError::Bincode(e) => write!(f, "bincode error: {e}"), + FramingError::UnexpectedEof => write!(f, "unexpected end of stream"), + } + } +} + +impl std::error::Error for FramingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + FramingError::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for FramingError { + fn from(e: io::Error) -> Self { + FramingError::Io(e) + } +} + +/// Serializes a message and writes it as a length-prefixed frame: +/// `[u32LE length][bincode payload]`. +/// +/// This is a blocking/synchronous write suitable for use with `std::os::unix::net::UnixStream` +/// in blocking mode, or with any `Write` implementor. +/// +/// # Errors +/// +/// Returns `FramingError::Bincode` if the payload length exceeds `u32::MAX` +/// (would be truncated by the length prefix cast). +pub fn write_message(writer: &mut W, msg: &M) -> Result<(), FramingError> { + let payload = bincode::serde::encode_to_vec(msg, bincode::config::standard()) + .map_err(|e| FramingError::Bincode(e.to_string()))?; + + let len = payload.len(); + if len > u32::MAX as usize { + return Err(FramingError::Bincode(format!( + "payload length {len} exceeds u32::MAX ({}), would be truncated by length prefix", + u32::MAX + ))); + } + + writer.write_all(&(len as u32).to_le_bytes())?; + writer.write_all(&payload)?; + writer.flush()?; + Ok(()) +} + +/// Reads and deserializes a length-prefixed frame from a reader. +/// +/// Reassembles partial reads correctly. Rejects frames whose declared +/// length exceeds `max_frame_size` without panicking or allocating +/// oversized buffers. +pub fn read_message Deserialize<'de>>( + reader: &mut R, + max_frame_size: usize, +) -> Result { + // Read the 4-byte length prefix, reassembling partial reads. + let mut len_buf = [0u8; LENGTH_PREFIX_BYTES]; + read_exact_or_eof(reader, &mut len_buf)?; + let claimed_len = u32::from_le_bytes(len_buf) as usize; + + if claimed_len > max_frame_size { + return Err(FramingError::Oversized { + claimed: claimed_len, + max: max_frame_size, + }); + } + + // Read the payload, reassembling partial reads. + let mut payload = vec![0u8; claimed_len]; + read_exact_or_eof(reader, &mut payload)?; + + let (msg, consumed) = bincode::serde::decode_from_slice(&payload, bincode::config::standard()) + .map_err(|e| FramingError::Bincode(e.to_string()))?; + + // Enforce that the decoder consumed the full payload. + // Trailing bytes after the decoded message indicate a protocol violation + // (e.g., a corrupted length prefix or concatenated payloads). + if consumed != claimed_len { + return Err(FramingError::Bincode(format!( + "decoded {} bytes but payload length was {claimed_len}; trailing bytes are not allowed", + consumed + ))); + } + + Ok(msg) +} + +/// Like `Read::read_exact`, but returns `FramingError::UnexpectedEof` +/// when the reader hits end-of-stream before filling the buffer, instead +/// of the generic `io::ErrorKind::UnexpectedEof`. +fn read_exact_or_eof(reader: &mut R, buf: &mut [u8]) -> Result<(), FramingError> { + reader.read_exact(buf).map_err(|e| { + if e.kind() == io::ErrorKind::UnexpectedEof { + FramingError::UnexpectedEof + } else { + FramingError::Io(e) + } + }) +} + +// --------------------------------------------------------------------------- +// Version negotiation +// --------------------------------------------------------------------------- + +/// Result of checking a client's protocol version against the server's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionCheck { + /// Versions are compatible. The server should reply with a successful Welcome. + Compatible, + /// Versions are incompatible. The server should reply with a Welcome error and close. + Incompatible(String), +} + +/// Checks whether a client's protocol version is compatible with this server. +/// +/// Current rules: +/// - Version 0 (pre-persistence client) is always rejected. +/// - Matching major versions are accepted. +/// - A client with a newer version than the server is rejected. +/// - A client with an older version than the server is rejected +/// (backward compatibility is not yet supported). +pub fn check_client_version(client_version: u32) -> VersionCheck { + if client_version == 0 { + return VersionCheck::Incompatible( + "pre-persistence client (version 0) is not supported".to_owned(), + ); + } + + if client_version == PROTOCOL_VERSION { + VersionCheck::Compatible + } else if client_version < PROTOCOL_VERSION { + VersionCheck::Incompatible(format!( + "client version {client_version} is older than server version {PROTOCOL_VERSION}; please upgrade your herdr client" + )) + } else { + VersionCheck::Incompatible(format!( + "client version {client_version} is newer than server version {PROTOCOL_VERSION}; please upgrade the herdr server" + )) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::{Color, Modifier}; + + // ---- Round-trip: ClientMessage ---- + + #[test] + fn client_hello_roundtrip() { + let msg = ClientMessage::Hello { + version: PROTOCOL_VERSION, + cols: 80, + rows: 24, + cell_width_px: 8, + cell_height_px: 16, + requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_input_roundtrip() { + let msg = ClientMessage::Input { + data: vec![0x1b, 0x5b, 0x41], // ESC [ A (up arrow) + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_clipboard_image_roundtrip() { + let msg = ClientMessage::ClipboardImage { + extension: "png".to_owned(), + data: vec![0x89, b'P', b'N', b'G'], + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_input_large_multilingual_payload_roundtrip() { + let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024); + assert!(text.len() > 64 * 1024); + assert!(text.len() < MAX_FRAME_SIZE); + let msg = ClientMessage::Input { + data: text.as_bytes().to_vec(), + }; + + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, consumed): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + + assert_eq!(consumed, encoded.len()); + assert_eq!(decoded, msg); + } + + #[test] + fn client_resize_roundtrip() { + let msg = ClientMessage::Resize { + cols: 80, + rows: 24, + cell_width_px: 8, + cell_height_px: 16, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_detach_roundtrip() { + let msg = ClientMessage::Detach; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_attach_terminal_roundtrip() { + let msg = ClientMessage::AttachTerminal { + terminal_id: "term_123".to_owned(), + takeover: true, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + // ---- Round-trip: ServerMessage ---- + + #[test] + fn server_welcome_roundtrip() { + let msg = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: None, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn server_welcome_with_error_roundtrip() { + let msg = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some("incompatible version".to_owned()), + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn server_frame_roundtrip_nontrivial() { + // Build a 3×2 frame with varied styles (≥2×2). + let frame = FrameData { + cells: vec![ + CellData { + symbol: "H".into(), + fg: color_to_u32(Color::Red), + bg: color_to_u32(Color::Black), + modifier: Modifier::BOLD.bits(), + skip: false, + hyperlink: None, + }, + CellData { + symbol: "i".into(), + fg: color_to_u32(Color::Green), + bg: color_to_u32(Color::Reset), + modifier: Modifier::ITALIC.bits(), + skip: false, + hyperlink: None, + }, + CellData { + symbol: "!".into(), + fg: color_to_u32(Color::Rgb(255, 128, 0)), + bg: color_to_u32(Color::Indexed(220)), + modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(), + skip: false, + hyperlink: Some(0), + }, + CellData { + symbol: " ".into(), + fg: color_to_u32(Color::Reset), + bg: color_to_u32(Color::Reset), + modifier: Modifier::empty().bits(), + skip: true, + hyperlink: None, + }, + CellData { + symbol: "→".into(), // multi-byte grapheme + fg: color_to_u32(Color::Cyan), + bg: color_to_u32(Color::Blue), + modifier: Modifier::REVERSED.bits(), + skip: false, + hyperlink: None, + }, + CellData { + symbol: "🦀".into(), // emoji, wide grapheme cluster + fg: color_to_u32(Color::Yellow), + bg: color_to_u32(Color::Magenta), + modifier: Modifier::empty().bits(), + skip: false, + hyperlink: None, + }, + ], + width: 3, + height: 2, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 6, + }), + hyperlinks: vec!["https://example.com".to_owned()], + graphics: Vec::new(), + }; + let msg = ServerMessage::Frame(frame.clone()); + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + match decoded { + ServerMessage::Frame(frame) => { + assert_eq!(frame.cells[2].hyperlink, Some(0)); + assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]); + } + other => panic!("expected frame, got {other:?}"), + } + } + + #[test] + fn server_shutdown_roundtrip() { + let msg = ServerMessage::ServerShutdown { + reason: Some("updating".to_owned()), + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn server_notify_roundtrip() { + for kind in [ + NotifyKind::Sound, + NotifyKind::Toast, + NotifyKind::SystemToast, + ] { + let msg = ServerMessage::Notify { + kind, + message: "agent done".to_owned(), + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + } + + #[test] + fn server_clipboard_roundtrip() { + let msg = ServerMessage::Clipboard { + data: "dGVzdA==".to_owned(), // base64 "test" + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn server_graphics_roundtrip() { + let msg = ServerMessage::Graphics { + bytes: b"\x1b_Ga=d,d=A,q=2;\x1b\\".to_vec(), + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn server_terminal_frame_roundtrip() { + let msg = ServerMessage::Terminal(TerminalFrame { + seq: 7, + width: 120, + height: 40, + full: false, + bytes: b"\x1b[1;1Hhello".to_vec(), + }); + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn server_reload_sound_config_roundtrip() { + let msg = ServerMessage::ReloadSoundConfig; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn server_mouse_capture_roundtrip() { + let msg = ServerMessage::MouseCapture { enabled: true }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + // ---- Framing ---- + + #[test] + fn framing_small_message_roundtrip() { + let msg = ClientMessage::Hello { + version: PROTOCOL_VERSION, + cols: 80, + rows: 24, + cell_width_px: 8, + cell_height_px: 16, + requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, + }; + let mut buf = Vec::new(); + write_message(&mut buf, &msg).unwrap(); + let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn framing_large_payload_roundtrip() { + // Create a Frame message that is ≥128 KB. + // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding. + // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB. + let width: u16 = 200; + let height: u16 = 50; + let cells: Vec = (0..(width as usize) * (height as usize)) + .map(|i| CellData { + symbol: if i % 256 < 32 { + " ".to_owned() + } else { + format!("{:03}", i % 1000) + }, + fg: color_to_u32(Color::Rgb((i % 256) as u8, ((i / 256) % 256) as u8, 128)), + bg: color_to_u32(Color::Indexed((i % 256) as u8)), + modifier: ((i % 16) as u16), + skip: i % 100 == 0, + hyperlink: None, + }) + .collect(); + + let frame = FrameData { + cells, + width, + height, + cursor: Some(CursorState { + x: 10, + y: 5, + visible: true, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let msg = ServerMessage::Frame(frame); + + let mut buf = Vec::new(); + write_message(&mut buf, &msg).unwrap(); + // Verify the payload is at least 128 KB + assert!( + buf.len() >= 128 * 1024, + "framed payload should be >= 128 KB, got {} bytes", + buf.len() + ); + + let decoded: ServerMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn framing_multiple_messages_sequential() { + // Write 100+ messages of varying types and read them back. + let mut buf = Vec::new(); + let mut expected = Vec::new(); + + for i in 0..150u32 { + let msg = match i % 5 { + 0 => ClientMessage::Hello { + version: PROTOCOL_VERSION, + cols: (80 + (i % 40) as u16), + rows: (24 + (i % 20) as u16), + cell_width_px: 8, + cell_height_px: 16, + requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, + }, + 1 => ClientMessage::Input { + data: vec![(i % 256) as u8; (i as usize % 50) + 1], + }, + 2 => ClientMessage::ClipboardImage { + extension: "png".to_owned(), + data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8], + }, + 3 => ClientMessage::Resize { + cols: (100 + (i % 30) as u16), + rows: (30 + (i % 10) as u16), + cell_width_px: 8, + cell_height_px: 16, + }, + 4 => ClientMessage::Detach, + _ => unreachable!(), + }; + write_message(&mut buf, &msg).unwrap(); + expected.push(msg); + } + + let mut cursor = buf.as_slice(); + for expected_msg in &expected { + let decoded: ClientMessage = read_message(&mut cursor, MAX_FRAME_SIZE).unwrap(); + assert_eq!(*expected_msg, decoded); + } + } + + #[test] + fn framing_oversized_rejected_without_panic() { + // Craft a frame with a huge length prefix (4 GB claim). + let mut buf: Vec = (u32::MAX).to_le_bytes().to_vec(); + // Add a few garbage bytes after the length prefix. + buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); + + let result: Result = + read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); + match result { + Err(FramingError::Oversized { claimed, max }) => { + assert_eq!(claimed, u32::MAX as usize); + assert_eq!(max, MAX_FRAME_SIZE); + } + other => panic!("expected Oversized error, got: {other:?}"), + } + } + + #[test] + fn framing_malformed_payload_rejected_without_panic() { + // Valid length prefix pointing to garbage data. + let payload = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; + let mut buf = (payload.len() as u32).to_le_bytes().to_vec(); + buf.extend_from_slice(&payload); + + let result: Result = + read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); + assert!(result.is_err(), "malformed payload should be rejected"); + match result { + Err(FramingError::Bincode(_)) => {} // expected + other => panic!("expected Bincode error, got: {other:?}"), + } + } + + #[test] + fn framing_truncated_stream_returns_unexpected_eof() { + // Write a length prefix claiming 100 bytes, but only provide 4. + let mut buf: Vec = 100u32.to_le_bytes().to_vec(); + buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + + let result: Result = + read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); + match result { + Err(FramingError::UnexpectedEof) => {} + other => panic!("expected UnexpectedEof, got: {other:?}"), + } + } + + #[test] + fn framing_zero_length_message() { + // A 1-byte message (smallest possible valid bincode payload). + // Actually, let's test with the smallest real message: Detach. + let msg = ClientMessage::Detach; + let mut buf = Vec::new(); + write_message(&mut buf, &msg).unwrap(); + + // Verify the length prefix is correct + let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; + assert_eq!( + len, + buf.len() - 4, + "length prefix should match payload size" + ); + + let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn framing_partial_read_reassembly() { + // Simulate partial reads by using a reader that yields small chunks. + let msg = ClientMessage::Input { + data: vec![42; 500], // 500-byte input payload + }; + let mut full_buf = Vec::new(); + write_message(&mut full_buf, &msg).unwrap(); + + // Wrap in a chunked reader that only yields 7 bytes at a time. + let mut chunked = ChunkedReader::new(full_buf, 7); + let decoded: ClientMessage = read_message(&mut chunked, MAX_FRAME_SIZE).unwrap(); + assert_eq!(msg, decoded); + } + + // ---- Version negotiation ---- + + #[test] + fn version_compatible() { + assert_eq!( + check_client_version(PROTOCOL_VERSION), + VersionCheck::Compatible + ); + } + + #[test] + fn version_older_client_rejected() { + let result = check_client_version(PROTOCOL_VERSION - 1); + assert!(matches!(result, VersionCheck::Incompatible(_))); + if let VersionCheck::Incompatible(msg) = result { + assert!(msg.contains("older"), "error should mention older version"); + } + } + + #[test] + fn version_newer_client_rejected() { + let result = check_client_version(PROTOCOL_VERSION + 1); + assert!(matches!(result, VersionCheck::Incompatible(_))); + if let VersionCheck::Incompatible(msg) = result { + assert!(msg.contains("newer"), "error should mention newer version"); + } + } + + // ---- Pre-persistence client rejection ---- + + #[test] + fn prepersistence_version_zero_rejected() { + let result = check_client_version(0); + match result { + VersionCheck::Incompatible(msg) => { + assert!( + msg.contains("pre-persistence"), + "error should mention pre-persistence: {msg}" + ); + } + _ => panic!("version 0 should be rejected as incompatible"), + } + } + + #[test] + fn prepersistence_version_zero_welcome_has_error() { + // Simulating what the server would send to a v0 client. + let check = check_client_version(0); + let response = match check { + VersionCheck::Compatible => ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: None, + }, + VersionCheck::Incompatible(reason) => ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some(reason), + }, + }; + + match response { + ServerMessage::Welcome { error: Some(_), .. } => {} + other => panic!("expected Welcome with error, got: {other:?}"), + } + } + + // ---- Malformed/oversized input ---- + + #[test] + fn oversized_frame_does_not_panic() { + // Claim 4GB payload — should return Oversized error, not panic. + let mut buf: Vec = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim + buf.extend_from_slice(&[0; 8]); + + let result: Result = + read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); + assert!(result.is_err()); + // Did not panic — test passing is proof. + } + + #[test] + fn malformed_frame_does_not_panic() { + // Random garbage bytes after a valid-ish length prefix. + let garbage: Vec = (0..200).map(|i| (i ^ 0xAA) as u8).collect(); + let mut buf = (garbage.len() as u32).to_le_bytes().to_vec(); + buf.extend_from_slice(&garbage); + + let result: Result = + read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); + assert!(result.is_err()); + // Did not panic. + } + + #[test] + fn oversized_input_rejected_custom_max() { + // Verify a custom (small) max_frame_size is enforced. + let msg = ClientMessage::Input { + data: vec![0x41; 1000], + }; + let mut buf = Vec::new(); + write_message(&mut buf, &msg).unwrap(); + + let result: Result = read_message(&mut buf.as_slice(), 64); + // The actual bincode payload for 1000 bytes of input will be > 64 bytes. + assert!( + matches!(result, Err(FramingError::Oversized { .. })), + "expected Oversized with small max_frame_size" + ); + } + + // ---- FrameData ↔ ratatui Buffer conversion ---- + + #[test] + fn frame_data_roundtrip_through_ratatui_buffer() { + let area = ratatui::layout::Rect::new(0, 0, 5, 3); + let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" ")); + + // Write some styled content. + buffer.cell_mut((0, 0)).unwrap().set_symbol("H"); + buffer.cell_mut((0, 0)).unwrap().fg = Color::Red; + buffer.cell_mut((0, 0)).unwrap().modifier = Modifier::BOLD; + + buffer.cell_mut((1, 0)).unwrap().set_symbol("i"); + buffer.cell_mut((1, 0)).unwrap().fg = Color::Green; + buffer.cell_mut((1, 0)).unwrap().modifier = Modifier::ITALIC; + + buffer.cell_mut((2, 0)).unwrap().set_symbol("!"); + buffer.cell_mut((2, 0)).unwrap().fg = Color::Rgb(255, 128, 0); + buffer.cell_mut((2, 0)).unwrap().bg = Color::Indexed(220); + + let cursor = CursorState { + x: 1, + y: 0, + visible: true, + shape: 0, + }; + let frame = FrameData::from_ratatui_buffer(&buffer, Some(cursor.clone())); + + // Verify frame dimensions. + assert_eq!(frame.width, 5); + assert_eq!(frame.height, 3); + assert_eq!(frame.cells.len(), 15); + assert_eq!(frame.cursor, Some(cursor)); + + // Verify specific cells survived the conversion. + assert_eq!(frame.cells[0].symbol, "H"); + assert_eq!(frame.cells[0].fg, color_to_u32(Color::Red)); + assert_eq!(frame.cells[0].modifier, Modifier::BOLD.bits()); + + assert_eq!(frame.cells[1].symbol, "i"); + assert_eq!(frame.cells[1].fg, color_to_u32(Color::Green)); + assert_eq!(frame.cells[1].modifier, Modifier::ITALIC.bits()); + + assert_eq!(frame.cells[2].symbol, "!"); + assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0))); + assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220))); + + let with_links = FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, + None, + &[((1, 0), "i".to_owned(), "https://example.com".to_owned())], + ); + assert_eq!(with_links.cells[1].hyperlink, Some(0)); + assert_eq!( + with_links.hyperlinks, + vec!["https://example.com".to_owned()] + ); + + // Convert back to ratatui buffer and compare. + let restored = frame.to_ratatui_buffer().expect("should reconstruct"); + assert_eq!(restored.area, area); + assert_eq!(restored.cell((0, 0)).unwrap().symbol(), "H"); + assert_eq!(restored.cell((0, 0)).unwrap().fg, Color::Red); + assert_eq!(restored.cell((0, 0)).unwrap().modifier, Modifier::BOLD); + assert_eq!(restored.cell((1, 0)).unwrap().symbol(), "i"); + assert_eq!(restored.cell((2, 0)).unwrap().symbol(), "!"); + assert_eq!(restored.cell((2, 0)).unwrap().fg, Color::Rgb(255, 128, 0)); + } + + #[test] + fn frame_data_rejects_mismatched_cell_count() { + let frame = FrameData { + cells: vec![ + CellData { + symbol: "X".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }; + 5 + ], // 5 cells but 3×2 = 6 expected + width: 3, + height: 2, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + assert!(frame.to_ratatui_buffer().is_none()); + } + + // ---- Color conversion coverage ---- + + #[test] + fn color_roundtrip_all_named_colors() { + let named = [ + Color::Reset, + Color::Black, + Color::Red, + Color::Green, + Color::Yellow, + Color::Blue, + Color::Magenta, + Color::Cyan, + Color::Gray, + Color::DarkGray, + Color::LightRed, + Color::LightGreen, + Color::LightYellow, + Color::LightBlue, + Color::LightMagenta, + Color::LightCyan, + Color::White, + ]; + for c in named { + assert_eq!( + u32_to_color(color_to_u32(c)), + c, + "roundtrip failed for {c:?}" + ); + } + } + + #[test] + fn color_roundtrip_indexed() { + for i in 0..=255u8 { + let c = Color::Indexed(i); + assert_eq!( + u32_to_color(color_to_u32(c)), + c, + "roundtrip failed for Indexed({i})" + ); + } + } + + #[test] + fn color_roundtrip_rgb() { + let c = Color::Rgb(0xAB, 0xCD, 0xEF); + assert_eq!(u32_to_color(color_to_u32(c)), c); + + let c = Color::Rgb(0, 0, 0); + assert_eq!(u32_to_color(color_to_u32(c)), c); + + let c = Color::Rgb(255, 255, 255); + assert_eq!(u32_to_color(color_to_u32(c)), c); + } + + // ---- Modifier conversion ---- + + #[test] + fn modifier_roundtrip() { + let all_mods = [ + Modifier::BOLD, + Modifier::ITALIC, + Modifier::REVERSED, + Modifier::UNDERLINED, + Modifier::DIM, + Modifier::SLOW_BLINK, + Modifier::CROSSED_OUT, + Modifier::BOLD | Modifier::ITALIC, + Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED, + Modifier::empty(), + ]; + for m in all_mods { + assert_eq!( + u16_to_modifier(modifier_to_u16(m)), + m, + "roundtrip failed for {m:?}" + ); + } + } + + #[test] + fn read_message_rejects_trailing_bytes() { + // Encode a valid message, then append an extra byte after it. + let msg = ClientMessage::Detach; + let mut payload = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let original_len = payload.len(); + payload.push(0xDE); // trailing garbage + + // Frame it with the inflated length (original + 1). + let mut buf = (payload.len() as u32).to_le_bytes().to_vec(); + buf.extend_from_slice(&payload); + + let result: Result = + read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); + match result { + Err(FramingError::Bincode(msg)) => { + assert!( + msg.contains("trailing bytes"), + "error should mention trailing bytes: {msg}" + ); + assert!( + msg.contains(&format!("decoded {original_len}")), + "error should mention decoded byte count: {msg}" + ); + } + other => panic!("expected Bincode error about trailing bytes, got: {other:?}"), + } + } + + #[test] + fn read_message_accepts_exact_payload() { + // A normally-framed message should decode without error. + let msg = ClientMessage::Hello { + version: PROTOCOL_VERSION, + cols: 80, + rows: 24, + cell_width_px: 8, + cell_height_px: 16, + requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, + }; + let mut buf = Vec::new(); + write_message(&mut buf, &msg).unwrap(); + let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn write_message_rejects_oversized_payload() { + // We can't easily create a message that exceeds u32::MAX in a test, + // but we can verify the check exists by testing that normal messages + // have lengths well within the limit and the function doesn't fail. + let msg = ClientMessage::Detach; + let mut buf = Vec::new(); + assert!(write_message(&mut buf, &msg).is_ok()); + } + + // ---- Unix socketpair integration test ---- + + #[test] + fn framing_over_unix_socketpair() { + use std::os::unix::net::UnixStream; + + let (mut a, mut b) = UnixStream::pair().expect("socketpair"); + + let messages = vec![ + ClientMessage::Hello { + version: PROTOCOL_VERSION, + cols: 200, + rows: 60, + cell_width_px: 8, + cell_height_px: 16, + requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, + }, + ClientMessage::Input { + data: b"hello world".to_vec(), + }, + ClientMessage::ClipboardImage { + extension: "png".to_owned(), + data: vec![0x89, b'P', b'N', b'G'], + }, + ClientMessage::Resize { + cols: 100, + rows: 30, + cell_width_px: 8, + cell_height_px: 16, + }, + ClientMessage::Detach, + ]; + + // Set non-blocking so we can write and read in the same test. + a.set_nonblocking(false).unwrap(); + b.set_nonblocking(false).unwrap(); + + for msg in &messages { + write_message(&mut a, msg).unwrap(); + } + + for expected in &messages { + let decoded: ClientMessage = read_message(&mut b, MAX_FRAME_SIZE).unwrap(); + assert_eq!(*expected, decoded); + } + } + + // ---- Helper: chunked reader for simulating partial reads ---- + + /// A `Read` wrapper that yields at most `chunk_size` bytes per `read()` call, + /// simulating partial reads on a real socket. + struct ChunkedReader { + data: Vec, + pos: usize, + chunk_size: usize, + } + + impl ChunkedReader { + fn new(data: Vec, chunk_size: usize) -> Self { + Self { + data, + pos: 0, + chunk_size, + } + } + } + + impl Read for ChunkedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.pos >= self.data.len() { + return Ok(0); + } + let remaining = self.data.len() - self.pos; + let to_read = buf.len().min(remaining).min(self.chunk_size); + buf[..to_read].copy_from_slice(&self.data[self.pos..self.pos + to_read]); + self.pos += to_read; + Ok(to_read) + } + } +} -- cgit v1.2.3