2030 lines
72 KiB
Rust
2030 lines
72 KiB
Rust
//! 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 = 15;
|
||
|
||
/// 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 },
|
||
}
|
||
|
||
/// Client behavior requested at connection time.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ClientLaunchMode {
|
||
/// Full app client.
|
||
App,
|
||
/// Direct terminal attach client.
|
||
TerminalAttach,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ClientKeyKind {
|
||
Press,
|
||
Repeat,
|
||
Release,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ClientKeyCode {
|
||
Backspace,
|
||
Enter,
|
||
Left,
|
||
Right,
|
||
Up,
|
||
Down,
|
||
Home,
|
||
End,
|
||
PageUp,
|
||
PageDown,
|
||
Tab,
|
||
BackTab,
|
||
Delete,
|
||
Insert,
|
||
Esc,
|
||
Char(char),
|
||
F(u8),
|
||
Null,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ClientMouseButton {
|
||
Left,
|
||
Right,
|
||
Middle,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ClientMouseKind {
|
||
Down(ClientMouseButton),
|
||
Up(ClientMouseButton),
|
||
Drag(ClientMouseButton),
|
||
Moved,
|
||
ScrollUp,
|
||
ScrollDown,
|
||
ScrollLeft,
|
||
ScrollRight,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ClientInputEvent {
|
||
Key {
|
||
code: ClientKeyCode,
|
||
modifiers: u8,
|
||
kind: ClientKeyKind,
|
||
},
|
||
Mouse {
|
||
kind: ClientMouseKind,
|
||
column: u16,
|
||
row: u16,
|
||
modifiers: u8,
|
||
},
|
||
Paste {
|
||
text: String,
|
||
},
|
||
FocusGained,
|
||
FocusLost,
|
||
}
|
||
|
||
impl ClientKeyKind {
|
||
#[cfg(any(windows, test))]
|
||
pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self {
|
||
match kind {
|
||
crossterm::event::KeyEventKind::Press => Self::Press,
|
||
crossterm::event::KeyEventKind::Repeat => Self::Repeat,
|
||
crossterm::event::KeyEventKind::Release => Self::Release,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn to_crossterm(self) -> crossterm::event::KeyEventKind {
|
||
match self {
|
||
Self::Press => crossterm::event::KeyEventKind::Press,
|
||
Self::Repeat => crossterm::event::KeyEventKind::Repeat,
|
||
Self::Release => crossterm::event::KeyEventKind::Release,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ClientKeyCode {
|
||
#[cfg(any(windows, test))]
|
||
pub(crate) fn from_crossterm(code: crossterm::event::KeyCode) -> Option<Self> {
|
||
use crossterm::event::KeyCode;
|
||
Some(match code {
|
||
KeyCode::Backspace => Self::Backspace,
|
||
KeyCode::Enter => Self::Enter,
|
||
KeyCode::Left => Self::Left,
|
||
KeyCode::Right => Self::Right,
|
||
KeyCode::Up => Self::Up,
|
||
KeyCode::Down => Self::Down,
|
||
KeyCode::Home => Self::Home,
|
||
KeyCode::End => Self::End,
|
||
KeyCode::PageUp => Self::PageUp,
|
||
KeyCode::PageDown => Self::PageDown,
|
||
KeyCode::Tab => Self::Tab,
|
||
KeyCode::BackTab => Self::BackTab,
|
||
KeyCode::Delete => Self::Delete,
|
||
KeyCode::Insert => Self::Insert,
|
||
KeyCode::Esc => Self::Esc,
|
||
KeyCode::Char(ch) => Self::Char(ch),
|
||
KeyCode::F(n) => Self::F(n),
|
||
KeyCode::Null => Self::Null,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn to_crossterm(&self) -> crossterm::event::KeyCode {
|
||
use crossterm::event::KeyCode;
|
||
match self {
|
||
Self::Backspace => KeyCode::Backspace,
|
||
Self::Enter => KeyCode::Enter,
|
||
Self::Left => KeyCode::Left,
|
||
Self::Right => KeyCode::Right,
|
||
Self::Up => KeyCode::Up,
|
||
Self::Down => KeyCode::Down,
|
||
Self::Home => KeyCode::Home,
|
||
Self::End => KeyCode::End,
|
||
Self::PageUp => KeyCode::PageUp,
|
||
Self::PageDown => KeyCode::PageDown,
|
||
Self::Tab => KeyCode::Tab,
|
||
Self::BackTab => KeyCode::BackTab,
|
||
Self::Delete => KeyCode::Delete,
|
||
Self::Insert => KeyCode::Insert,
|
||
Self::Esc => KeyCode::Esc,
|
||
Self::Char(ch) => KeyCode::Char(*ch),
|
||
Self::F(n) => KeyCode::F(*n),
|
||
Self::Null => KeyCode::Null,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ClientMouseButton {
|
||
#[cfg(any(windows, test))]
|
||
pub(crate) fn from_crossterm(button: crossterm::event::MouseButton) -> Self {
|
||
match button {
|
||
crossterm::event::MouseButton::Left => Self::Left,
|
||
crossterm::event::MouseButton::Right => Self::Right,
|
||
crossterm::event::MouseButton::Middle => Self::Middle,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn to_crossterm(self) -> crossterm::event::MouseButton {
|
||
match self {
|
||
Self::Left => crossterm::event::MouseButton::Left,
|
||
Self::Right => crossterm::event::MouseButton::Right,
|
||
Self::Middle => crossterm::event::MouseButton::Middle,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ClientMouseKind {
|
||
#[cfg(any(windows, test))]
|
||
pub(crate) fn from_crossterm(kind: crossterm::event::MouseEventKind) -> Option<Self> {
|
||
use crossterm::event::MouseEventKind;
|
||
Some(match kind {
|
||
MouseEventKind::Down(button) => Self::Down(ClientMouseButton::from_crossterm(button)),
|
||
MouseEventKind::Up(button) => Self::Up(ClientMouseButton::from_crossterm(button)),
|
||
MouseEventKind::Drag(button) => Self::Drag(ClientMouseButton::from_crossterm(button)),
|
||
MouseEventKind::Moved => Self::Moved,
|
||
MouseEventKind::ScrollUp => Self::ScrollUp,
|
||
MouseEventKind::ScrollDown => Self::ScrollDown,
|
||
MouseEventKind::ScrollLeft => Self::ScrollLeft,
|
||
MouseEventKind::ScrollRight => Self::ScrollRight,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn to_crossterm(self) -> crossterm::event::MouseEventKind {
|
||
use crossterm::event::MouseEventKind;
|
||
match self {
|
||
Self::Down(button) => MouseEventKind::Down(button.to_crossterm()),
|
||
Self::Up(button) => MouseEventKind::Up(button.to_crossterm()),
|
||
Self::Drag(button) => MouseEventKind::Drag(button.to_crossterm()),
|
||
Self::Moved => MouseEventKind::Moved,
|
||
Self::ScrollUp => MouseEventKind::ScrollUp,
|
||
Self::ScrollDown => MouseEventKind::ScrollDown,
|
||
Self::ScrollLeft => MouseEventKind::ScrollLeft,
|
||
Self::ScrollRight => MouseEventKind::ScrollRight,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ClientInputEvent {
|
||
#[cfg(windows)]
|
||
pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option<Self> {
|
||
match event {
|
||
crossterm::event::Event::Key(key) => Some(Self::Key {
|
||
code: ClientKeyCode::from_crossterm(key.code)?,
|
||
modifiers: key.modifiers.bits(),
|
||
kind: ClientKeyKind::from_crossterm(key.kind),
|
||
}),
|
||
crossterm::event::Event::Mouse(mouse) => Some(Self::Mouse {
|
||
kind: ClientMouseKind::from_crossterm(mouse.kind)?,
|
||
column: mouse.column,
|
||
row: mouse.row,
|
||
modifiers: mouse.modifiers.bits(),
|
||
}),
|
||
crossterm::event::Event::Paste(text) => Some(Self::Paste { text }),
|
||
crossterm::event::Event::FocusGained => Some(Self::FocusGained),
|
||
crossterm::event::Event::FocusLost => Some(Self::FocusLost),
|
||
crossterm::event::Event::Resize(_, _) => None,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn to_raw_input_event(&self) -> crate::raw_input::RawInputEvent {
|
||
match self {
|
||
Self::Key {
|
||
code,
|
||
modifiers,
|
||
kind,
|
||
} => crate::raw_input::RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(
|
||
code.to_crossterm(),
|
||
crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
|
||
)
|
||
.with_kind(kind.to_crossterm()),
|
||
),
|
||
Self::Mouse {
|
||
kind,
|
||
column,
|
||
row,
|
||
modifiers,
|
||
} => crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: kind.to_crossterm(),
|
||
column: *column,
|
||
row: *row,
|
||
modifiers: crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
|
||
}),
|
||
Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()),
|
||
Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained,
|
||
Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
/// Whether this connection will render the full app or attach directly to a pane terminal.
|
||
launch_mode: ClientLaunchMode,
|
||
},
|
||
|
||
/// Raw input bytes read from the client's stdin.
|
||
Input {
|
||
/// Raw terminal input (possibly multi-byte escape sequences).
|
||
data: Vec<u8>,
|
||
},
|
||
|
||
/// 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<u8>,
|
||
},
|
||
|
||
/// 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,
|
||
},
|
||
|
||
/// Scroll input handled by a direct terminal attach client.
|
||
AttachScroll {
|
||
/// Original input source for routing.
|
||
source: AttachScrollSource,
|
||
/// Scroll direction.
|
||
direction: AttachScrollDirection,
|
||
/// Number of terminal rows to move when using host scrollback.
|
||
lines: u16,
|
||
/// Mouse column relative to the attached terminal, when available.
|
||
column: Option<u16>,
|
||
/// Mouse row relative to the attached terminal, when available.
|
||
row: Option<u16>,
|
||
/// Crossterm-compatible modifier bits for forwarded mouse wheel events.
|
||
modifiers: u8,
|
||
},
|
||
|
||
/// Structured input events from platform clients that do not expose Unix-style raw bytes.
|
||
InputEvents { events: Vec<ClientInputEvent> },
|
||
|
||
/// Switch this connection into read-only terminal observe mode.
|
||
ObserveTerminal {
|
||
/// Pane, terminal, or agent target to observe.
|
||
target: String,
|
||
},
|
||
|
||
/// Switch this connection into writable terminal control mode.
|
||
ControlTerminal {
|
||
/// Pane, terminal, or agent target to control.
|
||
target: String,
|
||
/// Replace an existing writable controller for this terminal.
|
||
takeover: bool,
|
||
},
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum AttachScrollDirection {
|
||
Up,
|
||
Down,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum AttachScrollSource {
|
||
Wheel,
|
||
PageKey {
|
||
/// Original key bytes to forward when the child application owns page keys.
|
||
input: Vec<u8>,
|
||
},
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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.) plus Herdr extension bits.
|
||
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<u32>,
|
||
}
|
||
|
||
/// 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<CellData>,
|
||
/// Frame width in columns.
|
||
pub width: u16,
|
||
/// Frame height in rows.
|
||
pub height: u16,
|
||
/// Cursor state for this frame, if applicable.
|
||
pub cursor: Option<CursorState>,
|
||
/// OSC 8 hyperlink URIs referenced by cells.
|
||
pub hyperlinks: Vec<String>,
|
||
/// Kitty graphics protocol bytes to apply after the text frame.
|
||
pub graphics: Vec<u8>,
|
||
}
|
||
|
||
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<CursorState>,
|
||
) -> Self {
|
||
Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[])
|
||
}
|
||
|
||
pub fn from_ratatui_buffer_with_hyperlinks(
|
||
buffer: &ratatui::buffer::Buffer,
|
||
cursor: Option<CursorState>,
|
||
hyperlinks: &[((u16, u16), String, String)],
|
||
) -> Self {
|
||
let area = buffer.area;
|
||
let width = area.width;
|
||
let height = area.height;
|
||
|
||
let mut hyperlink_uris = Vec::<String>::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<ratatui::buffer::Buffer> {
|
||
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<u8>,
|
||
}
|
||
|
||
/// 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<String>,
|
||
},
|
||
|
||
/// 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<u8>,
|
||
},
|
||
|
||
/// Server is shutting down. Clients should exit gracefully.
|
||
ServerShutdown {
|
||
/// Optional reason for the shutdown.
|
||
reason: Option<String>,
|
||
},
|
||
|
||
/// A notification event (sound/toast) to be rendered locally by the client.
|
||
Notify {
|
||
/// What kind of notification.
|
||
kind: NotifyKind,
|
||
/// Human-readable title or sound label.
|
||
message: String,
|
||
/// Optional human-readable notification body.
|
||
body: Option<String>,
|
||
},
|
||
|
||
/// OSC 52 clipboard data forwarded from a PTY through the server.
|
||
Clipboard {
|
||
/// Base64-encoded clipboard data.
|
||
data: String,
|
||
},
|
||
|
||
/// Set the foreground client's outer terminal window title.
|
||
WindowTitle {
|
||
/// Sanitized title to write with OSC 0. `None` restores Herdr's default title.
|
||
title: Option<String>,
|
||
},
|
||
|
||
/// Client-local runtime 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
|
||
pub(crate) 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
|
||
}
|
||
}
|
||
|
||
const UNDERLINE_STYLE_SHIFT: u16 = 12;
|
||
const UNDERLINE_STYLE_MASK: u16 = 0xF000;
|
||
|
||
/// Converts a ratatui `Modifier` bitmask to a u16 for wire transport.
|
||
pub(crate) fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 {
|
||
modifier.bits()
|
||
}
|
||
|
||
pub(crate) fn underline_style_from_modifier(modifier: u16) -> u8 {
|
||
((modifier & UNDERLINE_STYLE_MASK) >> UNDERLINE_STYLE_SHIFT) as u8
|
||
}
|
||
|
||
pub(crate) fn modifier_with_underline_style(
|
||
modifier: ratatui::style::Modifier,
|
||
underline_style: u8,
|
||
) -> ratatui::style::Modifier {
|
||
let bits = modifier.bits() | ((u16::from(underline_style) & 0x0F) << UNDERLINE_STYLE_SHIFT);
|
||
ratatui::style::Modifier::from_bits_retain(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 & !UNDERLINE_STYLE_MASK)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<io::Error> 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<W: Write, M: Serialize>(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<R: Read, M: for<'de> Deserialize<'de>>(
|
||
reader: &mut R,
|
||
max_frame_size: usize,
|
||
) -> Result<M, FramingError> {
|
||
// 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<R: Read>(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,
|
||
launch_mode: ClientLaunchMode::App,
|
||
};
|
||
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_message_wire_tags_preserve_protocol_15_order() {
|
||
fn tag(msg: &ClientMessage) -> u8 {
|
||
*bincode::serde::encode_to_vec(msg, bincode::config::standard())
|
||
.unwrap()
|
||
.first()
|
||
.expect("encoded client message should include enum tag")
|
||
}
|
||
|
||
assert_eq!(
|
||
tag(&ClientMessage::Hello {
|
||
version: PROTOCOL_VERSION,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 8,
|
||
cell_height_px: 16,
|
||
requested_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: ClientKeybindings::Server,
|
||
launch_mode: ClientLaunchMode::App,
|
||
}),
|
||
0
|
||
);
|
||
assert_eq!(tag(&ClientMessage::Input { data: Vec::new() }), 1);
|
||
assert_eq!(
|
||
tag(&ClientMessage::ClipboardImage {
|
||
extension: "png".to_owned(),
|
||
data: Vec::new(),
|
||
}),
|
||
2
|
||
);
|
||
assert_eq!(
|
||
tag(&ClientMessage::Resize {
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 8,
|
||
cell_height_px: 16,
|
||
}),
|
||
3
|
||
);
|
||
assert_eq!(tag(&ClientMessage::Detach), 4);
|
||
assert_eq!(
|
||
tag(&ClientMessage::AttachTerminal {
|
||
terminal_id: "term".to_owned(),
|
||
takeover: false,
|
||
}),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
tag(&ClientMessage::AttachScroll {
|
||
source: AttachScrollSource::Wheel,
|
||
direction: AttachScrollDirection::Up,
|
||
lines: 1,
|
||
column: None,
|
||
row: None,
|
||
modifiers: 0,
|
||
}),
|
||
6
|
||
);
|
||
assert_eq!(tag(&ClientMessage::InputEvents { events: Vec::new() }), 7);
|
||
assert_eq!(
|
||
tag(&ClientMessage::ObserveTerminal {
|
||
target: "w1:p1".to_owned(),
|
||
}),
|
||
8
|
||
);
|
||
assert_eq!(
|
||
tag(&ClientMessage::ControlTerminal {
|
||
target: "w1:p1".to_owned(),
|
||
takeover: false,
|
||
}),
|
||
9
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn client_input_events_roundtrip() {
|
||
let msg = ClientMessage::InputEvents {
|
||
events: vec![
|
||
ClientInputEvent::Key {
|
||
code: ClientKeyCode::Char('N'),
|
||
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
|
||
kind: ClientKeyKind::Press,
|
||
},
|
||
ClientInputEvent::Key {
|
||
code: ClientKeyCode::Backspace,
|
||
modifiers: 0,
|
||
kind: ClientKeyKind::Press,
|
||
},
|
||
ClientInputEvent::Mouse {
|
||
kind: ClientMouseKind::Down(ClientMouseButton::Left),
|
||
column: 3,
|
||
row: 4,
|
||
modifiers: 0,
|
||
},
|
||
],
|
||
};
|
||
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_events_convert_to_raw_keys() {
|
||
let shifted = ClientInputEvent::Key {
|
||
code: ClientKeyCode::Char('N'),
|
||
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
|
||
kind: ClientKeyKind::Press,
|
||
}
|
||
.to_raw_input_event();
|
||
match shifted {
|
||
crate::raw_input::RawInputEvent::Key(key) => {
|
||
assert_eq!(key.code, crossterm::event::KeyCode::Char('N'));
|
||
assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT);
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||
}
|
||
other => panic!("expected shifted key event, got {other:?}"),
|
||
}
|
||
|
||
let backspace = ClientInputEvent::Key {
|
||
code: ClientKeyCode::Backspace,
|
||
modifiers: 0,
|
||
kind: ClientKeyKind::Press,
|
||
}
|
||
.to_raw_input_event();
|
||
match backspace {
|
||
crate::raw_input::RawInputEvent::Key(key) => {
|
||
assert_eq!(key.code, crossterm::event::KeyCode::Backspace);
|
||
assert_eq!(key.modifiers, crossterm::event::KeyModifiers::empty());
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||
}
|
||
other => panic!("expected backspace key event, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[test]
|
||
fn client_observe_terminal_roundtrip() {
|
||
let msg = ClientMessage::ObserveTerminal {
|
||
target: "w1:p1".to_owned(),
|
||
};
|
||
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_control_terminal_roundtrip() {
|
||
let msg = ClientMessage::ControlTerminal {
|
||
target: "w1:p1".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);
|
||
}
|
||
|
||
#[test]
|
||
fn client_attach_scroll_roundtrip() {
|
||
let msg = ClientMessage::AttachScroll {
|
||
source: AttachScrollSource::Wheel,
|
||
direction: AttachScrollDirection::Up,
|
||
lines: 3,
|
||
column: Some(12),
|
||
row: Some(7),
|
||
modifiers: 4,
|
||
};
|
||
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(),
|
||
body: 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_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_window_title_roundtrip() {
|
||
for title in [Some("herdr api".to_owned()), None] {
|
||
let msg = ServerMessage::WindowTitle { title };
|
||
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,
|
||
launch_mode: ClientLaunchMode::App,
|
||
};
|
||
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<CellData> = (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,
|
||
launch_mode: ClientLaunchMode::App,
|
||
},
|
||
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<u8> = (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<ClientMessage, FramingError> =
|
||
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<ClientMessage, FramingError> =
|
||
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<u8> = 100u32.to_le_bytes().to_vec();
|
||
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
|
||
|
||
let result: Result<ClientMessage, FramingError> =
|
||
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<u8> = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim
|
||
buf.extend_from_slice(&[0; 8]);
|
||
|
||
let result: Result<ClientMessage, FramingError> =
|
||
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<u8> = (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<ClientMessage, FramingError> =
|
||
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<ClientMessage, FramingError> = 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<ClientMessage, FramingError> =
|
||
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,
|
||
launch_mode: ClientLaunchMode::App,
|
||
};
|
||
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 ----
|
||
|
||
#[cfg(unix)]
|
||
#[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,
|
||
launch_mode: ClientLaunchMode::App,
|
||
},
|
||
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<u8>,
|
||
pos: usize,
|
||
chunk_size: usize,
|
||
}
|
||
|
||
impl ChunkedReader {
|
||
fn new(data: Vec<u8>, chunk_size: usize) -> Self {
|
||
Self {
|
||
data,
|
||
pos: 0,
|
||
chunk_size,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Read for ChunkedReader {
|
||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||
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)
|
||
}
|
||
}
|
||
}
|