aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/client/blit.rs66
-rw-r--r--src/client/mod.rs89
-rw-r--r--src/main.rs35
-rw-r--r--src/remote.rs363
-rw-r--r--src/server/headless.rs515
-rw-r--r--src/server/protocol.rs64
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(),