herdr/src/client/mod.rs

2743 lines
91 KiB
Rust

//! Thin client mode — connects to the server's client socket.
//!
//! The client:
//! - Connects to `herdr-client.sock`, sends Hello with terminal size and protocol version
//! - Sets up the real terminal (raw mode, mouse capture, keyboard enhancements)
//! - Receives Frame messages and blits them to the terminal (diff against last frame)
//! - Reads stdin events (keystrokes, mouse, paste) and sends them as ClientMessage::Input
//! - Detects terminal resize and sends ClientMessage::Resize
//! - Restores terminal on exit (normal or error)
//! - Handles ServerShutdown gracefully (clean exit, informative message to stderr)
//! - Handles server unreachable (clear error screen, not blank/hang)
//! - Forwards OSC 52 clipboard writes from server to its own stdout
//! - Displays sound/toast notifications forwarded from server
mod input;
use std::collections::HashSet;
use std::io::{self, BufRead, Write as _};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use base64::Engine;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture,
};
#[cfg(unix)]
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
#[cfg(not(windows))]
use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
use crossterm::execute;
use interprocess::local_socket::traits::Stream as _;
use interprocess::TryClone as _;
use tracing::{debug, info, warn};
use crate::ipc::LocalStream;
use crate::protocol::render_ansi;
#[cfg(unix)]
use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD;
use crate::protocol::{
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE,
MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
use crate::server::socket_paths::client_socket_path;
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
// ---------------------------------------------------------------------------
// Client state
// ---------------------------------------------------------------------------
struct ClientLoopConfig {
sound_config: crate::config::SoundConfig,
mouse_scroll_lines: usize,
redraw_on_focus_gained: bool,
host_cursor: crate::config::HostCursorModeConfig,
kitty_graphics_enabled: bool,
mouse_capture_active: bool,
#[cfg(unix)]
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
}
/// State tracking for the thin client.
struct ClientState {
/// Stateful semantic-frame encoder used when the server sends FrameData.
blit_encoder: render_ansi::BlitEncoder,
/// Whether host mouse capture is currently active.
mouse_capture_active: bool,
/// 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.
sound_config: crate::config::SoundConfig,
/// Whether this client may write Kitty graphics bytes to its host terminal.
kitty_graphics_enabled: bool,
/// Direct attach prefix escape state. None for full-app clients.
attach_escape: Option<AttachEscapeState>,
/// Rows scrolled for one direct-attach wheel notch.
#[cfg(unix)]
mouse_scroll_lines: usize,
/// Local-client shortcut that sends a clipboard image to a remote Herdr session.
#[cfg(unix)]
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
/// Whether outer focus gain should force a full host-terminal redraw.
redraw_on_focus_gained: bool,
/// Whether this client draws the cursor into frame cells instead of using the host cursor.
draw_host_cursor: bool,
}
#[derive(Debug, Default)]
#[cfg(windows)]
struct AttachEscapeState;
#[derive(Debug, Default)]
#[cfg(unix)]
struct AttachEscapeState {
pending_prefix: bool,
}
#[derive(Debug)]
#[cfg(unix)]
enum AttachInputAction {
Forward(Vec<u8>),
Scroll {
source: AttachScrollSource,
direction: AttachScrollDirection,
lines: u16,
column: Option<u16>,
row: Option<u16>,
modifiers: u8,
},
Detach,
None,
}
impl AttachEscapeState {
#[cfg(unix)]
fn filter_input(
&mut self,
data: Vec<u8>,
viewport_rows: u16,
mouse_scroll_lines: usize,
) -> AttachInputAction {
const PREFIX: u8 = 0x02; // Ctrl+B
let mut output = Vec::with_capacity(data.len());
for byte in data {
if self.pending_prefix {
self.pending_prefix = false;
match byte {
b'q' => return AttachInputAction::Detach,
PREFIX => output.push(PREFIX),
other => {
output.push(PREFIX);
output.push(other);
}
}
continue;
}
if byte == PREFIX {
self.pending_prefix = true;
} else {
output.push(byte);
}
}
if output.is_empty() {
AttachInputAction::None
} else if let Some(action) =
attach_scroll_action(&output, viewport_rows, mouse_scroll_lines)
{
action
} else {
AttachInputAction::Forward(output)
}
}
}
#[cfg(unix)]
fn attach_scroll_action(
data: &[u8],
viewport_rows: u16,
mouse_scroll_lines: usize,
) -> Option<AttachInputAction> {
let mut events = crate::raw_input::parse_raw_input_bytes_sync(data);
if events.len() != 1 {
return None;
}
match events.pop()? {
crate::raw_input::RawInputEvent::Mouse(mouse) => {
let direction = match mouse.kind {
MouseEventKind::ScrollUp => AttachScrollDirection::Up,
MouseEventKind::ScrollDown => AttachScrollDirection::Down,
_ => return Some(AttachInputAction::None),
};
Some(AttachInputAction::Scroll {
source: AttachScrollSource::Wheel,
direction,
lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16,
column: Some(mouse.column),
row: Some(mouse.row),
modifiers: mouse.modifiers.bits(),
})
}
crate::raw_input::RawInputEvent::Key(key)
if key.modifiers.is_empty()
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
let direction = match key.code {
KeyCode::PageUp => AttachScrollDirection::Up,
KeyCode::PageDown => AttachScrollDirection::Down,
_ => return None,
};
Some(AttachInputAction::Scroll {
source: AttachScrollSource::PageKey {
input: data.to_vec(),
},
direction,
lines: viewport_rows.saturating_sub(1).max(1),
column: None,
row: None,
modifiers: KeyModifiers::empty().bits(),
})
}
crate::raw_input::RawInputEvent::Key(key)
if key.modifiers.is_empty()
&& key.kind == KeyEventKind::Release
&& matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) =>
{
Some(AttachInputAction::None)
}
_ => None,
}
}
impl ClientState {
fn request_full_redraw(&mut self) {
self.blit_encoder = render_ansi::BlitEncoder::new();
}
}
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
/// Errors that can occur during client operation.
#[derive(Debug)]
pub enum ClientError {
/// Could not connect to the server's client socket.
ConnectionFailed(io::Error),
/// Server rejected our handshake.
HandshakeRejected { version: u32, error: String },
/// Server shut down.
ServerShutdown { reason: Option<String> },
/// Lost connection to the server.
ConnectionLost(io::Error),
/// Protocol error (framing, deserialization).
Protocol(protocol::FramingError),
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::ConnectionFailed(err) => {
write!(f, "failed to connect to server: {err}")?;
let path = client_socket_path();
write!(
f,
"\nIs herdr server running? Start it with `herdr server`."
)?;
write!(f, "\nSocket path: {}", path.display())
}
ClientError::HandshakeRejected { version, error } => {
write!(f, "server rejected handshake (version {version}): {error}")
}
ClientError::ServerShutdown { reason } => {
match reason.as_deref() {
Some("detached") => {
if let Ok(reattach_command) =
std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR)
{
write!(f, "detached from remote server")?;
write!(f, "\nRun `{reattach_command}` to reattach")?;
} else {
write!(f, "detached from server")?;
write!(
f,
"\nRun `{}` to reattach",
crate::session::local_attach_command()
)?;
}
}
_ => {
write!(f, "server shut down")?;
if let Some(reason) = reason {
write!(f, ": {reason}")?;
}
}
}
Ok(())
}
ClientError::ConnectionLost(err) => {
if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR)
{
write!(f, "lost connection to remote Herdr: {err}")?;
write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?;
write!(f, "\nRun `{reattach_command}` to reattach")
} else {
write!(f, "lost connection to server: {err}")
}
}
ClientError::Protocol(err) => {
write!(f, "protocol error: {err}")
}
}
}
}
impl std::error::Error for ClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ClientError::ConnectionFailed(err) => Some(err),
ClientError::ConnectionLost(err) => Some(err),
ClientError::Protocol(err) => Some(err),
_ => None,
}
}
}
impl From<protocol::FramingError> for ClientError {
fn from(err: protocol::FramingError) -> Self {
match err {
protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new(
io::ErrorKind::UnexpectedEof,
"server closed connection",
)),
protocol::FramingError::Io(err) => ClientError::ConnectionLost(err),
err => ClientError::Protocol(err),
}
}
}
// ---------------------------------------------------------------------------
// Terminal setup / restore
// ---------------------------------------------------------------------------
/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements).
///
/// Returns a guard that restores the terminal when dropped.
fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> {
setup_terminal_with_capabilities(true, mouse_capture)
}
/// Sets up a direct attach terminal.
///
/// Direct attach forwards stdin to the attached PTY. It enables mouse capture
/// so wheel events can drive the attached viewport or be forwarded to child
/// programs that requested mouse input.
fn setup_direct_attach_terminal() -> io::Result<TerminalGuard> {
setup_terminal_with_capabilities(false, true)
}
fn setup_terminal_with_capabilities(
enable_client_protocols: bool,
mouse_capture: bool,
) -> io::Result<TerminalGuard> {
ratatui::init();
crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
let host_color_scheme_reports =
should_enable_host_color_scheme_reports(enable_client_protocols);
if enable_client_protocols {
if mouse_capture {
set_mouse_capture(true)?;
} else {
set_mouse_capture(false)?;
}
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
if host_color_scheme_reports {
write_host_color_scheme_report_mode(&mut io::stdout(), true)?;
}
push_keyboard_enhancement_flags()?;
} else {
if should_query_host_terminal_theme() {
write_host_color_scheme_report_mode(&mut io::stdout(), false)?;
}
if mouse_capture {
set_mouse_capture(true)?;
} else {
set_mouse_capture(false)?;
}
}
#[cfg(windows)]
let windows_virtual_terminal_input =
if enable_client_protocols && windows_vti_input_backend_enabled() {
enable_windows_virtual_terminal_input()
} else {
WindowsVirtualTerminalInputSetup::default()
};
#[cfg(windows)]
if enable_client_protocols
&& windows_vti_input_backend_enabled()
&& windows_virtual_terminal_input.active
&& windows_win32_input_mode_enabled()
{
if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) {
if let Some(mode) = windows_virtual_terminal_input.restore_mode {
restore_windows_input_mode_value(mode);
}
return Err(err);
}
}
let modify_other_keys_mode = enable_client_protocols
.then(crate::input::host_modify_other_keys_mode)
.flatten();
if let Some(mode) = modify_other_keys_mode {
io::stdout().write_all(mode.set_sequence())?;
io::stdout().flush()?;
}
Ok(TerminalGuard {
reset_modify_other_keys: modify_other_keys_mode.is_some(),
reset_host_color_scheme_reports: host_color_scheme_reports,
#[cfg(windows)]
restore_windows_input_mode: windows_virtual_terminal_input.restore_mode,
})
}
fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool {
enable_client_protocols && should_query_host_terminal_theme()
}
/// Guard that restores the terminal when dropped.
struct TerminalGuard {
reset_modify_other_keys: bool,
reset_host_color_scheme_reports: bool,
#[cfg(windows)]
restore_windows_input_mode: Option<u32>,
}
fn write_host_color_scheme_report_mode(
writer: &mut impl io::Write,
enabled: bool,
) -> io::Result<()> {
let sequence = if enabled {
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE
} else {
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE
};
writer.write_all(sequence.as_bytes())?;
writer.flush()
}
fn write_terminal_restore_postlude(
writer: &mut impl io::Write,
reset_host_color_scheme_reports: bool,
) -> io::Result<()> {
if reset_host_color_scheme_reports {
writer.write_all(
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
)?;
}
// Restore a visible cursor and reset DECSCUSR back to the terminal default.
writer.write_all(b"\x1b[?25h\x1b[0 q")?;
writer.flush()
}
fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool {
match mode {
crate::config::HostCursorModeConfig::Auto => cfg!(windows),
crate::config::HostCursorModeConfig::Native => false,
crate::config::HostCursorModeConfig::Drawn => true,
}
}
#[cfg(windows)]
#[derive(Default)]
struct WindowsVirtualTerminalInputSetup {
active: bool,
restore_mode: Option<u32>,
}
#[cfg(windows)]
fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup {
use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Console::{
GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT,
STD_INPUT_HANDLE,
};
let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
tracing::warn!("failed to get Windows console input handle for VT input");
return WindowsVirtualTerminalInputSetup::default();
}
let mut mode = 0;
if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
tracing::warn!("failed to read Windows console input mode for VT input");
return WindowsVirtualTerminalInputSetup::default();
}
let desired = windows_virtual_terminal_input_mode(mode);
if desired == mode {
return WindowsVirtualTerminalInputSetup {
active: true,
restore_mode: None,
};
}
if unsafe { SetConsoleMode(handle, desired) } == 0 {
tracing::warn!("failed to enable Windows virtual terminal input");
return WindowsVirtualTerminalInputSetup::default();
}
let mut applied = 0;
if unsafe { GetConsoleMode(handle, &mut applied) } == 0 {
tracing::warn!("failed to verify Windows virtual terminal input mode");
let _ = unsafe { SetConsoleMode(handle, mode) };
return WindowsVirtualTerminalInputSetup::default();
}
if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 {
tracing::warn!("Windows virtual terminal input bit did not stick");
let _ = unsafe { SetConsoleMode(handle, mode) };
return WindowsVirtualTerminalInputSetup::default();
}
WindowsVirtualTerminalInputSetup {
active: true,
restore_mode: Some(mode),
}
}
#[cfg(windows)]
fn windows_vti_input_backend_enabled() -> bool {
std::env::var("HERDR_WINDOWS_INPUT_BACKEND")
.map(|backend| !backend.eq_ignore_ascii_case("crossterm"))
.unwrap_or(true)
}
#[cfg(any(windows, test))]
fn windows_virtual_terminal_input_mode(mode: u32) -> u32 {
mode | 0x0200
}
#[cfg(windows)]
fn restore_windows_input_mode_value(mode: u32) {
use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE};
let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
return;
}
if unsafe { SetConsoleMode(handle, mode) } == 0 {
tracing::warn!("failed to restore Windows console input mode");
}
}
fn set_mouse_capture(enabled: bool) -> io::Result<()> {
crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
if enabled {
execute!(io::stdout(), EnableMouseCapture)
} else {
match execute!(io::stdout(), DisableMouseCapture) {
Ok(()) => Ok(()),
#[cfg(windows)]
Err(err) if err.to_string() == "Initial console modes not set" => Ok(()),
Err(err) => Err(err),
}
}
}
fn restore_terminal_state(
reset_modify_other_keys: bool,
reset_host_color_scheme_reports: bool,
#[cfg(windows)] restore_windows_input_mode: Option<u32>,
) {
let _ = clear_received_kitty_graphics(&mut io::stdout());
// Reset modifyOtherKeys if we enabled it.
if reset_modify_other_keys {
let _ = io::stdout().write_all(b"\x1b[>4;0m");
let _ = io::stdout().flush();
}
let _ = pop_keyboard_enhancement_flags();
let _ = execute!(
io::stdout(),
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
);
let _ = crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout());
#[cfg(windows)]
if let Some(mode) = restore_windows_input_mode {
restore_windows_input_mode_value(mode);
}
ratatui::restore();
let _ = write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports);
#[cfg(windows)]
if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() {
let _ = disable_windows_win32_input_mode(&mut io::stdout());
}
}
#[cfg(not(windows))]
fn push_keyboard_enhancement_flags() -> io::Result<()> {
execute!(
io::stdout(),
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
)
}
#[cfg(windows)]
fn push_keyboard_enhancement_flags() -> io::Result<()> {
Ok(())
}
#[cfg(not(windows))]
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
execute!(io::stdout(), PopKeyboardEnhancementFlags)
}
#[cfg(windows)]
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
Ok(())
}
#[cfg(windows)]
fn windows_win32_input_mode_enabled() -> bool {
std::env::var("HERDR_WINDOWS_INPUT_PROBE")
.map(|probe| probe.eq_ignore_ascii_case("win32"))
.unwrap_or(true)
}
#[cfg(windows)]
fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> {
writer.write_all(b"\x1b[?9001h")?;
writer.flush()
}
#[cfg(windows)]
fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> {
writer.write_all(b"\x1b[?9001l")?;
writer.flush()
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
restore_terminal_state(
self.reset_modify_other_keys,
self.reset_host_color_scheme_reports,
#[cfg(windows)]
self.restore_windows_input_mode,
);
}
}
// ---------------------------------------------------------------------------
// 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,
}
}
#[cfg(unix)]
fn is_remote_client_process() -> bool {
std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok()
}
/// Time to wait for the server's Welcome reply during the handshake.
///
/// A local client talks to an already-connected server, so 5s is plenty. The
/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh
/// connection whose cold-connect (TCP + key exchange + auth) happens inside this
/// window; on a high-latency link that easily exceeds 5s, so it gets a far
/// larger budget. See issue #753.
const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(unix)]
const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60);
fn handshake_read_timeout() -> Duration {
#[cfg(unix)]
if is_remote_client_process() {
return REMOTE_HANDSHAKE_READ_TIMEOUT;
}
LOCAL_HANDSHAKE_READ_TIMEOUT
}
fn requested_keybindings() -> ClientKeybindings {
match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR)
.ok()
.as_deref()
{
Some("local") => crate::config::Config::load()
.config
.local_keybindings_profile_toml()
.map(|keys_toml| ClientKeybindings::Local { keys_toml })
.unwrap_or(ClientKeybindings::Server),
_ => ClientKeybindings::Server,
}
}
#[cfg(windows)]
fn set_handshake_recv_timeout(
stream: &LocalStream,
timeout: Option<Duration>,
context: &'static str,
) -> Result<(), ClientError> {
match stream.set_recv_timeout(timeout) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::Unsupported => {
debug!(err = %err, context, "client socket receive timeout unavailable");
Ok(())
}
Err(err) => Err(ClientError::ConnectionFailed(err)),
}
}
#[cfg(not(windows))]
fn set_handshake_recv_timeout(
stream: &LocalStream,
timeout: Option<Duration>,
_context: &'static str,
) -> Result<(), ClientError> {
stream
.set_recv_timeout(timeout)
.map_err(ClientError::ConnectionFailed)
}
/// 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 LocalStream,
cols: u16,
rows: u16,
cell_width_px: u32,
cell_height_px: u32,
requested_encoding: RenderEncoding,
direct_attach_requested: bool,
) -> Result<RenderEncoding, ClientError> {
stream
.set_nonblocking(false)
.map_err(ClientError::ConnectionFailed)?;
// Send Hello.
let hello = ClientMessage::Hello {
version: PROTOCOL_VERSION,
cols,
rows,
cell_width_px,
cell_height_px,
requested_encoding,
keybindings: requested_keybindings(),
launch_mode: if direct_attach_requested {
ClientLaunchMode::TerminalAttach
} else {
ClientLaunchMode::App
},
};
protocol::write_message(stream, &hello)
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
// Read Welcome.
set_handshake_recv_timeout(
stream,
Some(handshake_read_timeout()),
"client handshake read timeout unavailable",
)?;
let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
set_handshake_recv_timeout(
stream,
None,
"failed to clear client handshake read timeout",
)?;
match welcome {
ServerMessage::Welcome {
version,
encoding,
error,
} => {
if let Some(error) = error {
return Err(ClientError::HandshakeRejected { version, error });
}
info!(version, ?encoding, "handshake succeeded");
Ok(encoding)
}
_ => Err(ClientError::Protocol(protocol::FramingError::Io(
io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"),
))),
}
}
// ---------------------------------------------------------------------------
// Client event loop
// ---------------------------------------------------------------------------
/// Internal events for the client event loop.
enum ClientLoopEvent {
/// Raw input bytes from stdin.
#[cfg(unix)]
StdinInput(Vec<u8>),
/// Structured input events from platforms without Unix-style stdin bytes.
#[cfg(windows)]
StdinEvents(Vec<crate::protocol::ClientInputEvent>),
/// Terminal resize detected.
Resize(u16, u16, u32, u32),
/// Server message received.
ServerMessage(ServerMessage),
/// Server reader thread exited (connection lost).
ServerDisconnected,
/// Timer tick.
Timer,
}
/// Runs the thin client: connects to the server, performs the handshake,
/// and enters the main event loop.
///
/// This is the entry point called from `main.rs` when running in client mode.
pub fn run_client() -> io::Result<()> {
run_client_with_mode(
requested_render_encoding(),
None,
None,
"connecting to server",
)
}
/// Runs a direct terminal attach client.
#[cfg(unix)]
pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> {
run_client_with_mode(
RenderEncoding::TerminalAnsi,
Some((terminal_id, takeover)),
Some(AttachEscapeState::default()),
"attaching to terminal",
)
}
/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path.
#[cfg(windows)]
pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> {
debug_assert!(!crate::platform::capabilities().direct_terminal_attach);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"direct terminal attach is not supported on Windows yet",
))
}
/// Runs a read-only terminal session observer and prints one JSON envelope per frame.
pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> {
let mut stream =
connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?;
write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?;
write_terminal_session_output(stream)
}
/// Runs a writable terminal session controller.
pub fn run_terminal_session_control(
target: String,
takeover: bool,
cols: u16,
rows: u16,
) -> io::Result<()> {
let mut stream = connect_terminal_session_stream(
target.clone(),
cols,
rows,
"controlling terminal session",
)?;
write_to_server(
&mut stream,
&ClientMessage::ControlTerminal { target, takeover },
)?;
let mut write_stream = stream.try_clone()?;
let _input_thread = std::thread::spawn(move || {
let stdin = io::stdin();
for line in stdin.lock().lines() {
let Ok(line) = line else {
break;
};
if line.trim().is_empty() {
continue;
}
match terminal_control_command_from_json(&line) {
Ok(message) => {
let release = matches!(message, ClientMessage::Detach);
if write_to_server(&mut write_stream, &message).is_err() {
return;
}
if release {
return;
}
}
Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"),
}
}
let _ = write_to_server(&mut write_stream, &ClientMessage::Detach);
});
write_terminal_session_output(stream)
}
fn connect_terminal_session_stream(
target: String,
cols: u16,
rows: u16,
log_message: &'static str,
) -> io::Result<LocalStream> {
init_logging();
let socket_path = client_socket_path();
crate::logging::startup("client");
info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}");
let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
Ok(stream) => stream,
Err(err) => {
eprintln!("herdr: {}", ClientError::ConnectionFailed(err));
std::process::exit(1);
}
};
match do_handshake(
&mut stream,
cols,
rows,
0,
0,
RenderEncoding::TerminalAnsi,
true,
) {
Ok(RenderEncoding::TerminalAnsi) => {}
Ok(encoding) => {
eprintln!(
"herdr: terminal session observe negotiated unsupported encoding {encoding:?}"
);
std::process::exit(1);
}
Err(err) => {
eprintln!("herdr: {err}");
std::process::exit(1);
}
}
stream.set_nonblocking(false)?;
Ok(stream)
}
fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> {
let mut stdout = io::stdout().lock();
loop {
match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) {
Ok(ServerMessage::Terminal(frame)) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes);
let line = serde_json::json!({
"type": "terminal.frame",
"seq": frame.seq,
"encoding": "ansi",
"width": frame.width,
"height": frame.height,
"full": frame.full,
"bytes": encoded,
});
serde_json::to_writer(&mut stdout, &line)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
}
Ok(ServerMessage::ServerShutdown { reason }) => {
let line = serde_json::json!({
"type": "terminal.closed",
"reason": reason,
});
serde_json::to_writer(&mut stdout, &line)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
return Ok(());
}
Ok(ServerMessage::Graphics { .. }) => {}
Ok(_) => {}
Err(protocol::FramingError::UnexpectedEof) => return Ok(()),
Err(err) => return Err(io::Error::other(err.to_string())),
}
}
}
#[derive(serde::Deserialize)]
#[serde(tag = "type")]
enum TerminalControlCommand {
#[serde(rename = "terminal.input")]
Input {
text: Option<String>,
bytes: Option<String>,
},
#[serde(rename = "terminal.resize")]
Resize {
cols: u16,
rows: u16,
#[serde(default)]
cell_width_px: u32,
#[serde(default)]
cell_height_px: u32,
},
#[serde(rename = "terminal.scroll")]
Scroll {
direction: TerminalControlScrollDirection,
lines: u16,
#[serde(default)]
source: TerminalControlScrollSource,
#[serde(default)]
column: Option<u16>,
#[serde(default)]
row: Option<u16>,
#[serde(default)]
modifiers: u8,
},
#[serde(rename = "terminal.release")]
Release {},
}
#[derive(Clone, Copy, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum TerminalControlScrollDirection {
Up,
Down,
}
#[derive(Clone, Copy, Default, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum TerminalControlScrollSource {
#[default]
Wheel,
PageKey,
}
fn terminal_control_command_from_json(raw: &str) -> Result<ClientMessage, String> {
let command = serde_json::from_str::<TerminalControlCommand>(raw)
.map_err(|err| format!("invalid json command: {err}"))?;
match command {
TerminalControlCommand::Input { text, bytes } => {
let data = match (text, bytes) {
(Some(_), Some(_)) => {
return Err("terminal.input accepts text or bytes, not both".into())
}
(Some(text), None) => text.into_bytes(),
(None, Some(bytes)) => base64::engine::general_purpose::STANDARD
.decode(bytes)
.map_err(|err| format!("invalid terminal.input bytes: {err}"))?,
(None, None) => Vec::new(),
};
Ok(ClientMessage::Input { data })
}
TerminalControlCommand::Resize {
cols,
rows,
cell_width_px,
cell_height_px,
} => {
if cols == 0 || rows == 0 {
return Err("terminal.resize cols and rows must be greater than 0".into());
}
Ok(ClientMessage::Resize {
cols,
rows,
cell_width_px,
cell_height_px,
})
}
TerminalControlCommand::Scroll {
direction,
lines,
source,
column,
row,
modifiers,
} => {
if lines == 0 {
return Err("terminal.scroll lines must be greater than 0".into());
}
let direction = match direction {
TerminalControlScrollDirection::Up => AttachScrollDirection::Up,
TerminalControlScrollDirection::Down => AttachScrollDirection::Down,
};
let source = match source {
TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel,
TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey {
input: match direction {
AttachScrollDirection::Up => b"\x1b[5~".to_vec(),
AttachScrollDirection::Down => b"\x1b[6~".to_vec(),
},
},
};
Ok(ClientMessage::AttachScroll {
source,
direction,
lines,
column,
row,
modifiers,
})
}
TerminalControlCommand::Release {} => Ok(ClientMessage::Detach),
}
}
fn run_client_with_mode(
requested_encoding: RenderEncoding,
attach_request: Option<(String, bool)>,
attach_escape: Option<AttachEscapeState>,
log_message: &'static str,
) -> io::Result<()> {
init_logging();
let loaded_config = crate::config::Config::load();
crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
let mouse_capture = loaded_config.config.ui.mouse_capture;
let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines();
let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
let host_cursor = loaded_config.config.ui.host_cursor;
let direct_attach_requested = attach_request.is_some();
#[cfg(unix)]
let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config);
let kitty_graphics_enabled =
loaded_config.config.experimental.kitty_graphics && !direct_attach_requested;
let loop_config = ClientLoopConfig {
sound_config: loaded_config.config.ui.sound,
mouse_scroll_lines,
redraw_on_focus_gained,
host_cursor,
kitty_graphics_enabled,
mouse_capture_active: mouse_capture,
#[cfg(unix)]
remote_image_paste_key,
};
let socket_path = client_socket_path();
crate::logging::startup("client");
info!(path = %socket_path.display(), "{log_message}");
// Try to connect to the server.
let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
Ok(s) => s,
Err(err) => {
// Server unreachable — show clear error and exit.
let client_err = ClientError::ConnectionFailed(err);
eprintln!("herdr: {client_err}");
std::process::exit(1);
}
};
// Get the terminal geometry before handshake (before raw mode).
let (cols, rows, cell_width_px, cell_height_px) =
current_terminal_geometry(kitty_graphics_enabled);
// Perform handshake while the stream is still in blocking mode.
let negotiated_encoding = match do_handshake(
&mut stream,
cols,
rows,
cell_width_px,
cell_height_px,
requested_encoding,
direct_attach_requested,
) {
Ok(encoding) => encoding,
Err(err) => {
eprintln!("herdr: {err}");
std::process::exit(1);
}
};
if let Some((terminal_id, takeover)) = attach_request {
let attach = ClientMessage::AttachTerminal {
terminal_id,
takeover,
};
if let Err(err) = write_to_server(&mut stream, &attach) {
eprintln!("herdr: failed to request terminal attach: {err}");
std::process::exit(1);
}
}
// Now set up the terminal. This must happen AFTER the handshake succeeds,
// so we don't leave the terminal in raw mode if the server rejects us.
let direct_attach = attach_escape.is_some();
let terminal_guard = if direct_attach {
setup_direct_attach_terminal()
} else {
setup_terminal(mouse_capture)
}
.map_err(|err| {
eprintln!("herdr: failed to set up terminal: {err}");
err
})?;
// Install a panic hook to restore the terminal on panic (same as monolithic).
let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys;
let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports;
#[cfg(windows)]
let panic_restore_windows_input_mode = terminal_guard.restore_windows_input_mode;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal_state(
panic_resets_modify_other_keys,
panic_resets_host_color_scheme_reports,
#[cfg(windows)]
panic_restore_windows_input_mode,
);
original_hook(info);
}));
// Create the tokio runtime.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(io::Error::other)?;
let should_quit = Arc::new(AtomicBool::new(false));
// Install Ctrl+C handler.
let quit_flag = should_quit.clone();
let _ = ctrlc::set_handler(move || {
quit_flag.store(true, Ordering::Release);
});
let result = rt.block_on(async {
run_client_loop(
stream,
cols,
rows,
should_quit,
loop_config,
negotiated_encoding,
attach_escape,
)
.await
});
// Restore the terminal before printing any final status message.
drop(terminal_guard);
if let Err(err) = result {
eprintln!("herdr: {err}");
rt.shutdown_timeout(Duration::from_millis(100));
crate::logging::shutdown("client");
if matches!(
err,
ClientError::ServerShutdown {
reason: Some(reason)
} if reason == "detached"
) {
return Ok(());
}
std::process::exit(1);
}
rt.shutdown_timeout(Duration::from_millis(100));
crate::logging::shutdown("client");
Ok(())
}
/// The main client event loop.
///
/// Uses a threaded architecture:
/// - stdin reader thread → sends raw input bytes to main loop
/// - resize poller thread → sends resize events to main loop
/// - server reader thread → reads ServerMessages and sends to main loop
/// - main loop: coordinates input, output, and server communication
async fn run_client_loop(
stream: LocalStream,
cols: u16,
rows: u16,
should_quit: Arc<AtomicBool>,
config: ClientLoopConfig,
negotiated_encoding: RenderEncoding,
attach_escape: Option<AttachEscapeState>,
) -> Result<(), ClientError> {
#[cfg(windows)]
let _ = config.mouse_scroll_lines;
let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor);
#[cfg(unix)]
let is_remote_client = is_remote_client_process();
let mut state = ClientState {
blit_encoder: render_ansi::BlitEncoder::new(),
mouse_capture_active: config.mouse_capture_active,
reported_size: (cols, rows),
sound_config: config.sound_config,
kitty_graphics_enabled: config.kitty_graphics_enabled,
attach_escape,
#[cfg(unix)]
mouse_scroll_lines: config.mouse_scroll_lines,
#[cfg(unix)]
remote_image_paste_key: config.remote_image_paste_key,
redraw_on_focus_gained: config.redraw_on_focus_gained,
draw_host_cursor,
};
debug!(?negotiated_encoding, "client render encoding active");
let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active));
// Channel for events from the stdin, resize, and server reader threads.
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
// Spawn the stdin reader thread.
let will_query_host_terminal_theme =
state.attach_escape.is_none() && should_query_host_terminal_theme();
let stdin_quit = should_quit.clone();
let stdin_tx = event_tx.clone();
let stdin_mouse_capture_active = host_mouse_capture_active.clone();
std::thread::spawn(move || {
input::stdin_reader_loop(
stdin_tx,
&stdin_quit,
will_query_host_terminal_theme,
stdin_mouse_capture_active,
);
});
if will_query_host_terminal_theme {
query_host_terminal_theme();
}
// Spawn the resize poller thread.
let resize_quit = should_quit.clone();
let resize_tx = event_tx.clone();
let kitty_graphics_enabled = state.kitty_graphics_enabled;
std::thread::spawn(move || {
resize_poll_loop(resize_tx, cols, rows, kitty_graphics_enabled, &resize_quit);
});
// Spawn the server reader thread (blocking reads from the socket).
// Clone the stream's file descriptor so we can read from a blocking stream.
let server_read_quit = should_quit.clone();
let server_read_tx = event_tx.clone();
let read_stream = stream.try_clone().map_err(ClientError::ConnectionFailed)?;
std::thread::spawn(move || {
let max_frame_size = if kitty_graphics_enabled {
MAX_GRAPHICS_FRAME_SIZE
} else {
MAX_FRAME_SIZE
};
server_reader_thread(
read_stream,
server_read_tx,
&server_read_quit,
max_frame_size,
);
});
// Use the original stream for writing (blocking is fine since we write
// from the async loop).
let mut write_stream = stream;
write_stream
.set_nonblocking(false)
.map_err(ClientError::ConnectionFailed)?;
// This (foreground) client owns the prefix ASCII input-source switch; a no-op on non-macOS.
use crate::platform::PrefixInputSource;
let mut prefix_input_source = crate::platform::RealPrefixInputSource::default();
// Main event loop.
while !should_quit.load(Ordering::Acquire) {
let event = tokio::select! {
ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer),
_ = tokio::time::sleep(Duration::from_millis(100)) => ClientLoopEvent::Timer,
};
match event {
#[cfg(unix)]
ClientLoopEvent::StdinInput(data) => {
let data = if let Some(attach_escape) = &mut state.attach_escape {
match attach_escape.filter_input(
data,
state.reported_size.1,
state.mouse_scroll_lines,
) {
AttachInputAction::Forward(data) => data,
AttachInputAction::Scroll {
source,
direction,
lines,
column,
row,
modifiers,
} => {
let msg = ClientMessage::AttachScroll {
source,
direction,
lines,
column,
row,
modifiers,
};
if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
}
continue;
}
AttachInputAction::Detach => {
let _ = write_to_server(&mut write_stream, &ClientMessage::Detach);
return Ok(());
}
AttachInputAction::None => continue,
}
} else {
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
if crate::raw_input::events_require_host_surface_redraw(
&events,
state.redraw_on_focus_gained,
) {
state.request_full_redraw();
}
if crate::raw_input::events_require_host_terminal_theme_query(&events) {
query_host_terminal_theme();
}
data
};
if should_bridge_clipboard_image_paste(
&data,
is_remote_client,
state.remote_image_paste_key,
) {
if let Some(image) = crate::platform::read_clipboard_image() {
if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
warn!(
bytes = image.bytes.len(),
max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
"local clipboard image is too large to bridge"
);
continue;
}
info!(
bytes = image.bytes.len(),
extension = image.extension,
"bridging local clipboard image paste to remote server"
);
let msg = ClientMessage::ClipboardImage {
extension: image.extension.to_owned(),
data: image.bytes,
};
if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
}
continue;
}
info!(
"clipboard image paste trigger received, but local clipboard has no image"
);
}
let msg = ClientMessage::Input { data };
if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
}
}
#[cfg(windows)]
ClientLoopEvent::StdinEvents(events) => {
if state.attach_escape.is_some() {
continue;
}
let raw_events = events
.iter()
.map(crate::protocol::ClientInputEvent::to_raw_input_event)
.collect::<Vec<_>>();
if crate::raw_input::events_require_host_surface_redraw(
&raw_events,
state.redraw_on_focus_gained,
) {
state.request_full_redraw();
}
let msg = ClientMessage::InputEvents { events };
if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
}
}
ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => {
state.reported_size = (new_cols, new_rows);
let msg = ClientMessage::Resize {
cols: new_cols,
rows: new_rows,
cell_width_px,
cell_height_px,
};
if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
}
}
ClientLoopEvent::ServerMessage(msg) => match msg {
ServerMessage::Frame(frame_data) => {
let frame_data = if state.draw_host_cursor {
render_ansi::frame_with_drawn_cursor(frame_data)
} else {
frame_data
};
let encoded = if state.draw_host_cursor {
state
.blit_encoder
.encode_with_suppressed_visible_cursor(&frame_data, false)
} else {
state.blit_encoder.encode(&frame_data, false)
};
let mut stdout = io::stdout();
let graphics = if state.kitty_graphics_enabled {
frame_data.graphics.as_slice()
} else {
&[]
};
let _ =
write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics);
let _ = stdout.flush();
state.blit_encoder.commit(frame_data, encoded);
}
ServerMessage::Terminal(frame) => {
if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) {
record_received_kitty_graphics(&frame.bytes);
}
let mut stdout = io::stdout();
let _ = stdout.write_all(&frame.bytes);
let _ = stdout.flush();
}
ServerMessage::Graphics { bytes } => {
if state.kitty_graphics_enabled {
record_received_kitty_graphics(&bytes);
let mut stdout = io::stdout();
let _ = stdout.write_all(&bytes);
let _ = stdout.flush();
}
}
ServerMessage::ServerShutdown { reason } => {
return Err(ClientError::ServerShutdown { reason });
}
ServerMessage::Notify {
kind,
message,
body,
} => {
handle_notify(kind, &message, body.as_deref(), &state.sound_config);
}
ServerMessage::Clipboard { data } => {
forward_clipboard(&data);
let _ = io::stdout().flush();
}
ServerMessage::WindowTitle { title } => {
write_window_title(title.as_deref());
let _ = io::stdout().flush();
}
ServerMessage::ReloadSoundConfig => {
reload_local_client_config(
&mut state.sound_config,
&mut state.redraw_on_focus_gained,
&mut state.draw_host_cursor,
#[cfg(unix)]
&mut state.remote_image_paste_key,
);
}
ServerMessage::MouseCapture { enabled } => {
let desired = enabled;
if desired != state.mouse_capture_active {
set_mouse_capture(desired).map_err(ClientError::ConnectionFailed)?;
#[cfg(windows)]
if windows_vti_input_backend_enabled() {
let _ = enable_windows_virtual_terminal_input();
}
state.mouse_capture_active = desired;
host_mouse_capture_active.store(desired, Ordering::Release);
}
}
ServerMessage::PrefixInputSource { active } => {
if active {
prefix_input_source.switch_to_ascii();
} else {
prefix_input_source.restore();
}
}
ServerMessage::Welcome { .. } => {
debug!("received unexpected Welcome in main loop");
}
},
ClientLoopEvent::ServerDisconnected => {
return Err(ClientError::ConnectionLost(io::Error::new(
io::ErrorKind::UnexpectedEof,
"server closed connection",
)));
}
ClientLoopEvent::Timer => {}
}
}
// Clean exit (Ctrl+C). Send Detach before closing.
let detach = ClientMessage::Detach;
let _ = write_to_server(&mut write_stream, &detach);
let _ = io::stdout().flush();
Ok(())
}
// ---------------------------------------------------------------------------
// Server reader thread
// ---------------------------------------------------------------------------
/// Blocking thread that reads ServerMessages from the server and sends them
/// to the main event loop.
fn server_reader_thread(
mut stream: LocalStream,
event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
max_frame_size: usize,
) {
// Ensure the read stream is in blocking mode to avoid WouldBlock errors
// from read_exact inside read_message. The stream should already be
// blocking after handshake, but we enforce it here as a safety measure.
if stream.set_nonblocking(false).is_err() {
// If we can't set blocking mode, the stream is likely broken.
let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
return;
}
loop {
if should_quit.load(Ordering::Acquire) {
break;
}
match protocol::read_message(&mut stream, max_frame_size) {
Ok(msg) => {
if event_tx
.blocking_send(ClientLoopEvent::ServerMessage(msg))
.is_err()
{
break; // Main loop gone.
}
}
Err(protocol::FramingError::UnexpectedEof) => {
// Server closed connection.
let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
break;
}
Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => {
// Should not happen with blocking mode, but handle gracefully
// in case the stream was set nonblocking by another clone.
std::thread::sleep(Duration::from_millis(1));
continue;
}
Err(err) => {
warn!(err = %err, "server read error");
let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
break;
}
}
}
}
// ---------------------------------------------------------------------------
// Write helper
// ---------------------------------------------------------------------------
/// Writes a message to the server stream (blocking).
fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> {
protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string()))
}
// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
#[cfg(unix)]
fn client_remote_image_paste_key(
config: &crate::config::Config,
) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
if !is_remote_client_process() {
return None;
}
match config.remote_image_paste_key() {
Ok(key) => key,
Err(diagnostic) => {
warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic");
None
}
}
}
fn reload_local_client_config(
sound_config: &mut crate::config::SoundConfig,
redraw_on_focus_gained: &mut bool,
draw_host_cursor: &mut bool,
#[cfg(unix)] remote_image_paste_key: &mut Option<(
crossterm::event::KeyCode,
crossterm::event::KeyModifiers,
)>,
) {
match crate::config::load_live_config() {
Ok(loaded) => {
for diagnostic in loaded.config.ui.sound.diagnostics() {
warn!(diagnostic = %diagnostic, "local sound config diagnostic");
}
#[cfg(unix)]
let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config);
*sound_config = loaded.config.ui.sound;
*redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained;
*draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor);
#[cfg(unix)]
{
*remote_image_paste_key = loaded_remote_image_paste_key;
}
debug!("reloaded local client config");
}
Err(diagnostics) => {
warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config");
}
}
}
fn handle_notify(
kind: NotifyKind,
message: &str,
body: Option<&str>,
sound_config: &crate::config::SoundConfig,
) {
handle_notify_with_notifiers(
kind,
message,
body,
sound_config,
crate::terminal_notify::show_notification,
crate::platform::show_desktop_notification,
);
}
fn handle_notify_with_notifiers(
kind: NotifyKind,
message: &str,
body: Option<&str>,
sound_config: &crate::config::SoundConfig,
mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>,
mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>,
) {
match kind {
NotifyKind::Sound => {
let Some(sound) = sound_from_notify_message(message) else {
warn!(
message = message,
"received unknown sound notification from server"
);
return;
};
if sound_config.enabled {
crate::sound::play(sound, sound_config);
}
}
NotifyKind::Toast => {
debug!(
message = message,
"received terminal toast notification from server"
);
if let Err(err) = show_terminal_notification(message, body) {
warn!(err = %err, "failed to emit terminal notification");
}
}
NotifyKind::SystemToast => {
debug!(
message = message,
"received system toast notification from server"
);
if let Err(err) = show_system_notification(message, body) {
warn!(err = %err, "failed to emit system notification");
}
}
}
}
fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
match message {
"agent done" => Some(crate::sound::Sound::Done),
"agent attention" => Some(crate::sound::Sound::Request),
_ => None,
}
}
#[cfg(unix)]
fn should_bridge_clipboard_image_paste(
data: &[u8],
is_remote_client: bool,
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
) -> bool {
if data == b"\x1b[200~\x1b[201~" {
return is_remote_client;
}
let Some(remote_image_paste_key) = remote_image_paste_key else {
return false;
};
let events = crate::raw_input::parse_raw_input_bytes_sync(data);
matches!(
events.as_slice(),
[crate::raw_input::RawInputEvent::Key(key)]
if key.kind == crossterm::event::KeyEventKind::Press
&& crate::config::terminal_key_matches_combo(*key, remote_image_paste_key)
)
}
// ---------------------------------------------------------------------------
// Clipboard forwarding
// ---------------------------------------------------------------------------
/// Decode a clipboard payload forwarded by the server.
fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(data).ok()
}
/// Forwards a clipboard write from the server to the local client clipboard.
fn forward_clipboard(data: &str) {
let Some(bytes) = decode_clipboard_payload(data) else {
warn!("received invalid clipboard payload from server");
return;
};
crate::selection::write_osc52_bytes(&bytes);
}
fn window_title_osc(title: Option<&str>) -> Vec<u8> {
let title = title.unwrap_or("herdr");
let safe_title = title
.chars()
.filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}'))
.collect::<String>();
format!("\x1b]0;{safe_title}\x07").into_bytes()
}
fn write_window_title(title: Option<&str>) {
let _ = io::stdout().write_all(&window_title_osc(title));
}
// ---------------------------------------------------------------------------
// Frame output
// ---------------------------------------------------------------------------
fn write_encoded_frame_with_graphics(
mut writer: impl io::Write,
encoded: &[u8],
graphics: &[u8],
) -> io::Result<()> {
writer.write_all(encoded)?;
if graphics.is_empty() {
return Ok(());
}
record_received_kitty_graphics(graphics);
writer.write_all(b"\x1b7")?;
writer.write_all(graphics)?;
writer.write_all(b"\x1b8")
}
fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool {
bytes.windows(3).any(|window| window == b"\x1b_G")
}
fn record_received_kitty_graphics(bytes: &[u8]) {
let ids = kitty_graphics_image_ids(bytes);
if ids.is_empty() {
return;
}
let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new()));
if let Ok(mut set) = set.lock() {
set.extend(ids);
}
}
fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> {
let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else {
return Ok(());
};
let Ok(mut set) = set.lock() else {
return Ok(());
};
for id in set.drain() {
write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?;
}
writer.flush()
}
fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec<u32> {
let mut ids = Vec::new();
let mut index = 0usize;
while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") {
let command_start = index + start + 3;
let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else {
break;
};
let command = &bytes[command_start..command_start + end];
if let Some(id) = kitty_graphics_command_image_id(command) {
ids.push(id);
}
index = command_start + end + 2;
}
ids
}
fn kitty_graphics_command_image_id(command: &[u8]) -> Option<u32> {
let header_end = command
.iter()
.position(|byte| *byte == b';')
.unwrap_or(command.len());
for part in command[..header_end].split(|byte| *byte == b',') {
let Some(value) = part.strip_prefix(b"i=") else {
continue;
};
let text = std::str::from_utf8(value).ok()?;
if let Ok(id) = text.parse::<u32>() {
return Some(id);
}
}
None
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
// ---------------------------------------------------------------------------
// Resize polling
// ---------------------------------------------------------------------------
fn current_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
if !kitty_graphics_enabled {
return (cols, rows, 0, 0);
}
let Ok(size) = crossterm::terminal::window_size() else {
return (cols, rows, 8, 16);
};
if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 {
return (cols, rows, 8, 16);
}
(
cols,
rows,
(size.width as u32 / size.columns as u32).max(1),
(size.height as u32 / size.rows as u32).max(1),
)
}
/// Polls the terminal size and sends resize events when it changes.
fn resize_poll_loop(
resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
initial_cols: u16,
initial_rows: u16,
kitty_graphics_enabled: bool,
should_quit: &Arc<AtomicBool>,
) {
let (_, _, initial_cell_width, initial_cell_height) =
current_terminal_geometry(kitty_graphics_enabled);
let mut last_size = (
initial_cols,
initial_rows,
initial_cell_width,
initial_cell_height,
);
while !should_quit.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(100));
let new_size = current_terminal_geometry(kitty_graphics_enabled);
if new_size != last_size {
last_size = new_size;
if resize_tx
.blocking_send(ClientLoopEvent::Resize(
new_size.0, new_size.1, new_size.2, new_size.3,
))
.is_err()
{
break; // Main loop gone.
}
}
}
}
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
/// Initialize logging for the client process.
fn query_host_terminal_theme() {
let _ = write_host_terminal_theme_query(io::stdout());
}
fn should_query_host_terminal_theme() -> bool {
!cfg!(windows)
}
fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
writer.write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes())?;
writer.flush()
}
fn init_logging() {
crate::logging::init_file_logging("herdr-client.log");
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn restore_env_var(key: &str, value: Option<OsString>) {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
restore_env_var(self.key, self.previous.clone());
}
}
#[test]
fn windows_virtual_terminal_input_mode_sets_only_vti_bit() {
assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0);
assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0);
}
struct EnvVarsRemovedGuard {
previous: Vec<(&'static str, Option<OsString>)>,
}
impl EnvVarsRemovedGuard {
fn new(keys: &[&'static str]) -> Self {
let previous: Vec<_> = keys
.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect();
for key in keys {
std::env::remove_var(key);
}
Self { previous }
}
}
impl Drop for EnvVarsRemovedGuard {
fn drop(&mut self) {
for (key, value) in self.previous.clone() {
restore_env_var(key, value);
}
}
}
#[test]
fn host_cursor_policy_auto_uses_drawn_cursor_on_windows() {
assert_eq!(
should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto),
cfg!(windows)
);
}
#[test]
fn host_cursor_policy_native_and_drawn_override_auto_detection() {
let _guard = env_lock().lock().unwrap();
let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm");
assert!(!should_draw_host_cursor(
crate::config::HostCursorModeConfig::Native
));
assert!(should_draw_host_cursor(
crate::config::HostCursorModeConfig::Drawn
));
}
#[cfg(unix)]
#[test]
fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() {
let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap();
assert!(should_bridge_clipboard_image_paste(
&[0x16],
true,
Some(ctrl_v)
));
assert!(should_bridge_clipboard_image_paste(
b"\x1b[118;5u",
true,
Some(ctrl_v)
));
assert!(should_bridge_clipboard_image_paste(
b"\x1b[200~\x1b[201~",
true,
None
));
assert!(!should_bridge_clipboard_image_paste(
b"\x1b[200~\x1b[201~",
false,
Some(ctrl_v)
));
assert!(!should_bridge_clipboard_image_paste(
b"\x1b[200~text\x1b[201~",
true,
Some(ctrl_v)
));
assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None));
assert!(!should_bridge_clipboard_image_paste(
b"v",
true,
Some(ctrl_v)
));
}
#[test]
fn graphics_bytes_are_written_after_blit_with_saved_cursor() {
let mut output = Vec::new();
write_encoded_frame_with_graphics(
&mut output,
b"\x1b[?2026htext\x1b[?2026lcursor",
b"graphics",
)
.unwrap();
assert_eq!(
output,
b"\x1b[?2026htext\x1b[?2026lcursor\x1b7graphics\x1b8"
);
}
#[test]
fn empty_graphics_writes_only_blit_frame() {
let mut output = Vec::new();
write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap();
assert_eq!(output, b"text");
}
#[test]
fn terminal_frame_kitty_detection_matches_apc_prefix() {
assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\"));
assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h"));
}
#[test]
fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() {
let ids = kitty_graphics_image_ids(
b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\",
);
assert_eq!(ids, vec![10023, 10023]);
}
#[test]
fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() {
record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\");
let mut output = Vec::new();
clear_received_kitty_graphics(&mut output).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("a=d,d=I,i=123"));
assert!(!text.contains("d=A"));
}
#[test]
fn write_host_terminal_theme_query_emits_osc_queries() {
let mut output = Vec::new();
write_host_terminal_theme_query(&mut output).unwrap();
assert_eq!(
output,
crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes()
);
}
#[test]
fn write_host_color_scheme_report_mode_emits_mode_sequences() {
let mut output = Vec::new();
write_host_color_scheme_report_mode(&mut output, true).unwrap();
write_host_color_scheme_report_mode(&mut output, false).unwrap();
let mut expected = Vec::new();
expected.extend_from_slice(
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(),
);
expected.extend_from_slice(
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
);
assert_eq!(output, expected);
}
#[test]
fn color_scheme_change_event_requests_host_theme_query() {
let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n");
assert!(crate::raw_input::events_require_host_terminal_theme_query(
&events
));
}
#[test]
fn host_terminal_theme_query_is_disabled_on_windows() {
assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
}
#[test]
fn color_scheme_reports_are_enabled_only_for_full_clients() {
assert_eq!(
should_enable_host_color_scheme_reports(true),
!cfg!(windows)
);
assert!(!should_enable_host_color_scheme_reports(false));
}
#[test]
fn terminal_restore_postlude_restores_visible_default_cursor() {
let mut output = Vec::new();
write_terminal_restore_postlude(&mut output, false).unwrap();
assert_eq!(output, b"\x1b[?25h\x1b[0 q");
}
#[test]
fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() {
let mut output = Vec::new();
write_terminal_restore_postlude(&mut output, true).unwrap();
let mut expected = Vec::new();
expected.extend_from_slice(
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
);
expected.extend_from_slice(b"\x1b[?25h\x1b[0 q");
assert_eq!(output, expected);
}
#[cfg(unix)]
#[test]
fn attach_escape_detaches_on_prefix_q() {
let mut escape = AttachEscapeState::default();
assert!(matches!(
escape.filter_input(vec![0x02], 24, 3),
AttachInputAction::None
));
assert!(matches!(
escape.filter_input(vec![b'q'], 24, 3),
AttachInputAction::Detach
));
}
#[cfg(unix)]
#[test]
fn attach_escape_sends_literal_prefix_on_double_prefix() {
let mut escape = AttachEscapeState::default();
assert!(matches!(
escape.filter_input(vec![0x02], 24, 3),
AttachInputAction::None
));
match escape.filter_input(vec![0x02], 24, 3) {
AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]),
other => panic!("expected forwarded prefix, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn attach_escape_forwards_prefix_before_non_escape_key() {
let mut escape = AttachEscapeState::default();
assert!(matches!(
escape.filter_input(vec![b'a', 0x02], 24, 3),
AttachInputAction::Forward(bytes) if bytes == b"a"
));
match escape.filter_input(vec![b'x'], 24, 3) {
AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']),
other => panic!("expected forwarded bytes, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn attach_escape_turns_wheel_into_scroll_action() {
let mut escape = AttachEscapeState::default();
match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) {
AttachInputAction::Scroll {
source,
direction,
lines,
column,
row,
..
} => {
assert_eq!(source, AttachScrollSource::Wheel);
assert_eq!(direction, AttachScrollDirection::Up);
assert_eq!(lines, 7);
assert_eq!(column, Some(10));
assert_eq!(row, Some(5));
}
other => panic!("expected scroll action, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn attach_escape_swallows_non_wheel_mouse_reports() {
let mut escape = AttachEscapeState::default();
assert!(matches!(
escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7),
AttachInputAction::None
));
}
#[cfg(unix)]
#[test]
fn attach_escape_turns_plain_page_keys_into_scroll_actions() {
let mut escape = AttachEscapeState::default();
match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) {
AttachInputAction::Scroll {
source,
direction,
lines,
..
} => {
assert_eq!(
source,
AttachScrollSource::PageKey {
input: b"\x1b[5~".to_vec()
}
);
assert_eq!(direction, AttachScrollDirection::Up);
assert_eq!(lines, 11);
}
other => panic!("expected page-up scroll action, got {other:?}"),
}
match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) {
AttachInputAction::Scroll {
source,
direction,
lines,
..
} => {
assert_eq!(
source,
AttachScrollSource::PageKey {
input: b"\x1b[6~".to_vec()
}
);
assert_eq!(direction, AttachScrollDirection::Down);
assert_eq!(lines, 11);
}
other => panic!("expected page-down scroll action, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn attach_escape_forwards_modified_page_key() {
let mut escape = AttachEscapeState::default();
match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) {
AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"),
other => panic!("expected modified page key to forward, got {other:?}"),
}
}
#[test]
fn client_error_display_connection_failed() {
let err = ClientError::ConnectionFailed(io::Error::new(
io::ErrorKind::ConnectionRefused,
"connection refused",
));
let msg = err.to_string();
assert!(
msg.contains("failed to connect to server"),
"should mention connection failure: {msg}"
);
assert!(
msg.contains("herdr server"),
"should suggest starting server: {msg}"
);
}
#[test]
fn client_error_display_handshake_rejected() {
let err = ClientError::HandshakeRejected {
version: 1,
error: "incompatible".into(),
};
let msg = err.to_string();
assert!(
msg.contains("rejected handshake"),
"should mention rejection: {msg}"
);
assert!(msg.contains("incompatible"), "should include error: {msg}");
}
#[test]
fn client_error_display_server_shutdown() {
let err = ClientError::ServerShutdown {
reason: Some("maintenance".into()),
};
let msg = err.to_string();
assert!(
msg.contains("server shut down"),
"should mention shutdown: {msg}"
);
assert!(msg.contains("maintenance"), "should include reason: {msg}");
}
#[test]
fn client_error_display_server_shutdown_no_reason() {
let err = ClientError::ServerShutdown { reason: None };
let msg = err.to_string();
assert!(
msg.contains("server shut down"),
"should mention shutdown: {msg}"
);
}
#[test]
fn client_error_display_detached_default_session_reattach_hint() {
let _guard = env_lock().lock().unwrap();
let _env = EnvVarsRemovedGuard::new(&[
crate::remote::REATTACH_COMMAND_ENV_VAR,
crate::session::SESSION_ENV_VAR,
]);
let err = ClientError::ServerShutdown {
reason: Some("detached".into()),
};
let msg = err.to_string();
assert!(
msg.contains("Run `herdr` to reattach"),
"should suggest default reattach command: {msg}"
);
}
#[test]
fn client_error_display_detached_named_session_reattach_hint() {
let _guard = env_lock().lock().unwrap();
let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]);
let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
let err = ClientError::ServerShutdown {
reason: Some("detached".into()),
};
let msg = err.to_string();
assert!(
msg.contains("Run `herdr session attach work` to reattach"),
"should suggest named session reattach command: {msg}"
);
}
#[test]
fn client_error_display_detached_remote_reattach_hint_takes_precedence() {
let _guard = env_lock().lock().unwrap();
let _remote_env = EnvVarGuard::set(
crate::remote::REATTACH_COMMAND_ENV_VAR,
"herdr --remote host --session work",
);
let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
let err = ClientError::ServerShutdown {
reason: Some("detached".into()),
};
let msg = err.to_string();
assert!(
msg.contains("Run `herdr --remote host --session work` to reattach"),
"should prefer remote reattach command: {msg}"
);
}
#[test]
fn client_error_display_connection_lost() {
let _guard = env_lock().lock().unwrap();
let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]);
let err =
ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
let msg = err.to_string();
assert!(
msg.contains("lost connection to server"),
"should mention lost connection: {msg}"
);
}
#[test]
fn client_error_display_remote_connection_lost_has_reattach_hint() {
let _guard = env_lock().lock().unwrap();
let _remote_env = EnvVarGuard::set(
crate::remote::REATTACH_COMMAND_ENV_VAR,
"herdr --remote host --session work",
);
let err =
ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
let msg = err.to_string();
assert!(
msg.contains("lost connection to remote Herdr"),
"should mention remote connection loss: {msg}"
);
assert!(
msg.contains("panes may still be running"),
"should explain possible persistence: {msg}"
);
assert!(
msg.contains("Run `herdr --remote host --session work` to reattach"),
"should show remote reattach command: {msg}"
);
}
#[test]
fn sound_from_notify_message_maps_done() {
assert_eq!(
sound_from_notify_message("agent done"),
Some(crate::sound::Sound::Done)
);
}
#[test]
fn sound_from_notify_message_maps_attention() {
assert_eq!(
sound_from_notify_message("agent attention"),
Some(crate::sound::Sound::Request)
);
}
#[test]
fn sound_from_notify_message_rejects_unknown_payloads() {
assert_eq!(sound_from_notify_message("toast"), None);
}
#[test]
fn reload_local_client_config_refreshes_local_client_presentation_state() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let path = std::env::temp_dir().join(format!(
"herdr-client-config-reload-{}-{}.toml",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(
&path,
"[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\n",
)
.unwrap();
let path_string = path.to_string_lossy().to_string();
let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string);
let mut sound_config = crate::config::SoundConfig::default();
let mut redraw_on_focus_gained = true;
let mut draw_host_cursor = false;
#[cfg(unix)]
let mut remote_image_paste_key = None;
reload_local_client_config(
&mut sound_config,
&mut redraw_on_focus_gained,
&mut draw_host_cursor,
#[cfg(unix)]
&mut remote_image_paste_key,
);
assert!(!redraw_on_focus_gained);
assert!(draw_host_cursor);
let _ = std::fs::remove_file(path);
}
#[test]
fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() {
let sound_config = crate::config::SoundConfig::default();
let mut emitted = None;
handle_notify_with_notifiers(
NotifyKind::Toast,
"pi finished",
Some("workspace 1"),
&sound_config,
|title, body| {
emitted = Some((title.to_string(), body.map(str::to_string)));
Ok(true)
},
|_, _| Ok(false),
);
assert_eq!(
emitted,
Some(("pi finished".to_string(), Some("workspace 1".to_string())))
);
}
#[test]
fn system_toast_notify_from_server_uses_system_notifier() {
let sound_config = crate::config::SoundConfig::default();
let mut emitted = None;
handle_notify_with_notifiers(
NotifyKind::SystemToast,
"pi finished",
Some("workspace 1"),
&sound_config,
|_, _| Ok(false),
|title, body| {
emitted = Some((title.to_string(), body.map(str::to_string)));
Ok(true)
},
);
assert_eq!(
emitted,
Some(("pi finished".to_string(), Some("workspace 1".to_string())))
);
}
#[test]
fn system_toast_notify_preserves_colon_in_title() {
let sound_config = crate::config::SoundConfig::default();
let mut emitted = None;
handle_notify_with_notifiers(
NotifyKind::SystemToast,
"build: failed",
Some("api workspace"),
&sound_config,
|_, _| Ok(false),
|title, body| {
emitted = Some((title.to_string(), body.map(str::to_string)));
Ok(true)
},
);
assert_eq!(
emitted,
Some((
"build: failed".to_string(),
Some("api workspace".to_string())
))
);
}
#[test]
fn decode_clipboard_payload_decodes_base64() {
assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec()));
}
#[test]
fn decode_clipboard_payload_rejects_invalid_base64() {
assert_eq!(decode_clipboard_payload("not-base64!!!"), None);
}
#[test]
fn terminal_control_input_command_accepts_text() {
let action =
terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#)
.unwrap();
let ClientMessage::Input { data } = action else {
panic!("expected input command");
};
assert_eq!(data, b"hello");
}
#[test]
fn terminal_control_input_command_accepts_base64_bytes() {
let action =
terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#)
.unwrap();
let ClientMessage::Input { data } = action else {
panic!("expected input command");
};
assert_eq!(data, b"\x1b[A");
}
#[test]
fn terminal_control_resize_command_maps_to_client_resize() {
let action = terminal_control_command_from_json(
r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#,
)
.unwrap();
let ClientMessage::Resize {
cols,
rows,
cell_width_px,
cell_height_px,
} = action
else {
panic!("expected resize command");
};
assert_eq!(
(cols, rows, cell_width_px, cell_height_px),
(100, 30, 8, 16)
);
}
#[test]
fn terminal_control_scroll_command_maps_to_attach_scroll() {
let action = terminal_control_command_from_json(
r#"{"type":"terminal.scroll","direction":"up","lines":3}"#,
)
.unwrap();
let ClientMessage::AttachScroll {
source,
direction,
lines,
..
} = action
else {
panic!("expected scroll command");
};
assert_eq!(source, AttachScrollSource::Wheel);
assert_eq!(direction, AttachScrollDirection::Up);
assert_eq!(lines, 3);
}
#[test]
fn forward_clipboard_uses_local_clipboard_path() {
unsafe {
std::env::set_var("SSH_CONNECTION", "1 2 3 4");
}
forward_clipboard("dGVzdA==");
unsafe {
std::env::remove_var("SSH_CONNECTION");
}
}
#[test]
fn window_title_osc_strips_terminators_and_defaults_to_herdr() {
assert_eq!(
window_title_osc(Some("herdr\x1b api\u{7}\u{9c}")),
b"\x1b]0;herdr api\x07"
);
assert_eq!(window_title_osc(None), b"\x1b]0;herdr\x07");
}
}