//! 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 pub(crate) mod blit; mod input; use std::collections::HashSet; use std::io::{self, Write as _}; use std::os::unix::net::UnixStream; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::execute; use tracing::{debug, info, warn}; use crate::server::headless::client_socket_path; use crate::server::protocol::{ self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock>> = OnceLock::new(); // --------------------------------------------------------------------------- // Client state // --------------------------------------------------------------------------- /// State tracking for the thin client. struct ClientState { /// Stateful semantic-frame encoder used when the server sends FrameData. blit_encoder: blit::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, } #[derive(Debug, Default)] struct AttachEscapeState { pending_prefix: bool, } #[derive(Debug)] enum AttachInputAction { Forward(Vec), Detach, None, } impl AttachEscapeState { fn filter_input(&mut self, data: Vec) -> 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 { AttachInputAction::Forward(output) } } } impl ClientState { fn request_full_redraw(&mut self) { self.blit_encoder = blit::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 }, /// 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 `herdr` to reattach")?; } } _ => { write!(f, "server shut down")?; if let Some(reason) = reason { write!(f, ": {reason}")?; } } } Ok(()) } ClientError::ConnectionLost(err) => { 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 for ClientError { fn from(err: protocol::FramingError) -> Self { 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 { setup_terminal_with_capabilities(true, mouse_capture) } /// Sets up a direct attach terminal. /// /// Direct attach forwards stdin to the attached PTY, so it must not enable /// outer-terminal protocols that generate local responses or mouse reports. fn setup_direct_attach_terminal() -> io::Result { setup_terminal_with_capabilities(false, false) } fn setup_terminal_with_capabilities( enable_client_protocols: bool, mouse_capture: bool, ) -> io::Result { ratatui::init(); if enable_client_protocols { if mouse_capture { execute!(io::stdout(), EnableMouseCapture)?; } else { execute!(io::stdout(), DisableMouseCapture)?; } execute!( io::stdout(), EnableBracketedPaste, EnableFocusChange, PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) )?; } else { execute!(io::stdout(), DisableMouseCapture)?; } let modify_other_keys_mode = enable_client_protocols .then(|| { crate::input::host_modify_other_keys_mode( std::env::var("TMUX").is_ok(), std::env::var("TERM_PROGRAM").ok().as_deref(), std::env::var_os("WEZTERM_PANE").is_some(), ) }) .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(), }) } /// Guard that restores the terminal when dropped. struct TerminalGuard { reset_modify_other_keys: bool, } fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<()> { // Restore a visible cursor and reset DECSCUSR back to the terminal default. writer.write_all(b"\x1b[?25h\x1b[0 q")?; writer.flush() } fn set_mouse_capture(enabled: bool) -> io::Result<()> { if enabled { execute!(io::stdout(), EnableMouseCapture) } else { execute!(io::stdout(), DisableMouseCapture) } } fn restore_terminal_state(reset_modify_other_keys: bool) { 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 _ = execute!( io::stdout(), PopKeyboardEnhancementFlags, DisableFocusChange, DisableBracketedPaste, DisableMouseCapture ); ratatui::restore(); let _ = write_terminal_restore_postlude(&mut io::stdout()); } impl Drop for TerminalGuard { fn drop(&mut self) { restore_terminal_state(self.reset_modify_other_keys); } } // --------------------------------------------------------------------------- // 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, cell_width_px: u32, cell_height_px: u32, requested_encoding: RenderEncoding, ) -> Result { 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, }; protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; // Read Welcome. stream .set_read_timeout(Some(Duration::from_secs(5))) .map_err(ClientError::ConnectionFailed)?; let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; stream .set_read_timeout(None) .map_err(ClientError::ConnectionFailed)?; 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. StdinInput(Vec), /// 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. 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", ) } fn run_client_with_mode( requested_encoding: RenderEncoding, attach_request: Option<(String, bool)>, attach_escape: Option, log_message: &'static str, ) -> io::Result<()> { init_logging(); let loaded_config = crate::config::Config::load(); let sound_config = loaded_config.config.ui.sound; let direct_attach_requested = attach_request.is_some(); let kitty_graphics_enabled = loaded_config.config.experimental.kitty_graphics && !direct_attach_requested; 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 UnixStream::connect(&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, ) { 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 _guard = if direct_attach { setup_direct_attach_terminal() } else { setup_terminal(false) } .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 in_tmux = std::env::var("TMUX").is_ok(); let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { restore_terminal_state(in_tmux); 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, sound_config, kitty_graphics_enabled, false, negotiated_encoding, attach_escape, ) .await }); // Restore the terminal before printing any final status message. drop(_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: UnixStream, cols: u16, rows: u16, should_quit: Arc, sound_config: crate::config::SoundConfig, kitty_graphics_enabled: bool, mouse_capture_active: bool, negotiated_encoding: RenderEncoding, attach_escape: Option, ) -> Result<(), ClientError> { let mut state = ClientState { blit_encoder: blit::BlitEncoder::new(), mouse_capture_active, reported_size: (cols, rows), sound_config, kitty_graphics_enabled, attach_escape, }; 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::(256); // Spawn the stdin reader thread. let stdin_quit = should_quit.clone(); let stdin_tx = event_tx.clone(); std::thread::spawn(move || { input::stdin_reader_loop(stdin_tx, &stdin_quit); }); if state.attach_escape.is_none() { query_host_terminal_theme(); } // Spawn the resize poller thread. let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); 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)?; // 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 { ClientLoopEvent::StdinInput(data) => { let data = if let Some(attach_escape) = &mut state.attach_escape { match attach_escape.filter_input(data) { AttachInputAction::Forward(data) => data, 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.request_full_redraw(); } data }; if should_bridge_clipboard_image_paste(&data) { 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)); } } 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 encoded = 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 } => { handle_notify(kind, &message, &state.sound_config); } ServerMessage::Clipboard { data } => { forward_clipboard(&data); let _ = io::stdout().flush(); } ServerMessage::ReloadSoundConfig => { reload_local_sound_config(&mut state.sound_config); } ServerMessage::MouseCapture { enabled } => { let desired = enabled; if desired != state.mouse_capture_active { set_mouse_capture(desired).map_err(ClientError::ConnectionFailed)?; state.mouse_capture_active = desired; } } 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 => { // Check if we should quit. } } } // 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: UnixStream, event_tx: tokio::sync::mpsc::Sender, should_quit: &Arc, 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 UnixStream, msg: &ClientMessage) -> io::Result<()> { protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) } // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- fn reload_local_sound_config(sound_config: &mut crate::config::SoundConfig) { match crate::config::load_live_config() { Ok(loaded) => { for diagnostic in loaded.config.ui.sound.diagnostics() { warn!(diagnostic = %diagnostic, "local sound config diagnostic"); } *sound_config = loaded.config.ui.sound; debug!("reloaded local sound config"); } Err(diagnostics) => { warn!(diagnostics = ?diagnostics, "failed to reload local sound config; keeping current sound config"); } } } fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig) { handle_notify_with_notifiers( kind, message, sound_config, crate::terminal_notify::show_notification, crate::platform::show_desktop_notification, ); } fn handle_notify_with_notifiers( kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig, mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result, mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result, ) { 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" ); let (title, body) = crate::terminal_notify::split_message(message); if let Err(err) = show_terminal_notification(title, body) { warn!(err = %err, "failed to emit terminal notification"); } } NotifyKind::SystemToast => { debug!( message = message, "received system toast notification from server" ); let (title, body) = crate::terminal_notify::split_message(message); if let Err(err) = show_system_notification(title, body) { warn!(err = %err, "failed to emit system notification"); } } } } fn sound_from_notify_message(message: &str) -> Option { match message { "agent done" => Some(crate::sound::Sound::Done), "agent attention" => Some(crate::sound::Sound::Request), _ => None, } } fn should_bridge_clipboard_image_paste(data: &[u8]) -> bool { if data == b"\x1b[200~\x1b[201~" { return true; } 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 && key.modifiers == crossterm::event::KeyModifiers::CONTROL && matches!(key.code, crossterm::event::KeyCode::Char('v' | 'V')) ) } // --------------------------------------------------------------------------- // Clipboard forwarding // --------------------------------------------------------------------------- /// Decode a clipboard payload forwarded by the server. fn decode_clipboard_payload(data: &str) -> Option> { 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); } // --------------------------------------------------------------------------- // 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 { 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 { 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::() { return Some(id); } } None } fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { 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, initial_cols: u16, initial_rows: u16, kitty_graphics_enabled: bool, should_quit: &Arc, ) { 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 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::*; #[test] fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() { assert!(should_bridge_clipboard_image_paste(&[0x16])); assert!(should_bridge_clipboard_image_paste(b"\x1b[118;5u")); assert!(should_bridge_clipboard_image_paste(b"\x1b[200~\x1b[201~")); assert!(!should_bridge_clipboard_image_paste( b"\x1b[200~text\x1b[201~" )); assert!(!should_bridge_clipboard_image_paste(b"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 terminal_restore_postlude_restores_visible_default_cursor() { let mut output = Vec::new(); write_terminal_restore_postlude(&mut output).unwrap(); assert_eq!(output, b"\x1b[?25h\x1b[0 q"); } #[test] fn attach_escape_detaches_on_prefix_q() { let mut escape = AttachEscapeState::default(); assert!(matches!( escape.filter_input(vec![0x02]), AttachInputAction::None )); assert!(matches!( escape.filter_input(vec![b'q']), AttachInputAction::Detach )); } #[test] fn attach_escape_sends_literal_prefix_on_double_prefix() { let mut escape = AttachEscapeState::default(); assert!(matches!( escape.filter_input(vec![0x02]), AttachInputAction::None )); match escape.filter_input(vec![0x02]) { AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), other => panic!("expected forwarded prefix, got {other:?}"), } } #[test] fn attach_escape_forwards_prefix_before_non_escape_key() { let mut escape = AttachEscapeState::default(); assert!(matches!( escape.filter_input(vec![b'a', 0x02]), AttachInputAction::Forward(bytes) if bytes == b"a" )); match escape.filter_input(vec![b'x']) { AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), other => panic!("expected forwarded bytes, 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_connection_lost() { 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 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 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: 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: 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 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 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"); } } }