diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema.rs | 2 | ||||
| -rw-r--r-- | src/client/blit.rs | 66 | ||||
| -rw-r--r-- | src/client/mod.rs | 89 | ||||
| -rw-r--r-- | src/main.rs | 35 | ||||
| -rw-r--r-- | src/remote.rs | 363 | ||||
| -rw-r--r-- | src/server/headless.rs | 515 | ||||
| -rw-r--r-- | src/server/protocol.rs | 64 |
7 files changed, 1032 insertions, 102 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs index c9467d5a..fc3cc68f 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -941,7 +941,7 @@ mod tests { id: "req_1".into(), result: ResponseResult::Pong { version: "0.1.2".into(), - protocol: 3, + protocol: 4, }, }; diff --git a/src/client/blit.rs b/src/client/blit.rs index 3d562563..a206f97e 100644 --- a/src/client/blit.rs +++ b/src/client/blit.rs @@ -24,12 +24,62 @@ //! and minimize cursor movement. use std::cmp; -use std::io::{self, Write}; +use std::io::Write; use unicode_width::UnicodeWidthStr; use crate::server::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<u8>, + /// Whether this frame was encoded as a full redraw. + pub(crate) full: bool, + next_last_visible_cursor: Option<(u16, u16)>, +} + +/// Stateful encoder that diffs semantic frames into terminal ANSI bytes. +#[derive(Default)] +pub(crate) struct BlitEncoder { + last_frame: Option<FrameData>, + last_visible_cursor: Option<(u16, u16)>, +} + +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; + blit_frame_to_with_cursor_memory(&mut bytes, frame, prev, &mut next_last_visible_cursor); + EncodedBlit { + bytes, + full, + next_last_visible_cursor, + } + } + + pub(crate) fn commit(&mut self, frame: FrameData, encoded: EncodedBlit) { + self.last_visible_cursor = encoded.next_last_visible_cursor; + self.last_frame = Some(frame); + } + + pub(crate) fn is_current(&self, frame: &FrameData) -> bool { + self.last_frame.as_ref() == Some(frame) + } +} + // --------------------------------------------------------------------------- // Color → escape sequence // --------------------------------------------------------------------------- @@ -190,20 +240,6 @@ fn cells_equal(a: &CellData, b: &CellData) -> bool { // Blitting // --------------------------------------------------------------------------- -/// Blits a frame to the terminal, diffing against the previous frame. -/// -/// On the first frame (no previous), does a full redraw. -/// On subsequent frames, only writes cells that changed. -/// After writing cells, positions the cursor. -pub fn blit_frame_with_cursor_memory( - frame: &FrameData, - prev: Option<&FrameData>, - last_visible_cursor: &mut Option<(u16, u16)>, -) { - let mut stdout = io::stdout(); - blit_frame_to_with_cursor_memory(&mut stdout, frame, prev, last_visible_cursor); -} - /// 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>) { diff --git a/src/client/mod.rs b/src/client/mod.rs index 5b6c1bf1..851066eb 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -12,7 +12,7 @@ //! - Forwards OSC 52 clipboard writes from server to its own stdout //! - Displays sound/toast notifications forwarded from server -mod blit; +pub(crate) mod blit; mod input; use std::io::{self, Write as _}; @@ -31,7 +31,8 @@ use tracing::{debug, info, warn}; use crate::server::headless::client_socket_path; use crate::server::protocol::{ - self, ClientMessage, FrameData, NotifyKind, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, + PROTOCOL_VERSION, }; // --------------------------------------------------------------------------- @@ -40,11 +41,8 @@ use crate::server::protocol::{ /// State tracking for the thin client. struct ClientState { - /// The last frame we rendered, used for diff-based blitting. - last_frame: Option<FrameData>, - /// Last visible cursor exported by the focused pane, used as the hidden - /// cursor parking position when the pane temporarily hides its cursor. - last_visible_cursor: Option<(u16, u16)>, + /// Stateful semantic-frame encoder used when the server sends FrameData. + blit_encoder: blit::BlitEncoder, /// The terminal size we reported to the server in our last Hello/Resize. reported_size: (u16, u16), /// Client-local sound playback config, refreshed on server request. @@ -198,16 +196,33 @@ impl Drop for TerminalGuard { // Handshake // --------------------------------------------------------------------------- +fn requested_render_encoding() -> RenderEncoding { + match std::env::var("HERDR_RENDER_ENCODING").ok().as_deref() { + Some("terminal-ansi" | "terminal_ansi" | "ansi") => RenderEncoding::TerminalAnsi, + _ => RenderEncoding::SemanticFrame, + } +} + /// Performs the client→server handshake. /// /// Sends Hello with the terminal size and protocol version, reads the Welcome /// response. Returns Ok(()) on success, or an error if the server rejects us. -fn do_handshake(stream: &mut UnixStream, cols: u16, rows: u16) -> Result<(), ClientError> { +fn do_handshake( + stream: &mut UnixStream, + cols: u16, + rows: u16, + requested_encoding: RenderEncoding, +) -> Result<RenderEncoding, ClientError> { + stream + .set_nonblocking(false) + .map_err(ClientError::ConnectionFailed)?; + // Send Hello. let hello = ClientMessage::Hello { version: PROTOCOL_VERSION, cols, rows, + requested_encoding, }; protocol::write_message(stream, &hello).map_err(|e| { ClientError::ConnectionFailed(io::Error::new(io::ErrorKind::Other, e.to_string())) @@ -223,12 +238,16 @@ fn do_handshake(stream: &mut UnixStream, cols: u16, rows: u16) -> Result<(), Cli .map_err(ClientError::ConnectionFailed)?; match welcome { - ServerMessage::Welcome { version, error } => { + ServerMessage::Welcome { + version, + encoding, + error, + } => { if let Some(error) = error { return Err(ClientError::HandshakeRejected { version, error }); } - info!(version, "handshake succeeded"); - Ok(()) + info!(version, ?encoding, "handshake succeeded"); + Ok(encoding) } _ => Err(ClientError::Protocol(protocol::FramingError::Io( io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), @@ -282,11 +301,16 @@ pub fn run_client() -> io::Result<()> { // Get the terminal size before handshake (before raw mode). let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + let requested_encoding = requested_render_encoding(); + // Perform handshake while the stream is still in blocking mode. - if let Err(err) = do_handshake(&mut stream, cols, rows) { - eprintln!("herdr: {err}"); - std::process::exit(1); - } + let negotiated_encoding = match do_handshake(&mut stream, cols, rows, requested_encoding) { + Ok(encoding) => encoding, + Err(err) => { + eprintln!("herdr: {err}"); + std::process::exit(1); + } + }; // Now set up the terminal (raw mode, mouse, keyboard enhancements). // This must happen AFTER the handshake succeeds, so we don't leave @@ -318,8 +342,17 @@ pub fn run_client() -> io::Result<()> { quit_flag.store(true, Ordering::Release); }); - let result = - rt.block_on(async { run_client_loop(stream, cols, rows, should_quit, sound_config).await }); + let result = rt.block_on(async { + run_client_loop( + stream, + cols, + rows, + should_quit, + sound_config, + negotiated_encoding, + ) + .await + }); // Restore the terminal before printing any final status message. drop(_guard); @@ -359,13 +392,14 @@ async fn run_client_loop( rows: u16, should_quit: Arc<AtomicBool>, sound_config: crate::config::SoundConfig, + negotiated_encoding: RenderEncoding, ) -> Result<(), ClientError> { let mut state = ClientState { - last_frame: None, - last_visible_cursor: None, + blit_encoder: blit::BlitEncoder::new(), reported_size: (cols, rows), sound_config, }; + debug!(?negotiated_encoding, "client render encoding active"); // Channel for events from the stdin, resize, and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); @@ -428,13 +462,16 @@ async fn run_client_loop( } ClientLoopEvent::ServerMessage(msg) => match msg { ServerMessage::Frame(frame_data) => { - blit::blit_frame_with_cursor_memory( - &frame_data, - state.last_frame.as_ref(), - &mut state.last_visible_cursor, - ); - state.last_frame = Some(frame_data); - let _ = io::stdout().flush(); + let encoded = state.blit_encoder.encode(&frame_data, false); + let mut stdout = io::stdout(); + let _ = stdout.write_all(&encoded.bytes); + let _ = stdout.flush(); + state.blit_encoder.commit(frame_data, encoded); + } + ServerMessage::Terminal(frame) => { + let mut stdout = io::stdout(); + let _ = stdout.write_all(&frame.bytes); + let _ = stdout.flush(); } ServerMessage::ServerShutdown { reason } => { return Err(ClientError::ServerShutdown { reason }); diff --git a/src/main.rs b/src/main.rs index 45ac3db1..ee2c30e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ mod persist; mod platform; mod raw_input; mod release_notes; +mod remote; mod selection; mod server; mod session; @@ -173,12 +174,38 @@ fn main() -> io::Result<()> { std::process::exit(2); } }; + let (args, remote_launch) = match remote::extract_remote_args(&args) { + Ok(parsed) => parsed, + Err(err) => { + eprintln!("error: {err}"); + eprintln!("run 'herdr --help' for usage"); + std::process::exit(2); + } + }; + + if remote_launch.is_some() + && args.get(1).is_some() + && !args.iter().any(|a| { + matches!( + a.as_str(), + "--help" | "-h" | "--version" | "-V" | "--default-config" + ) + }) + { + eprintln!("error: --remote can only be used with the default launch command"); + eprintln!("run 'herdr --help' for usage"); + std::process::exit(2); + } if let cli::CommandOutcome::Handled(code) = cli::maybe_run(&args)? { std::process::exit(code); } // Subcommands and flags (no TUI, no logging needed) + if args.get(1).map(|s| s.as_str()) == Some("remote-client-bridge") { + return remote::run_remote_client_bridge(); + } + if args.get(1).map(|s| s.as_str()) == Some("server") { return server::headless::run_server(); } @@ -203,6 +230,7 @@ fn main() -> io::Result<()> { println!(); println!("Usage: herdr [options]"); println!(" herdr --session <name> [options]"); + println!(" herdr --remote <ssh-target> [--session <name>]"); println!(" herdr session attach <name>"); println!(" herdr update"); println!(" herdr server stop"); @@ -270,6 +298,7 @@ fn main() -> io::Result<()> { println!("Options:"); println!(" --no-session Run monolithically (no server/client, escape hatch)"); println!(" --session <name> Use or create a named persistent session"); + println!(" --remote <target> Attach through SSH to a remote Herdr server"); println!(" --default-config Print default configuration and exit"); println!(" --version, -V Print version and exit"); println!(" --help, -h Show this help"); @@ -295,6 +324,7 @@ fn main() -> io::Result<()> { let known_flags = [ "--no-session", "--session", + "--remote", "--version", "-V", "--default-config", @@ -311,6 +341,7 @@ fn main() -> io::Result<()> { && ![ "server", "client", + "remote-client-bridge", "update", "status", "workspace", @@ -327,6 +358,10 @@ fn main() -> io::Result<()> { } } + if let Some(remote_launch) = remote_launch { + return remote::run_remote(remote_launch); + } + let loaded_config = config::Config::load(); if should_block_nested(&loaded_config.config) { eprintln!("\x1b[1merror:\x1b[0m nested herdr is disabled by default."); diff --git a/src/remote.rs b/src/remote.rs new file mode 100644 index 00000000..a1344031 --- /dev/null +++ b/src/remote.rs @@ -0,0 +1,363 @@ +//! Remote thin-client launcher over SSH command stdio. + +use std::io; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); +const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteLaunch { + pub(crate) target: String, +} + +pub(crate) fn extract_remote_args( + args: &[String], +) -> Result<(Vec<String>, Option<RemoteLaunch>), String> { + let mut cleaned = Vec::with_capacity(args.len()); + if let Some(program) = args.first() { + cleaned.push(program.clone()); + } + + let mut remote = None; + let mut index = 1; + while index < args.len() { + let arg = &args[index]; + if arg == "--remote" { + if remote.is_some() { + return Err("--remote can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote".to_string()); + }; + remote = Some(RemoteLaunch { + target: validate_remote_target(value)?.to_owned(), + }); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote=") { + if remote.is_some() { + return Err("--remote can only be specified once".to_string()); + } + remote = Some(RemoteLaunch { + target: validate_remote_target(value)?.to_owned(), + }); + index += 1; + continue; + } + + cleaned.push(arg.clone()); + index += 1; + } + + Ok((cleaned, remote)) +} + +fn validate_remote_target(target: &str) -> Result<&str, String> { + if target.is_empty() { + return Err("missing value for --remote".to_string()); + } + if target.starts_with('-') { + return Err("--remote target must not start with '-'".to_string()); + } + Ok(target) +} + +pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { + let session_name = crate::session::active_name() + .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()); + let local_socket = local_forward_socket_path(&remote.target, &session_name); + + let _bridge = SshStdioBridge::start(remote.target, local_socket.clone(), session_name)?; + + run_client_process(&local_socket) +} + +pub(crate) fn run_remote_client_bridge() -> io::Result<()> { + let socket_path = crate::server::headless::client_socket_path(); + let stream = UnixStream::connect(&socket_path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to connect to remote Herdr client socket {}: {err}", + socket_path.display() + ), + ) + })?; + + let mut stdout = io::stdout().lock(); + let mut socket_to_stdout = stream.try_clone()?; + let mut stdin_to_socket = stream; + + let _upload = thread::spawn(move || { + let mut stdin = io::stdin(); + let _ = copy_flush(&mut stdin, &mut stdin_to_socket); + let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write); + }); + + copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ()) +} + +struct SshStdioBridge { + local_socket: PathBuf, + should_stop: Arc<AtomicBool>, + thread: Option<JoinHandle<()>>, +} + +impl SshStdioBridge { + fn start(target: String, local_socket: PathBuf, session_name: String) -> io::Result<Self> { + let _ = std::fs::remove_file(&local_socket); + let listener = UnixListener::bind(&local_socket)?; + crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?; + listener.set_nonblocking(true)?; + + let should_stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&should_stop); + let thread = thread::spawn(move || { + while !thread_stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _addr)) => { + if let Err(err) = stream.set_nonblocking(false) { + eprintln!( + "herdr: remote bridge failed to prepare client socket: {err}" + ); + continue; + } + if let Err(err) = bridge_connection(stream, &target, &session_name) { + eprintln!("herdr: remote bridge failed: {err}"); + } + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(BRIDGE_ACCEPT_POLL); + } + Err(err) => { + eprintln!("herdr: remote bridge listener failed: {err}"); + break; + } + } + } + }); + + Ok(Self { + local_socket, + should_stop, + thread: Some(thread), + }) + } +} + +impl Drop for SshStdioBridge { + fn drop(&mut self) { + self.should_stop.store(true, Ordering::Release); + let _ = std::fs::remove_file(&self.local_socket); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn bridge_connection(stream: UnixStream, target: &str, session_name: &str) -> io::Result<()> { + let mut command = Command::new("ssh"); + command.arg("-T").arg(target).arg("herdr"); + if session_name != crate::session::DEFAULT_SESSION_NAME { + command.arg("--session").arg(session_name); + } + command + .arg("remote-client-bridge") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = command + .spawn() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; + let mut child_stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?; + let mut child_stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?; + let mut stream_to_child = stream.try_clone()?; + let mut child_to_stream = stream; + + let upload = thread::spawn(move || { + let _ = copy_flush(&mut stream_to_child, &mut child_stdin); + }); + let download = thread::spawn(move || { + let _ = copy_flush(&mut child_stdout, &mut child_to_stream); + let _ = child_to_stream.shutdown(std::net::Shutdown::Write); + }); + + let status = child.wait()?; + let _ = upload.join(); + let _ = download.join(); + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + format!("ssh bridge exited with {status}"), + )) + } +} + +fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + + loop { + let bytes_read = match reader.read(&mut buffer) { + Ok(0) => return Ok(total), + Ok(bytes_read) => bytes_read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + + writer.write_all(&buffer[..bytes_read])?; + writer.flush()?; + total += bytes_read as u64; + } +} + +fn run_client_process(local_socket: &Path) -> io::Result<()> { + let exe = std::env::current_exe()?; + let status = Command::new(exe) + .arg("client") + .env( + crate::server::headless::CLIENT_SOCKET_PATH_ENV_VAR, + local_socket, + ) + .env("HERDR_RENDER_ENCODING", "terminal-ansi") + .env_remove(crate::api::SOCKET_PATH_ENV_VAR) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Interrupted, + format!("remote client exited with {status}"), + )) + } +} + +fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "herdr-remote-{}-{}-{}.sock", + std::process::id(), + sanitize_path_component(target), + sanitize_path_component(session_name) + )) +} + +fn sanitize_path_component(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + ch + } else { + '-' + } + }) + .collect(); + + sanitized.trim_matches('-').chars().take(32).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_socket_is_user_only() { + use std::os::unix::fs::PermissionsExt; + + let socket = std::env::temp_dir().join(format!( + "herdr-bridge-permissions-test-{}.sock", + std::process::id() + )); + let bridge = + SshStdioBridge::start("example".to_string(), socket.clone(), "default".to_string()) + .expect("start bridge listener"); + + let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE); + + drop(bridge); + let _ = std::fs::remove_file(socket); + } + + #[test] + fn extract_remote_args_removes_space_form() { + let args = vec![ + "herdr".into(), + "--remote".into(), + "dev".into(), + "--help".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr", "--help"]); + assert_eq!(remote.unwrap().target, "dev"); + } + + #[test] + fn extract_remote_args_removes_equals_form() { + let args = vec!["herdr".into(), "--remote=user@host".into()]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + assert_eq!(remote.unwrap().target, "user@host"); + } + + #[test] + fn extract_remote_args_requires_value() { + let args = vec!["herdr".into(), "--remote".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "missing value for --remote"); + } + + #[test] + fn extract_remote_args_rejects_empty_value() { + let args = vec!["herdr".into(), "--remote=".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "missing value for --remote"); + } + + #[test] + fn extract_remote_args_rejects_duplicate_values() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote=prod".into(), + ]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote can only be specified once"); + } + + #[test] + fn extract_remote_args_rejects_option_like_target() { + let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote target must not start with '-'"); + } + + #[test] + fn sanitize_path_component_removes_shell_sensitive_chars() { + assert_eq!(sanitize_path_component("user@host:22"), "user-host-22"); + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 5fc7a544..463d37a4 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -34,12 +34,14 @@ use crate::api; use crate::app; use crate::app::state::AppState; use crate::app::Mode; +use crate::client::blit::BlitEncoder; use crate::config; use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::PaneId; use crate::server::protocol::{ - self, ClientMessage, CursorState, FrameData, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame, + MAX_FRAME_SIZE, PROTOCOL_VERSION, }; // --------------------------------------------------------------------------- @@ -76,7 +78,7 @@ const MIN_CLIENT_ROWS: u16 = 1; /// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`. /// This variable is kept as a fallback for callers that explicitly need a /// client-only override when `HERDR_SOCKET_PATH` is not set. -const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH"; +pub const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH"; /// Socket permission mode (owner read/write only). const SOCKET_PERMISSION_MODE: u32 = 0o600; @@ -213,6 +215,15 @@ pub fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) { // Connected client state // --------------------------------------------------------------------------- +/// Channels owned by the server side of a client writer thread. +#[derive(Clone, Debug)] +pub(crate) struct ClientWriter { + /// Reliable control messages such as shutdown, notifications, and clipboard writes. + control: std::sync::mpsc::Sender<Vec<u8>>, + /// Droppable render messages. Capacity is one so slow clients cannot build lag. + render: std::sync::mpsc::SyncSender<Vec<u8>>, +} + /// A connected client tracked by the server. struct ClientConnection { /// The client's terminal size (after clamping). @@ -223,10 +234,18 @@ struct ClientConnection { outer_terminal_focus: Option<bool>, /// Monotonic activity stamp used to choose the fallback foreground client. last_activity: u64, - /// Last frame sent to this client. Used to skip identical frame sends. + /// Render encoding negotiated for this client. + render_encoding: RenderEncoding, + /// Last semantic frame sent to this client. Used to skip identical frame sends. last_frame: Option<FrameData>, - /// Channel for sending framed ServerMessage data to the client writer thread. - writer: Option<std::sync::mpsc::Sender<Vec<u8>>>, + /// Terminal-ANSI encoder and baseline for this client. + blit_encoder: BlitEncoder, + /// Monotonic render sequence for this client. + frame_seq: u64, + /// Whether a render was skipped because the render channel was full. + render_pending: bool, + /// Channels for sending framed ServerMessage data to the client writer thread. + writer: Option<ClientWriter>, } // --------------------------------------------------------------------------- @@ -662,6 +681,7 @@ impl HeadlessServer { // even if the next rendered buffer compares equal to its cached frame. for client in self.clients.values_mut() { client.last_frame = None; + client.blit_encoder = BlitEncoder::new(); } } @@ -1165,7 +1185,7 @@ impl HeadlessServer { let mut broken_clients: Vec<u64> = Vec::new(); for (&client_id, client) in &mut self.clients { if let Some(writer) = &client.writer { - if writer.send(serialized.clone()).is_err() { + if writer.control.send(serialized.clone()).is_err() { debug!(client_id, "client writer channel closed during broadcast"); broken_clients.push(client_id); } @@ -1202,7 +1222,7 @@ impl HeadlessServer { if let Some(client) = self.clients.get(&client_id) { if let Some(writer) = &client.writer { - if writer.send(serialized).is_err() { + if writer.control.send(serialized).is_err() { debug!( client_id, "client writer channel closed during targeted send" @@ -1228,8 +1248,9 @@ impl HeadlessServer { cols, rows, writer, + render_encoding, } => { - info!(client_id, cols, rows, "client connected"); + info!(client_id, cols, rows, ?render_encoding, "client connected"); let last_activity = self.allocate_activity_stamp(); self.clients.insert( client_id, @@ -1238,7 +1259,11 @@ impl HeadlessServer { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity, + render_encoding, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(writer), }, ); @@ -1250,11 +1275,13 @@ impl HeadlessServer { ServerEvent::ClientInput { client_id, data } => { debug!(client_id, len = data.len(), "client input received"); if let Some(client) = self.clients.get_mut(&client_id) { - // Ensure the next render after client input is delivered even if the - // current frame buffer still compares equal. Input can change cursor or - // PTY state asynchronously, and thin clients should not stall waiting for - // a post-input frame behind identical-frame dedupe. - client.last_frame = None; + // Ensure semantic clients receive one post-input frame even if the + // semantic buffer compares equal. Terminal-ANSI clients must keep their + // server-side blit baseline; resetting it here forces a full redraw on + // every keypress and makes remote sessions feel extremely slow. + if client.render_encoding == RenderEncoding::SemanticFrame { + client.last_frame = None; + } } let events = crate::raw_input::parse_raw_input_bytes_sync(&data); self.update_client_outer_focus_from_events(client_id, &events); @@ -1334,6 +1361,17 @@ impl HeadlessServer { } true } + ServerEvent::ClientWriterDrained { client_id } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if client.render_pending { + client.render_pending = false; + true + } else { + false + } + } ServerEvent::QuitSignal => { // The quit check at the top of the loop handles this. // No render needed — the next iteration will initiate shutdown. @@ -1607,11 +1645,33 @@ impl HeadlessServer { continue; }; - if client.last_frame.as_ref() == Some(&frame) { - continue; - } + let mut terminal_encoded = None; + let message = match client.render_encoding { + RenderEncoding::SemanticFrame => { + if client.last_frame.as_ref() == Some(&frame) { + client.render_pending = false; + continue; + } + ServerMessage::Frame(frame.clone()) + } + RenderEncoding::TerminalAnsi => { + if client.blit_encoder.is_current(&frame) { + client.render_pending = false; + continue; + } + let encoded = client.blit_encoder.encode(&frame, false); + let message = ServerMessage::Terminal(TerminalFrame { + seq: client.frame_seq + 1, + width: frame.width, + height: frame.height, + full: encoded.full, + bytes: encoded.bytes.clone(), + }); + terminal_encoded = Some(encoded); + message + } + }; - let message = ServerMessage::Frame(frame.clone()); let serialized = match Self::frame_server_message(&message) { Ok(framed) => framed, Err(protocol::FramingError::Oversized { claimed, max }) => { @@ -1619,7 +1679,9 @@ impl HeadlessServer { client_id, claimed, max, "skipping oversized frame for client" ); - client.last_frame = Some(frame); + if client.render_encoding == RenderEncoding::SemanticFrame { + client.last_frame = Some(frame); + } continue; } Err(err) => { @@ -1629,13 +1691,34 @@ impl HeadlessServer { } }; - if writer.send(serialized).is_err() { - debug!(client_id, "client writer channel closed, marking as broken"); - broken_clients.push(client_id); - continue; + match writer.render.try_send(serialized) { + Ok(()) => { + client.render_pending = false; + match message { + ServerMessage::Frame(_) => { + client.last_frame = Some(frame); + client.frame_seq += 1; + } + ServerMessage::Terminal(_) => { + if let Some(encoded) = terminal_encoded { + client.blit_encoder.commit(frame, encoded); + client.frame_seq += 1; + } + } + _ => {} + } + } + Err(std::sync::mpsc::TrySendError::Full(_)) => { + client.render_pending = true; + debug!(client_id, "render queue full, deferring latest frame"); + continue; + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + debug!(client_id, "client writer channel closed, marking as broken"); + broken_clients.push(client_id); + continue; + } } - - client.last_frame = Some(frame); } if !broken_clients.is_empty() { @@ -1814,7 +1897,8 @@ pub enum ServerEvent { client_id: u64, cols: u16, rows: u16, - writer: std::sync::mpsc::Sender<Vec<u8>>, + render_encoding: RenderEncoding, + writer: ClientWriter, }, /// A client sent an input message. ClientInput { client_id: u64, data: Vec<u8> }, @@ -1828,6 +1912,8 @@ pub enum ServerEvent { ClientDetach { client_id: u64 }, /// A client connection was lost. ClientDisconnected { client_id: u64 }, + /// A client writer drained its render slot and can accept another render. + ClientWriterDrained { client_id: u64 }, /// Ctrl+C or external shutdown signal received. QuitSignal, } @@ -1866,11 +1952,12 @@ fn handle_client_handshake( } }; - let (client_cols, client_rows) = match hello { + let (client_cols, client_rows, render_encoding) = match hello { ClientMessage::Hello { version, cols, rows, + requested_encoding, } => { // Version check. match protocol::check_client_version(version) { @@ -1879,6 +1966,7 @@ fn handle_client_handshake( // Send rejection Welcome. let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, error: Some(reason), }; let _ = protocol::write_message(&mut stream, &welcome); @@ -1888,13 +1976,14 @@ fn handle_client_handshake( // Clamp size. let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); - (clamped_cols, clamped_rows) + (clamped_cols, clamped_rows, requested_encoding) } _ => { // First message must be Hello. debug!(client_id, "first message was not Hello, closing"); let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, error: Some("expected Hello as first message".to_owned()), }; let _ = protocol::write_message(&mut stream, &welcome); @@ -1905,6 +1994,7 @@ fn handle_client_handshake( // Send Welcome. let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, + encoding: render_encoding, error: None, }; protocol::write_message(&mut stream, &welcome) @@ -1913,53 +2003,123 @@ fn handle_client_handshake( // Clear read timeout for normal operation. stream.set_read_timeout(None)?; - // Create a channel for writing frames back to the client. - let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>(); + // Create separate channels for reliable control messages and droppable renders. + let (control_tx, control_rx) = std::sync::mpsc::channel::<Vec<u8>>(); + let (render_tx, render_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(1); + let writer = ClientWriter { + control: control_tx, + render: render_tx, + }; // Notify the main loop about the new client. let _ = server_event_tx.blocking_send(ServerEvent::ClientConnected { client_id, cols: client_cols, rows: client_rows, - writer: frame_tx, + render_encoding, + writer, }); - // Spawn a writer thread that forwards frames from the channel to the stream. + // Spawn a writer thread that forwards messages from the channels to the stream. let write_stream = stream.try_clone()?; let write_quit = should_quit.clone(); + let writer_event_tx = server_event_tx.clone(); std::thread::spawn(move || { - client_writer_loop(write_stream, frame_rx, &write_quit); + client_writer_loop( + write_stream, + client_id, + control_rx, + render_rx, + writer_event_tx, + &write_quit, + ); }); // Enter read loop — read client messages and forward to main loop. client_read_loop(stream, client_id, server_event_tx, should_quit) } -/// The client writer loop — sends frames from the channel to the client stream. +/// The client writer loop — prioritizes control messages over render frames. fn client_writer_loop( mut stream: UnixStream, - frame_rx: std::sync::mpsc::Receiver<Vec<u8>>, + client_id: u64, + control_rx: std::sync::mpsc::Receiver<Vec<u8>>, + render_rx: std::sync::mpsc::Receiver<Vec<u8>>, + server_event_tx: mpsc::Sender<ServerEvent>, should_quit: &Arc<AtomicBool>, ) { + let mut control_closed = false; + let mut render_closed = false; + while !should_quit.load(Ordering::Acquire) { - match frame_rx.recv_timeout(Duration::from_millis(100)) { + match control_rx.try_recv() { + Ok(data) => { + if !write_framed_bytes(&mut stream, &data) { + break; + } + continue; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => control_closed = true, + } + + match render_rx.try_recv() { Ok(data) => { - if let Err(err) = stream.write_all(&data) { - debug!(err = %err, "client write failed, closing writer"); + let _ = + server_event_tx.blocking_send(ServerEvent::ClientWriterDrained { client_id }); + if !write_framed_bytes(&mut stream, &data) { break; } - if let Err(err) = stream.flush() { - debug!(err = %err, "client flush failed, closing writer"); + continue; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => render_closed = true, + } + + if control_closed && render_closed { + break; + } + + if control_closed { + match render_rx.recv_timeout(Duration::from_millis(5)) { + Ok(data) => { + let _ = server_event_tx + .blocking_send(ServerEvent::ClientWriterDrained { client_id }); + if !write_framed_bytes(&mut stream, &data) { + break; + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => render_closed = true, + } + continue; + } + + match control_rx.recv_timeout(Duration::from_millis(5)) { + Ok(data) => { + if !write_framed_bytes(&mut stream, &data) { break; } } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => control_closed = true, } } debug!("client writer thread exiting"); } +fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool { + if let Err(err) = stream.write_all(data) { + debug!(err = %err, "client write failed, closing writer"); + return false; + } + if let Err(err) = stream.flush() { + debug!(err = %err, "client flush failed, closing writer"); + return false; + } + true +} + /// The client read loop — reads messages from the client and forwards to the server event channel. fn client_read_loop( mut stream: UnixStream, @@ -2193,6 +2353,23 @@ mod tests { } } + fn test_client_writer() -> ( + ClientWriter, + std::sync::mpsc::Receiver<Vec<u8>>, + std::sync::mpsc::Receiver<Vec<u8>>, + ) { + let (control_tx, control_rx) = std::sync::mpsc::channel(); + let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); + ( + ClientWriter { + control: control_tx, + render: render_tx, + }, + control_rx, + render_rx, + ) + } + #[test] fn clamp_terminal_size_zero_zero() { assert_eq!( @@ -2426,7 +2603,11 @@ mod tests { }, outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: None, }, ); @@ -2448,7 +2629,11 @@ mod tests { }, outer_terminal_focus: None, last_activity: 2, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: None, }, ); @@ -2489,7 +2674,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: None, }, ); @@ -2500,7 +2689,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: Some(true), last_activity: 2, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: None, }, ); @@ -2529,7 +2722,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: None, }, ); @@ -2540,7 +2737,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: Some(true), last_activity: 2, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: None, }, ); @@ -2569,7 +2770,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: Some(true), last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: None, }, ); @@ -2594,8 +2799,8 @@ mod tests { server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; - let (desktop_tx, desktop_rx) = std::sync::mpsc::channel(); - let (phone_tx, phone_rx) = std::sync::mpsc::channel(); + let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer(); + let (phone_tx, _phone_control_rx, phone_rx) = test_client_writer(); server.clients.insert( 1, @@ -2604,7 +2809,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(desktop_tx), }, ); @@ -2615,7 +2824,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 2, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(phone_tx), }, ); @@ -2633,6 +2846,168 @@ mod tests { } #[test] + fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() { + let mut server = test_headless_server(); + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, + last_activity: 1, + render_encoding: RenderEncoding::TerminalAnsi, + last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, + writer: Some(client_tx), + }, + ); + server.foreground_client_id = Some(1); + + server.render_and_stream(); + + match read_server_message( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("terminal frame"), + ) { + ServerMessage::Terminal(frame) => { + assert_eq!(frame.seq, 1); + assert_eq!((frame.width, frame.height), (80, 24)); + assert!(frame.full); + assert!(!frame.bytes.is_empty()); + } + other => panic!("expected terminal frame, got {other:?}"), + } + assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1); + } + + #[test] + fn terminal_ansi_input_does_not_reset_blit_baseline() { + let mut server = test_headless_server(); + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, + last_activity: 1, + render_encoding: RenderEncoding::TerminalAnsi, + last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, + writer: Some(client_tx), + }, + ); + server.foreground_client_id = Some(1); + + server.render_and_stream(); + let _ = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial terminal frame"); + assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1); + + assert!(!server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: Vec::new(), + })); + server.render_and_stream(); + + assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + } + + #[test] + fn full_render_queue_does_not_advance_terminal_ansi_baseline() { + let mut server = test_headless_server(); + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) + .expect("serialize dummy message"); + client_tx + .render + .send(queued) + .expect("pre-fill render queue"); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, + last_activity: 1, + render_encoding: RenderEncoding::TerminalAnsi, + last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, + writer: Some(client_tx), + }, + ); + server.foreground_client_id = Some(1); + + server.render_and_stream(); + + assert_eq!(server.clients.get(&1).unwrap().frame_seq, 0); + assert!(matches!( + read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), + ServerMessage::ReloadSoundConfig + )); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + } + + #[test] + fn writer_drained_retries_pending_terminal_ansi_render() { + let mut server = test_headless_server(); + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) + .expect("serialize dummy message"); + client_tx + .render + .send(queued) + .expect("pre-fill render queue"); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, + last_activity: 1, + render_encoding: RenderEncoding::TerminalAnsi, + last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, + writer: Some(client_tx), + }, + ); + server.foreground_client_id = Some(1); + + server.render_and_stream(); + assert!(server.clients.get(&1).unwrap().render_pending); + assert!(matches!( + read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), + ServerMessage::ReloadSoundConfig + )); + + assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 })); + server.render_and_stream(); + + match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { + ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 1), + other => panic!("expected terminal frame, got {other:?}"), + } + assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1); + assert!(!server.clients.get(&1).unwrap().render_pending); + } + + #[test] fn render_and_stream_skips_identical_frame_sends() { let mut server = test_headless_server(); server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; @@ -2640,7 +3015,7 @@ mod tests { server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; - let (client_tx, client_rx) = std::sync::mpsc::channel(); + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, @@ -2649,7 +3024,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(client_tx), }, ); @@ -2671,7 +3050,7 @@ mod tests { #[test] fn client_sound_reload_request_refreshes_attached_clients() { let mut server = test_headless_server(); - let (client_tx, client_rx) = std::sync::mpsc::channel(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, @@ -2680,7 +3059,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(client_tx), }, ); @@ -2689,7 +3072,7 @@ mod tests { server.drain_client_sound_config_reload_request(); match read_server_message( - client_rx + client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("client sound reload message"), ) { @@ -2702,8 +3085,8 @@ mod tests { #[test] fn clipboard_write_targets_foreground_client_only() { let mut server = test_headless_server(); - let (background_tx, background_rx) = std::sync::mpsc::channel(); - let (foreground_tx, foreground_rx) = std::sync::mpsc::channel(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); server.clients.insert( 1, @@ -2712,7 +3095,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(background_tx), }, ); @@ -2723,7 +3110,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 2, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(foreground_tx), }, ); @@ -2736,7 +3127,7 @@ mod tests { assert!(!changed); match read_server_message( - foreground_rx + foreground_control_rx .recv_timeout(Duration::from_millis(100)) .expect("foreground clipboard message"), ) { @@ -2744,7 +3135,7 @@ mod tests { other => panic!("expected clipboard message, got {other:?}"), } assert!( - background_rx + background_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "background client should not receive clipboard writes" @@ -2754,8 +3145,8 @@ mod tests { #[test] fn client_local_notifications_target_foreground_client_only() { let mut server = test_headless_server(); - let (background_tx, background_rx) = std::sync::mpsc::channel(); - let (foreground_tx, foreground_rx) = std::sync::mpsc::channel(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); server.clients.insert( 1, @@ -2764,7 +3155,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(background_tx), }, ); @@ -2775,7 +3170,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 2, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(foreground_tx), }, ); @@ -2788,7 +3187,7 @@ mod tests { })); match read_server_message( - foreground_rx + foreground_control_rx .recv_timeout(Duration::from_millis(100)) .expect("foreground toast message"), ) { @@ -2799,7 +3198,7 @@ mod tests { other => panic!("expected toast notify, got {other:?}"), } assert!( - background_rx + background_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "background client should not receive client-local notifications" @@ -2809,7 +3208,7 @@ mod tests { #[test] fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() { let mut server = test_headless_server(); - let (client_tx, client_rx) = std::sync::mpsc::channel(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, @@ -2818,7 +3217,11 @@ mod tests { host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), outer_terminal_focus: None, last_activity: 1, + render_encoding: RenderEncoding::SemanticFrame, last_frame: None, + blit_encoder: BlitEncoder::new(), + frame_seq: 0, + render_pending: false, writer: Some(client_tx), }, ); @@ -2831,7 +3234,9 @@ mod tests { assert!(changed); assert!(server.app.state.toast.is_some()); assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), "herdr delivery should render in-frame instead of forwarding a terminal notification" ); } diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 74e2d7f3..15aa3784 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 3; +pub const PROTOCOL_VERSION: u32 = 4; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -26,6 +26,15 @@ 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, +} + /// Messages sent from the client to the server over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { @@ -37,6 +46,8 @@ pub enum ClientMessage { cols: u16, /// Terminal height in rows. rows: u16, + /// Render encoding requested by the client. + requested_encoding: RenderEncoding, }, /// Raw input bytes read from the client's stdin. @@ -200,6 +211,21 @@ impl FrameData { } } +/// 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 { @@ -216,14 +242,19 @@ pub enum ServerMessage { 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 the client. + /// 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), + /// Server is shutting down. Clients should exit gracefully. ServerShutdown { /// Optional reason for the shutdown. @@ -464,10 +495,9 @@ fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), Fram /// 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 `Welcome { version, error: None }`. + /// Versions are compatible. The server should reply with a successful Welcome. Compatible, - /// Versions are incompatible. The server should reply - /// `Welcome { version, error: Some(reason) }` and close the connection. + /// Versions are incompatible. The server should reply with a Welcome error and close. Incompatible(String), } @@ -516,6 +546,7 @@ mod tests { version: PROTOCOL_VERSION, cols: 80, rows: 24, + requested_encoding: RenderEncoding::SemanticFrame, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -558,6 +589,7 @@ mod tests { 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(); @@ -570,6 +602,7 @@ mod tests { 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(); @@ -690,6 +723,21 @@ mod tests { } #[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(); @@ -706,6 +754,7 @@ mod tests { version: PROTOCOL_VERSION, cols: 80, rows: 24, + requested_encoding: RenderEncoding::SemanticFrame, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -773,6 +822,7 @@ mod tests { version: PROTOCOL_VERSION, cols: (80 + (i % 40) as u16), rows: (24 + (i % 20) as u16), + requested_encoding: RenderEncoding::SemanticFrame, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], @@ -929,10 +979,12 @@ mod tests { 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), }, }; @@ -1194,6 +1246,7 @@ mod tests { version: PROTOCOL_VERSION, cols: 80, rows: 24, + requested_encoding: RenderEncoding::SemanticFrame, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1224,6 +1277,7 @@ mod tests { version: PROTOCOL_VERSION, cols: 200, rows: 60, + requested_encoding: RenderEncoding::SemanticFrame, }, ClientMessage::Input { data: b"hello world".to_vec(), |
