diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:19:54 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:22:57 +0300 |
| commit | 866aaf740e10fcfce169aaf81380ea0257fbe272 (patch) | |
| tree | f815392b7c6b24a7c0de14e655bd635656383813 /src/server | |
| parent | ca579c402d5668560ca13059f82c03178be18b2a (diff) | |
refactor: separate server client boundaries
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/autodetect.rs | 6 | ||||
| -rw-r--r-- | src/server/client_accept.rs | 50 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 2 | ||||
| -rw-r--r-- | src/server/clients.rs | 208 | ||||
| -rw-r--r-- | src/server/headless.rs | 573 | ||||
| -rw-r--r-- | src/server/keybindings.rs | 17 | ||||
| -rw-r--r-- | src/server/mod.rs | 7 | ||||
| -rw-r--r-- | src/server/notifications.rs | 60 | ||||
| -rw-r--r-- | src/server/protocol.rs | 1513 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 39 | ||||
| -rw-r--r-- | src/server/socket_paths.rs | 180 | ||||
| -rw-r--r-- | src/server/terminal_attach.rs | 14 |
12 files changed, 635 insertions, 2034 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index fd48de9b..976d2583 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -18,7 +18,7 @@ use std::time::Duration; use tracing::{info, warn}; -use super::headless::client_socket_path; +use super::socket_paths::client_socket_path; /// Maximum time to wait for the server's client socket to become ready /// after spawning the server process. @@ -91,7 +91,7 @@ fn validate_running_server_compatibility() -> io::Result<()> { )); }; - if status.protocol == Some(crate::server::protocol::PROTOCOL_VERSION) { + if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) { return Ok(()); } @@ -103,7 +103,7 @@ fn validate_running_server_compatibility() -> io::Result<()> { .map(|value| value.to_string()) .unwrap_or_else(|| "unknown".to_string()), env!("CARGO_PKG_VERSION"), - crate::server::protocol::PROTOCOL_VERSION + crate::protocol::PROTOCOL_VERSION ))) } diff --git a/src/server/client_accept.rs b/src/server/client_accept.rs new file mode 100644 index 00000000..61db2eb9 --- /dev/null +++ b/src/server/client_accept.rs @@ -0,0 +1,50 @@ +use std::io; +use std::os::unix::net::UnixListener; +use std::sync::{atomic::AtomicBool, Arc}; + +use tokio::sync::mpsc; +use tracing::{debug, error, warn}; + +use crate::server::client_transport::{self, ServerEvent}; + +/// Accepts pending thin-client connections and starts their handshake readers. +pub(crate) fn accept_pending_client_connections( + listener: &UnixListener, + next_client_id: &mut u64, + should_quit: &Arc<AtomicBool>, + server_event_tx: &mpsc::Sender<ServerEvent>, +) -> io::Result<()> { + loop { + match listener.accept() { + Ok((stream, _addr)) => { + let client_id = *next_client_id; + *next_client_id = next_client_id.saturating_add(1); + + if let Err(err) = stream.set_nonblocking(true) { + warn!(err = %err, "failed to set client stream nonblocking"); + continue; + } + + let should_quit = should_quit.clone(); + let server_event_tx = server_event_tx.clone(); + std::thread::spawn(move || { + if let Err(err) = client_transport::handle_client_handshake( + stream, + client_id, + &server_event_tx, + &should_quit, + ) { + debug!(client_id, err = %err, "client handshake failed"); + } + }); + } + Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => break, + Err(err) => { + error!(err = %err, "client listener accept failed"); + break; + } + } + } + + Ok(()) +} diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 0abea899..5a5984aa 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -13,7 +13,7 @@ use std::time::Duration; use tokio::sync::mpsc; use tracing::{debug, warn}; -use crate::server::protocol::{ +use crate::protocol::{ self, ClientKeybindings, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; diff --git a/src/server/clients.rs b/src/server/clients.rs new file mode 100644 index 00000000..4ee1ff09 --- /dev/null +++ b/src/server/clients.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::protocol::RenderEncoding; +use crate::server::client_transport::ClientWriter; +use crate::server::render_stream::ClientRenderState; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClientConnectionMode { + App, + TerminalAttach { terminal_id: String }, +} + +pub(crate) type RenderTarget = ( + u64, + (u16, u16), + crate::kitty_graphics::HostCellSize, + bool, + ClientConnectionMode, +); + +/// A connected client tracked by the server. +pub(crate) struct ClientConnection { + /// Whether this connection is the full app client or a direct terminal attach. + pub(crate) mode: ClientConnectionMode, + /// Client-local app keybindings. None means use the server's keybindings. + pub(crate) keybindings: Option<Box<crate::config::LiveKeybindConfig>>, + /// The client's terminal size after clamping. + pub(crate) terminal_size: (u16, u16), + /// Pixel size of one client terminal cell. + pub(crate) cell_size: crate::kitty_graphics::HostCellSize, + /// Last known host terminal default colors for this client. + pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last reported focus state for this client's outer terminal. + pub(crate) outer_terminal_focus: Option<bool>, + /// Monotonic activity stamp used to choose the fallback foreground client. + pub(crate) last_activity: u64, + /// Render baseline for the negotiated client encoding. + pub(crate) render_state: ClientRenderState, + /// Client-local host Kitty graphics cache. + pub(crate) graphics_cache: crate::kitty_graphics::HostGraphicsCache, + /// Whether the next graphics frame must clear and rebuild host-side Kitty state. + pub(crate) graphics_surface_reset_pending: bool, + /// Whether a render was skipped because the render channel was full. + pub(crate) render_pending: bool, + /// Last host mouse capture mode sent to this client. + pub(crate) host_mouse_capture_active: Option<bool>, + /// Temporary files staged from this client's local clipboard image pastes. + pub(crate) staged_clipboard_files: Vec<PathBuf>, + /// Channels for sending framed ServerMessage data to the client writer thread. + pub(crate) writer: Option<ClientWriter>, +} + +impl ClientConnection { + #[cfg(test)] + pub(crate) fn new( + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + outer_terminal_focus: Option<bool>, + last_activity: u64, + render_encoding: RenderEncoding, + writer: Option<ClientWriter>, + ) -> Self { + Self::new_with_mode( + ClientConnectionMode::App, + None, + terminal_size, + cell_size, + host_terminal_theme, + outer_terminal_focus, + last_activity, + render_encoding, + writer, + ) + } + + pub(crate) fn new_with_mode( + mode: ClientConnectionMode, + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + outer_terminal_focus: Option<bool>, + last_activity: u64, + render_encoding: RenderEncoding, + writer: Option<ClientWriter>, + ) -> Self { + Self { + mode, + keybindings, + terminal_size, + cell_size, + host_terminal_theme, + outer_terminal_focus, + last_activity, + render_state: ClientRenderState::new(render_encoding), + graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), + graphics_surface_reset_pending: false, + render_pending: false, + host_mouse_capture_active: None, + staged_clipboard_files: Vec::new(), + writer, + } + } + + pub(crate) fn request_full_redraw(&mut self) { + self.render_state.reset_baseline(); + self.graphics_surface_reset_pending = true; + } + + pub(crate) fn request_semantic_redraw_after_input(&mut self) { + self.render_state.reset_semantic_input_baseline(); + } + + pub(crate) fn update_host_theme_from_events( + &mut self, + events: &[crate::raw_input::RawInputEvent], + ) -> bool { + let mut next_theme = self.host_terminal_theme; + for event in events { + if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event { + next_theme = next_theme.with_color(*kind, *color); + } + } + + if next_theme == self.host_terminal_theme { + return false; + } + + self.host_terminal_theme = next_theme; + true + } + + pub(crate) fn update_outer_focus_from_events( + &mut self, + events: &[crate::raw_input::RawInputEvent], + ) -> Option<bool> { + let next_focus = events + .iter() + .filter_map(|event| match event { + crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), + crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), + _ => None, + }) + .next_back()?; + + self.outer_terminal_focus = Some(next_focus); + Some(next_focus) + } +} + +pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { + events.iter().any(|event| { + matches!( + event, + crate::raw_input::RawInputEvent::Key(_) + | crate::raw_input::RawInputEvent::Mouse(_) + | crate::raw_input::RawInputEvent::Paste(_) + | crate::raw_input::RawInputEvent::OuterFocusGained + ) + }) +} + +pub(crate) fn latest_app_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { + clients + .iter() + .filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App)) + .max_by_key(|(_, client)| client.last_activity) + .map(|(&client_id, _)| client_id) +} + +pub(crate) fn terminal_attach_client_ids( + clients: &HashMap<u64, ClientConnection>, + terminal_id: &str, +) -> Vec<u64> { + clients + .iter() + .filter_map(|(&client_id, client)| match &client.mode { + ClientConnectionMode::TerminalAttach { + terminal_id: attached, + } if attached == terminal_id => Some(client_id), + _ => None, + }) + .collect() +} + +pub(crate) fn render_targets( + clients: &HashMap<u64, ClientConnection>, + foreground_client_id: Option<u64>, +) -> Vec<RenderTarget> { + let mut targets: Vec<RenderTarget> = clients + .iter() + .filter(|(_, client)| client.writer.is_some()) + .map(|(&client_id, client)| { + ( + client_id, + client.terminal_size, + client.cell_size, + foreground_client_id == Some(client_id), + client.mode.clone(), + ) + }) + .collect(); + + targets.sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); + targets +} diff --git a/src/server/headless.rs b/src/server/headless.rs index ddfd631c..842ded80 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -25,23 +25,35 @@ use std::time::{Duration, Instant}; use ratatui::layout::Rect; use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; use base64::Engine; use bytes::Bytes; use crate::api; use crate::app; -use crate::app::state::AppState; use crate::config; -use crate::detect::AgentState; use crate::events::AppEvent; -use crate::layout::PaneId; -use crate::server::client_transport::{self, ClientWriter, ServerEvent}; -use crate::server::protocol::{ - self, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, +use crate::protocol::{self, FrameData, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE}; +use crate::server::client_accept::accept_pending_client_connections; +use crate::server::client_transport::ServerEvent; +use crate::server::clients::{ + events_include_interaction, latest_app_client, render_targets, terminal_attach_client_ids, + ClientConnection, ClientConnectionMode, }; -use crate::server::render_stream::ClientRenderState; +use crate::server::keybindings::{app_keybindings, apply_keybindings}; +use crate::server::notifications::{ + should_forward_toast_to_clients, toast_message_from_state_change, toast_notify_kind, +}; +use crate::server::socket_paths::{ + client_socket_path, prepare_socket_path, restrict_socket_permissions, +}; +use crate::server::terminal_attach::paste_payload_for_runtime; + +#[cfg(test)] +use crate::protocol::RenderEncoding; +#[cfg(test)] +use crate::server::client_transport::ClientWriter; // --------------------------------------------------------------------------- // Loop event enum for the headless server event loop @@ -64,28 +76,6 @@ enum LoopEvent { const MIN_COLS: u16 = 80; const MIN_ROWS: u16 = 24; -fn paste_payload_for_runtime(runtime: &crate::terminal::TerminalRuntime, text: &str) -> String { - if runtime - .input_state() - .map(|state| state.bracketed_paste) - .unwrap_or(false) - { - format!("\x1b[200~{text}\x1b[201~") - } else { - text.to_owned() - } -} - -/// Legacy environment variable for overriding the client socket path. -/// -/// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`. -/// This variable is kept as a fallback for callers that explicitly need a -/// client-only override when `HERDR_SOCKET_PATH` is not set. -pub const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH"; - -/// Socket permission mode (owner read/write only). -const SOCKET_PERMISSION_MODE: u32 = 0o600; - /// Timeout for in-flight API requests during shutdown. #[allow(dead_code)] const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5); @@ -99,263 +89,6 @@ const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5); /// avoid reintroducing the idle CPU spin. const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); -fn should_forward_toast_to_clients(delivery: config::ToastDelivery) -> bool { - toast_notify_kind(delivery).is_some() -} - -fn toast_notify_kind(delivery: config::ToastDelivery) -> Option<protocol::NotifyKind> { - match delivery { - config::ToastDelivery::Terminal => Some(protocol::NotifyKind::Toast), - config::ToastDelivery::System => Some(protocol::NotifyKind::SystemToast), - config::ToastDelivery::Off | config::ToastDelivery::Herdr => None, - } -} - -fn toast_event_text(kind: app::state::ToastKind) -> &'static str { - match kind { - app::state::ToastKind::NeedsAttention => "needs attention", - app::state::ToastKind::Finished => "finished", - app::state::ToastKind::UpdateInstalled => "updated", - } -} - -fn toast_message_from_state_change( - state: &AppState, - pane_id: PaneId, - suppress_active_tab_notifications: bool, - prev_state: AgentState, - new_state: AgentState, -) -> Option<String> { - let kind = app::actions::notification_toast_for_state_change( - suppress_active_tab_notifications, - prev_state, - new_state, - )?; - - state - .workspaces - .iter() - .enumerate() - .find_map(|(ws_idx, ws)| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - let agent_label = state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label())?; - Some(format!( - "{} {}: {}", - agent_label, - toast_event_text(kind), - app::actions::notification_context(ws, ws_idx, pane_id) - )) - }) - }) -} - -// --------------------------------------------------------------------------- -// Socket path helpers -// --------------------------------------------------------------------------- - -/// Returns the path for the client protocol socket. -/// -/// Contract-aligned override behavior: -/// 1. If CLI `--session <name>` is active, use that session's client socket. -/// 2. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by -/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`). -/// This keeps JSON API and client socket overrides consistent. -/// 3. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback). -/// 4. Otherwise, use the active session data directory. -pub fn client_socket_path() -> PathBuf { - if crate::session::explicit_session_requested() { - return crate::session::client_socket_path_for(crate::session::active_name().as_deref()); - } - client_socket_path_from_overrides( - std::env::var(api::SOCKET_PATH_ENV_VAR).ok().as_deref(), - std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(), - ) -} - -fn client_socket_path_from_overrides( - api_socket_override: Option<&str>, - client_socket_override: Option<&str>, -) -> PathBuf { - if let Some(api_socket_override) = api_socket_override { - return derive_client_socket_from_api_socket(Path::new(api_socket_override)); - } - - if let Some(client_socket_override) = client_socket_override { - return PathBuf::from(client_socket_override); - } - - crate::session::client_socket_path_for(crate::session::active_name().as_deref()) -} - -fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { - let stem = api_socket_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("herdr"); - let parent = api_socket_path.parent().unwrap_or_else(|| Path::new("")); - - if api_socket_path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext == "sock") - { - return parent.join(format!("{stem}-client.sock")); - } - - parent.join(format!("{stem}-client.sock")) -} - -fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { - crate::config::LiveKeybindConfig { - prefix: (app.state.prefix_code, app.state.prefix_mods), - keybinds: app.state.keybinds.clone(), - } -} - -fn apply_keybindings(app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig) { - app.state.prefix_code = keybindings.prefix.0; - app.state.prefix_mods = keybindings.prefix.1; - app.state.keybinds = keybindings.keybinds.clone(); -} - -// --------------------------------------------------------------------------- -// Connected client state -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ClientConnectionMode { - App, - TerminalAttach { terminal_id: String }, -} - -type RenderTarget = ( - u64, - (u16, u16), - crate::kitty_graphics::HostCellSize, - bool, - ClientConnectionMode, -); - -/// A connected client tracked by the server. -struct ClientConnection { - /// Whether this connection is the full app client or a direct terminal attach. - mode: ClientConnectionMode, - /// Client-local app keybindings. None means use the server's keybindings. - keybindings: Option<Box<crate::config::LiveKeybindConfig>>, - /// The client's terminal size (after clamping). - terminal_size: (u16, u16), - /// Pixel size of one client terminal cell. - cell_size: crate::kitty_graphics::HostCellSize, - /// Last known host terminal default colors for this client. - host_terminal_theme: crate::terminal_theme::TerminalTheme, - /// Last reported focus state for this client's outer terminal. - outer_terminal_focus: Option<bool>, - /// Monotonic activity stamp used to choose the fallback foreground client. - last_activity: u64, - /// Render baseline for the negotiated client encoding. - render_state: ClientRenderState, - /// Client-local host Kitty graphics cache. - graphics_cache: crate::kitty_graphics::HostGraphicsCache, - /// Whether the next graphics frame must clear and rebuild host-side Kitty state. - graphics_surface_reset_pending: bool, - /// Whether a render was skipped because the render channel was full. - render_pending: bool, - /// Last host mouse capture mode sent to this client. - host_mouse_capture_active: Option<bool>, - /// Temporary files staged from this client's local clipboard image pastes. - staged_clipboard_files: Vec<PathBuf>, - /// Channels for sending framed ServerMessage data to the client writer thread. - writer: Option<ClientWriter>, -} - -impl ClientConnection { - #[cfg(test)] - fn new( - terminal_size: (u16, u16), - cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, - last_activity: u64, - render_encoding: RenderEncoding, - writer: Option<ClientWriter>, - ) -> Self { - Self::new_with_mode( - ClientConnectionMode::App, - None, - terminal_size, - cell_size, - host_terminal_theme, - outer_terminal_focus, - last_activity, - render_encoding, - writer, - ) - } - - fn new_with_mode( - mode: ClientConnectionMode, - keybindings: Option<Box<crate::config::LiveKeybindConfig>>, - terminal_size: (u16, u16), - cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, - last_activity: u64, - render_encoding: RenderEncoding, - writer: Option<ClientWriter>, - ) -> Self { - Self { - mode, - keybindings, - terminal_size, - cell_size, - host_terminal_theme, - outer_terminal_focus, - last_activity, - render_state: ClientRenderState::new(render_encoding), - graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), - graphics_surface_reset_pending: false, - render_pending: false, - host_mouse_capture_active: None, - staged_clipboard_files: Vec::new(), - writer, - } - } - - fn request_full_redraw(&mut self) { - self.render_state.reset_baseline(); - self.graphics_surface_reset_pending = true; - } - - fn request_semantic_redraw_after_input(&mut self) { - self.render_state.reset_semantic_input_baseline(); - } -} - -// --------------------------------------------------------------------------- -// Stale socket cleanup -// --------------------------------------------------------------------------- - -/// Prepares a socket path for binding: creates parent directories, -/// removes stale socket files (where no server is listening), and -/// returns an error if a live server is already bound. -fn prepare_socket_path(path: &Path) -> io::Result<()> { - crate::ipc::prepare_socket_path(path, |path| { - format!( - "herdr server is already running (socket busy at {})", - path.display() - ) - }) -} - -/// Restricts socket file permissions to owner-only (0o600). -fn restrict_socket_permissions(path: &Path) -> io::Result<()> { - crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) -} - // --------------------------------------------------------------------------- // Headless server // --------------------------------------------------------------------------- @@ -616,9 +349,18 @@ impl HeadlessServer { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { - crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size); + crate::ui::compute_view_with_cell_size( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + client.cell_size, + ); } else { - crate::ui::compute_view(&mut self.app.state, area); + crate::ui::compute_view_with_runtime_registry( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); } // Shared runtime size changes affect pane wrapping and foreground-driven @@ -697,12 +439,7 @@ impl HeadlessServer { } fn promote_latest_remaining_client(&mut self) -> bool { - let next_foreground = self - .clients - .iter() - .filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App)) - .max_by_key(|(_, client)| client.last_activity) - .map(|(&client_id, _)| client_id); + let next_foreground = latest_app_client(&self.clients); let changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; self.sync_foreground_client_state(); @@ -768,20 +505,12 @@ impl HeadlessServer { return false; }; - let mut next_theme = client.host_terminal_theme; - for event in events { - if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event { - next_theme = next_theme.with_color(*kind, *color); - } - } - - if next_theme == client.host_terminal_theme { + if !client.update_host_theme_from_events(events) { return false; } - client.host_terminal_theme = next_theme; if self.foreground_client_id == Some(client_id) { - self.app.set_host_terminal_theme(next_theme) + self.app.set_host_terminal_theme(client.host_terminal_theme) } else { false } @@ -792,77 +521,25 @@ impl HeadlessServer { client_id: u64, events: &[crate::raw_input::RawInputEvent], ) { - let next_focus = events - .iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), - crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), - _ => None, - }) - .next_back(); - - let Some(next_focus) = next_focus else { + let Some(client) = self.clients.get_mut(&client_id) else { return; }; - let Some(client) = self.clients.get_mut(&client_id) else { + let Some(next_focus) = client.update_outer_focus_from_events(events) else { return; }; - client.outer_terminal_focus = Some(next_focus); if self.foreground_client_id == Some(client_id) { self.app.state.outer_terminal_focus = Some(next_focus); } } - fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { - events.iter().any(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) - | crate::raw_input::RawInputEvent::OuterFocusGained - ) - }) - } - /// Accepts pending client connections from the non-blocking listener. fn accept_client_connections(&mut self) -> io::Result<()> { - loop { - match self.client_listener.accept() { - Ok((stream, _addr)) => { - let client_id = self.next_client_id; - self.next_client_id += 1; - - if let Err(err) = stream.set_nonblocking(true) { - warn!(err = %err, "failed to set client stream nonblocking"); - continue; - } - - // Spawn a thread for the handshake and read loop. - let should_quit = self.should_quit.clone(); - let server_event_tx = self.server_event_tx.clone(); - std::thread::spawn(move || { - if let Err(err) = client_transport::handle_client_handshake( - stream, - client_id, - &server_event_tx, - &should_quit, - ) { - debug!(client_id, err = %err, "client handshake failed"); - } - }); - } - Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => { - // No more pending connections. - break; - } - Err(err) => { - error!(err = %err, "client listener accept failed"); - break; - } - } - } - Ok(()) + accept_pending_client_connections( + &self.client_listener, + &mut self.next_client_id, + &self.should_quit, + &self.server_event_tx, + ) } /// Drains server events from the dedicated channel. @@ -890,7 +567,7 @@ impl HeadlessServer { terminal_id: &str, ) -> Option<&crate::terminal::TerminalRuntime> { let terminal_id = self.terminal_id_by_string(terminal_id)?; - self.app.state.terminal_runtimes.get(&terminal_id) + self.app.terminal_runtimes.get(&terminal_id) } fn write_client_clipboard_image( @@ -1328,16 +1005,7 @@ impl HeadlessServer { } fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) { - let client_ids: Vec<u64> = self - .clients - .iter() - .filter_map(|(&client_id, client)| match &client.mode { - ClientConnectionMode::TerminalAttach { - terminal_id: attached, - } if attached == terminal_id => Some(client_id), - _ => None, - }) - .collect(); + let client_ids = terminal_attach_client_ids(&self.clients, terminal_id); for client_id in client_ids { self.send_to_client( @@ -1419,7 +1087,7 @@ impl HeadlessServer { .state .direct_attach_resize_locks .insert(real_terminal_id.clone()); - if let Some(runtime) = self.app.state.terminal_runtimes.get(&real_terminal_id) { + if let Some(runtime) = self.app.terminal_runtimes.get(&real_terminal_id) { runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px); } true @@ -1504,7 +1172,7 @@ impl HeadlessServer { } } self.update_client_outer_focus_from_events(client_id, &events); - let interaction = Self::events_include_interaction(&events); + let interaction = events_include_interaction(&events); let foreground_changed = if interaction { self.promote_client_to_foreground(client_id) } else { @@ -1878,7 +1546,10 @@ impl HeadlessServer { } fn stream_host_mouse_capture_mode(&mut self) { - let enabled = self.app.state.should_capture_host_mouse(); + let enabled = self + .app + .state + .should_capture_host_mouse_from(&self.app.terminal_runtimes); let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) { Ok(framed) => framed, @@ -1921,33 +1592,18 @@ impl HeadlessServer { /// Renders the current state to client-sized virtual buffers and streams /// frames to all connected clients. fn render_and_stream(&mut self) { - let foreground_client_id = self.foreground_client_id; - let mut render_targets: Vec<RenderTarget> = self - .clients - .iter() - .filter(|(_, client)| client.writer.is_some()) - .map(|(&client_id, client)| { - ( - client_id, - client.terminal_size, - client.cell_size, - foreground_client_id == Some(client_id), - client.mode.clone(), - ) - }) - .collect(); - - render_targets - .sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); + let render_targets = render_targets(&self.clients, self.foreground_client_id); if render_targets.is_empty() { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); let resize_panes = self.app.state.view.pane_infos.is_empty(); - let _ = crate::server::render_stream::render_virtual( + let _ = crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, + &self.app.terminal_runtimes, area, resize_panes, + crate::kitty_graphics::HostCellSize::default(), ); debug!( cols, @@ -1964,21 +1620,26 @@ impl HeadlessServer { ClientConnectionMode::App => { let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known() { - crate::server::render_stream::render_virtual_with_cell_size( + crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, + &self.app.terminal_runtimes, area, is_foreground, cell_size, ) } else { - crate::server::render_stream::render_virtual( + crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, + &self.app.terminal_runtimes, area, is_foreground, + crate::kitty_graphics::HostCellSize::default(), ) }; - let hyperlinks = - crate::server::render_stream::visible_hyperlinks(&self.app.state); + let hyperlinks = crate::server::render_stream::visible_hyperlinks( + &self.app.state, + &self.app.terminal_runtimes, + ); FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) } ClientConnectionMode::TerminalAttach { terminal_id } => { @@ -2014,6 +1675,7 @@ impl HeadlessServer { .graphics .extend(crate::kitty_graphics::encode_local_pane_graphics( &self.app.state, + &self.app.terminal_runtimes, cell_size, &mut next_graphics_cache, )); @@ -2415,7 +2077,8 @@ fn init_logging() { mod tests { use super::*; - use crate::server::protocol::CursorState; + use crate::app::AppState; + use crate::protocol::CursorState; fn test_headless_server() -> HeadlessServer { let config = crate::config::Config::default(); @@ -2632,100 +2295,6 @@ new_tab = "prefix+t" } #[test] - fn client_socket_path_derived_from_api_socket_override() { - let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None); - assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); - } - - #[test] - fn client_socket_path_api_override_takes_precedence_over_legacy_client_override() { - let path = client_socket_path_from_overrides( - Some("/tmp/test-herdr.sock"), - Some("/tmp/legacy-client.sock"), - ); - assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); - } - - #[test] - fn client_socket_path_respects_legacy_client_override_without_api_override() { - let path = client_socket_path_from_overrides(None, Some("/tmp/test-herdr-client.sock")); - assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); - } - - #[test] - fn client_socket_path_defaults_to_config_dir() { - std::env::remove_var(crate::session::SESSION_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - let path = client_socket_path_from_overrides(None, None); - assert_eq!(path, config::config_dir().join("herdr-client.sock")); - } - - #[test] - fn derive_client_socket_from_api_socket_without_sock_extension() { - let derived = derive_client_socket_from_api_socket(Path::new("/tmp/custom-api")); - assert_eq!(derived, PathBuf::from("/tmp/custom-api-client.sock")); - } - - #[test] - fn prepare_socket_path_removes_stale_socket() { - let dir = PathBuf::from(format!( - "/tmp/hs-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = fs::create_dir_all(&dir); - let socket_path = dir.join("stale.sock"); - - // Create a socket file that nobody is listening on. - { - let _listener = UnixListener::bind(&socket_path).expect("bind stale socket"); - } - // The listener scope ended, so the socket is now stale. - let deadline = std::time::Instant::now() + Duration::from_secs(1); - while std::time::Instant::now() < deadline { - if std::os::unix::net::UnixStream::connect(&socket_path).is_err() { - break; - } - std::thread::sleep(Duration::from_millis(10)); - } - - // prepare_socket_path should remove it without error. - let result = prepare_socket_path(&socket_path); - assert!(result.is_ok(), "should remove stale socket: {result:?}"); - - // Socket file should be gone. - assert!(!socket_path.exists()); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn prepare_socket_path_rejects_live_socket() { - let dir = PathBuf::from(format!( - "/tmp/hl-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = fs::create_dir_all(&dir); - let socket_path = dir.join("live.sock"); - - // Bind a live listener. - let _listener = UnixListener::bind(&socket_path).expect("bind"); - - let result = prepare_socket_path(&socket_path); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AddrInUse); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); @@ -2876,8 +2445,9 @@ new_tab = "prefix+t" let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let runtime = state - .runtime_for_pane(pane_id) + .runtime_for_pane(&terminal_runtimes, pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); @@ -3031,8 +2601,9 @@ new_tab = "prefix+t" let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let runtime = state - .runtime_for_pane(pane_id) + .runtime_for_pane(&terminal_runtimes, pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); @@ -3325,7 +2896,7 @@ new_tab = "prefix+t" server .app .state - .runtime_for_pane(active_pane) + .runtime_for_pane(&server.app.terminal_runtimes, active_pane) .unwrap() .current_size(), expected @@ -3334,7 +2905,7 @@ new_tab = "prefix+t" server .app .state - .runtime_for_pane(background_pane) + .runtime_for_pane(&server.app.terminal_runtimes, background_pane) .unwrap() .current_size(), expected diff --git a/src/server/keybindings.rs b/src/server/keybindings.rs new file mode 100644 index 00000000..262463ff --- /dev/null +++ b/src/server/keybindings.rs @@ -0,0 +1,17 @@ +use crate::app; + +pub(crate) fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { + crate::config::LiveKeybindConfig { + prefix: (app.state.prefix_code, app.state.prefix_mods), + keybinds: app.state.keybinds.clone(), + } +} + +pub(crate) fn apply_keybindings( + app: &mut app::App, + keybindings: &crate::config::LiveKeybindConfig, +) { + app.state.prefix_code = keybindings.prefix.0; + app.state.prefix_mods = keybindings.prefix.1; + app.state.keybinds = keybindings.keybinds.clone(); +} diff --git a/src/server/mod.rs b/src/server/mod.rs index b41e2a67..b123e111 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,6 +1,11 @@ pub mod autodetect; +pub(crate) mod client_accept; pub(crate) mod client_transport; +pub(crate) mod clients; pub(crate) mod clipboard_image; pub mod headless; -pub mod protocol; +pub(crate) mod keybindings; +pub(crate) mod notifications; pub(crate) mod render_stream; +pub mod socket_paths; +pub(crate) mod terminal_attach; diff --git a/src/server/notifications.rs b/src/server/notifications.rs new file mode 100644 index 00000000..1729c698 --- /dev/null +++ b/src/server/notifications.rs @@ -0,0 +1,60 @@ +use crate::app; +use crate::app::state::AppState; +use crate::config; +use crate::detect::AgentState; +use crate::layout::PaneId; +use crate::protocol; + +pub(crate) fn should_forward_toast_to_clients(delivery: config::ToastDelivery) -> bool { + toast_notify_kind(delivery).is_some() +} + +pub(crate) fn toast_notify_kind(delivery: config::ToastDelivery) -> Option<protocol::NotifyKind> { + match delivery { + config::ToastDelivery::Terminal => Some(protocol::NotifyKind::Toast), + config::ToastDelivery::System => Some(protocol::NotifyKind::SystemToast), + config::ToastDelivery::Off | config::ToastDelivery::Herdr => None, + } +} + +pub(crate) fn toast_message_from_state_change( + state: &AppState, + pane_id: PaneId, + suppress_active_tab_notifications: bool, + prev_state: AgentState, + new_state: AgentState, +) -> Option<String> { + let kind = app::actions::notification_toast_for_state_change( + suppress_active_tab_notifications, + prev_state, + new_state, + )?; + + state + .workspaces + .iter() + .enumerate() + .find_map(|(ws_idx, ws)| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + let agent_label = state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label())?; + Some(format!( + "{} {}: {}", + agent_label, + toast_event_text(kind), + app::actions::notification_context(ws, ws_idx, pane_id) + )) + }) + }) +} + +fn toast_event_text(kind: app::state::ToastKind) -> &'static str { + match kind { + app::state::ToastKind::NeedsAttention => "needs attention", + app::state::ToastKind::Finished => "finished", + app::state::ToastKind::UpdateInstalled => "updated", + } +} diff --git a/src/server/protocol.rs b/src/server/protocol.rs deleted file mode 100644 index 33a5f513..00000000 --- a/src/server/protocol.rs +++ /dev/null @@ -1,1513 +0,0 @@ -//! Wire protocol for herdr server/client communication. -//! -//! Defines the message types, framing, version negotiation, and safety -//! constraints for the binary protocol over Unix domain sockets. - -use std::collections::HashMap; -use std::io::{self, Read, Write}; - -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Protocol constants -// --------------------------------------------------------------------------- - -/// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 9; - -/// Maximum allowed frame payload size (2 MB). Frames larger than this are -/// rejected to prevent denial-of-service via oversized length prefixes. -pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024; - -/// Maximum allowed server-to-client frame payload when Kitty graphics are enabled. -/// Normal traffic keeps `MAX_FRAME_SIZE`; this larger cap is only for explicit -/// image payloads that are naturally much larger after base64 encoding. -pub const MAX_GRAPHICS_FRAME_SIZE: usize = 32 * 1024 * 1024; - -/// Maximum clipboard image payload size for remote paste bridging. -pub const MAX_CLIPBOARD_IMAGE_PAYLOAD: usize = 16 * 1024 * 1024; - -/// Length of the u32 little-endian length prefix in bytes. -const LENGTH_PREFIX_BYTES: usize = 4; - -// --------------------------------------------------------------------------- -// Client → Server messages -// --------------------------------------------------------------------------- - -/// Render payload encoding negotiated during client handshake. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RenderEncoding { - /// Send full semantic FrameData values. This is the local/default mode. - SemanticFrame, - /// Send already-diffed terminal ANSI byte streams. - TerminalAnsi, -} - -/// Keybinding profile requested by an attached app client. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ClientKeybindings { - /// Use the server's own keybinding config. - Server, - /// Use this attached client's normalized local `[keys]` config. - Local { keys_toml: String }, -} - -/// Messages sent from the client to the server over the client protocol socket. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ClientMessage { - /// Handshake: client announces its protocol version and terminal dimensions. - Hello { - /// Protocol version the client speaks. - version: u32, - /// Terminal width in columns. - cols: u16, - /// Terminal height in rows. - rows: u16, - /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. - cell_width_px: u32, - /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. - cell_height_px: u32, - /// Render encoding requested by the client. - requested_encoding: RenderEncoding, - /// Keybinding profile requested by the client. - keybindings: ClientKeybindings, - }, - - /// Raw input bytes read from the client's stdin. - Input { - /// Raw terminal input (possibly multi-byte escape sequences). - data: Vec<u8>, - }, - - /// Image bytes read from the client's local clipboard for remote paste bridging. - ClipboardImage { - /// Image file extension without a leading dot. - extension: String, - /// Raw image bytes. - data: Vec<u8>, - }, - - /// Terminal resize notification from the client. - Resize { - /// New terminal width in columns. - cols: u16, - /// New terminal height in rows. - rows: u16, - /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. - cell_width_px: u32, - /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. - cell_height_px: u32, - }, - - /// Graceful disconnect request. - Detach, - - /// Switch this connection into direct terminal attach mode. - AttachTerminal { - /// Terminal id to attach to. - terminal_id: String, - /// Replace an existing writable attach owner for this terminal. - takeover: bool, - }, -} - -// --------------------------------------------------------------------------- -// Server → Client messages -// --------------------------------------------------------------------------- - -/// A single cell in a rendered frame, serialized independently from ratatui's -/// `Cell` type to keep the wire protocol stable. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CellData { - /// Grapheme cluster displayed in this cell (usually 1–2 chars). - pub symbol: String, - /// Foreground color as a packed u32 (0xAARRGGBB or ratatui Color index). - pub fg: u32, - /// Background color as a packed u32. - pub bg: u32, - /// Bitmask of style modifiers (bold, italic, etc.). - pub modifier: u16, - /// Whether this cell should be skipped during diff-based rendering. - pub skip: bool, - /// Index into `FrameData::hyperlinks` for this cell's OSC 8 target, if any. - pub hyperlink: Option<u32>, -} - -/// Cursor shape encoded as a DECSCUSR parameter. -/// -/// 0 = terminal default, 1 = blinking block, 2 = steady block, -/// 3 = blinking underline, 4 = steady underline, 5 = blinking bar, -/// 6 = steady bar. -pub type CursorShapeParam = u8; - -/// Cursor position within a rendered frame. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CursorState { - /// Column offset (0-based) of the cursor. - pub x: u16, - /// Row offset (0-based) of the cursor. - pub y: u16, - /// Whether the cursor is visible. - pub visible: bool, - /// Cursor shape as a DECSCUSR parameter. - #[serde(default)] - pub shape: CursorShapeParam, -} - -/// A rendered frame to be displayed by the client. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FrameData { - /// Cells in row-major order. Length must equal `width * height`. - pub cells: Vec<CellData>, - /// Frame width in columns. - pub width: u16, - /// Frame height in rows. - pub height: u16, - /// Cursor state for this frame, if applicable. - pub cursor: Option<CursorState>, - /// OSC 8 hyperlink URIs referenced by cells. - pub hyperlinks: Vec<String>, - /// Kitty graphics protocol bytes to apply after the text frame. - pub graphics: Vec<u8>, -} - -impl FrameData { - /// Creates a `FrameData` from a ratatui `Buffer` and optional cursor. - /// - /// This converts ratatui's internal cell representation into the - /// wire-protocol cell format. The conversion is lossless for all - /// commonly used cell attributes. - #[cfg(test)] - pub fn from_ratatui_buffer( - buffer: &ratatui::buffer::Buffer, - cursor: Option<CursorState>, - ) -> Self { - Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[]) - } - - pub fn from_ratatui_buffer_with_hyperlinks( - buffer: &ratatui::buffer::Buffer, - cursor: Option<CursorState>, - hyperlinks: &[((u16, u16), String, String)], - ) -> Self { - let area = buffer.area; - let width = area.width; - let height = area.height; - - let mut hyperlink_uris = Vec::<String>::new(); - let mut hyperlink_indices = HashMap::<&str, u32>::new(); - let mut hyperlink_by_position = HashMap::<(u16, u16), (&str, &str)>::new(); - for ((x, y), symbol, uri) in hyperlinks { - hyperlink_by_position.insert((*x, *y), (symbol.as_str(), uri.as_str())); - } - let mut cells = Vec::with_capacity((width as usize) * (height as usize)); - for row in 0..height { - for col in 0..width { - let cell = buffer.cell((col, row)).expect("cell within bounds"); - let hyperlink = hyperlink_by_position - .get(&(col, row)) - .and_then(|(symbol, uri)| { - if *symbol != cell.symbol() { - return None; - } - Some(*hyperlink_indices.entry(*uri).or_insert_with(|| { - let index = hyperlink_uris.len() as u32; - hyperlink_uris.push((*uri).to_owned()); - index - })) - }); - cells.push(CellData { - symbol: cell.symbol().to_owned(), - fg: color_to_u32(cell.fg), - bg: color_to_u32(cell.bg), - modifier: modifier_to_u16(cell.modifier), - skip: cell.skip, - hyperlink, - }); - } - } - - FrameData { - cells, - width, - height, - cursor, - hyperlinks: hyperlink_uris, - graphics: Vec::new(), - } - } - - /// Reconstructs a ratatui `Buffer` from this frame data. - /// - /// Returns `None` if the cells vector length doesn't match `width * height`. - #[cfg(test)] - pub fn to_ratatui_buffer(&self) -> Option<ratatui::buffer::Buffer> { - let expected = (self.width as usize) * (self.height as usize); - if self.cells.len() != expected { - return None; - } - - let area = ratatui::layout::Rect::new(0, 0, self.width, self.height); - let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" ")); - - for row in 0..self.height { - for col in 0..self.width { - let idx = (row as usize) * (self.width as usize) + (col as usize); - let cell_data = &self.cells[idx]; - let cell = buffer.cell_mut((col, row)).expect("cell within bounds"); - cell.set_symbol(&cell_data.symbol); - cell.fg = u32_to_color(cell_data.fg); - cell.bg = u32_to_color(cell_data.bg); - cell.modifier = u16_to_modifier(cell_data.modifier); - cell.skip = cell_data.skip; - } - } - - Some(buffer) - } -} - -/// Terminal ANSI bytes encoded by the server for network-efficient clients. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalFrame { - /// Monotonic per-client frame sequence. - pub seq: u64, - /// Frame width in columns. - pub width: u16, - /// Frame height in rows. - pub height: u16, - /// Whether bytes contain a full redraw rather than an incremental diff. - pub full: bool, - /// Terminal escape bytes ready to write directly to stdout. - pub bytes: Vec<u8>, -} - -/// Notification kind forwarded from server to client. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum NotifyKind { - /// Play a sound (bell/agent-done, etc.). - Sound, - /// Display a toast message through the outer terminal. - Toast, - /// Display a toast message through the host OS notification service. - SystemToast, -} - -/// Messages sent from the server to the client over the client protocol socket. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ServerMessage { - /// Handshake response: server acknowledges (or rejects) the client. - Welcome { - /// Protocol version the server speaks. - version: u32, - /// Render encoding selected by the server for this connection. - encoding: RenderEncoding, - /// If present, the handshake failed and this describes why. - /// The client should exit with a clear error message. - error: Option<String>, - }, - - /// A rendered frame to be displayed by a semantic-frame client. - Frame(FrameData), - - /// Terminal bytes to write directly for a terminal-ANSI client. - Terminal(TerminalFrame), - - /// Client-local Kitty graphics bytes to write directly to the host terminal. - Graphics { - /// Raw Kitty graphics protocol bytes. - bytes: Vec<u8>, - }, - - /// Server is shutting down. Clients should exit gracefully. - ServerShutdown { - /// Optional reason for the shutdown. - reason: Option<String>, - }, - - /// A notification event (sound/toast) to be rendered locally by the client. - Notify { - /// What kind of notification. - kind: NotifyKind, - /// Human-readable message. - message: String, - }, - - /// OSC 52 clipboard data forwarded from a PTY through the server. - Clipboard { - /// Base64-encoded clipboard data. - data: String, - }, - - /// Client-local sound config changed on disk; refresh it without reconnecting. - ReloadSoundConfig, - - /// Whether the client should currently capture host mouse input. - MouseCapture { - /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting. - enabled: bool, - }, -} - -// --------------------------------------------------------------------------- -// Color / Modifier conversion helpers -// --------------------------------------------------------------------------- - -/// Converts a ratatui `Color` to a packed u32 for wire transport. -/// -/// Encoding: -/// - Named colors (Reset, Black, …, White) → `0x00_00_00_XX` where XX is 0..=16 -/// - Indexed palette → `0x01_00_00_XX` where XX is the palette index -/// - RGB → `0x02_RR_GG_BB` with components in the lower 3 bytes -fn color_to_u32(color: ratatui::style::Color) -> u32 { - match color { - ratatui::style::Color::Reset => 0x00_00_00_00, - ratatui::style::Color::Black => 0x00_00_00_01, - ratatui::style::Color::Red => 0x00_00_00_02, - ratatui::style::Color::Green => 0x00_00_00_03, - ratatui::style::Color::Yellow => 0x00_00_00_04, - ratatui::style::Color::Blue => 0x00_00_00_05, - ratatui::style::Color::Magenta => 0x00_00_00_06, - ratatui::style::Color::Cyan => 0x00_00_00_07, - ratatui::style::Color::Gray => 0x00_00_00_08, - ratatui::style::Color::DarkGray => 0x00_00_00_09, - ratatui::style::Color::LightRed => 0x00_00_00_0A, - ratatui::style::Color::LightGreen => 0x00_00_00_0B, - ratatui::style::Color::LightYellow => 0x00_00_00_0C, - ratatui::style::Color::LightBlue => 0x00_00_00_0D, - ratatui::style::Color::LightMagenta => 0x00_00_00_0E, - ratatui::style::Color::LightCyan => 0x00_00_00_0F, - ratatui::style::Color::White => 0x00_00_00_10, - ratatui::style::Color::Indexed(i) => 0x01_00_00_00 | (i as u32), - ratatui::style::Color::Rgb(r, g, b) => { - 0x02_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32) - } - } -} - -/// Converts a packed u32 back to a ratatui `Color`. -#[cfg(test)] -fn u32_to_color(val: u32) -> ratatui::style::Color { - match val >> 24 { - 0x00 => match val & 0xFF { - 0x00 => ratatui::style::Color::Reset, - 0x01 => ratatui::style::Color::Black, - 0x02 => ratatui::style::Color::Red, - 0x03 => ratatui::style::Color::Green, - 0x04 => ratatui::style::Color::Yellow, - 0x05 => ratatui::style::Color::Blue, - 0x06 => ratatui::style::Color::Magenta, - 0x07 => ratatui::style::Color::Cyan, - 0x08 => ratatui::style::Color::Gray, - 0x09 => ratatui::style::Color::DarkGray, - 0x0A => ratatui::style::Color::LightRed, - 0x0B => ratatui::style::Color::LightGreen, - 0x0C => ratatui::style::Color::LightYellow, - 0x0D => ratatui::style::Color::LightBlue, - 0x0E => ratatui::style::Color::LightMagenta, - 0x0F => ratatui::style::Color::LightCyan, - 0x10 => ratatui::style::Color::White, - _ => ratatui::style::Color::Reset, // unknown named → Reset - }, - 0x01 => ratatui::style::Color::Indexed((val & 0xFF) as u8), - 0x02 => { - let r = ((val >> 16) & 0xFF) as u8; - let g = ((val >> 8) & 0xFF) as u8; - let b = (val & 0xFF) as u8; - ratatui::style::Color::Rgb(r, g, b) - } - _ => ratatui::style::Color::Reset, // unknown tag → Reset - } -} - -/// Converts a ratatui `Modifier` bitmask to a u16 for wire transport. -fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 { - modifier.bits() -} - -/// Converts a u16 back to a ratatui `Modifier`. -#[cfg(test)] -fn u16_to_modifier(val: u16) -> ratatui::style::Modifier { - ratatui::style::Modifier::from_bits_truncate(val) -} - -// --------------------------------------------------------------------------- -// Framing: length-prefixed binary messages -// --------------------------------------------------------------------------- - -/// Errors that can occur during framing operations. -#[derive(Debug)] -pub enum FramingError { - /// The decoded payload length exceeds the configured maximum frame size. - Oversized { claimed: usize, max: usize }, - /// An I/O error occurred while reading or writing. - Io(io::Error), - /// Bincode serialization or deserialization failed. - Bincode(String), - /// The connection was closed before a complete frame could be read. - UnexpectedEof, -} - -impl std::fmt::Display for FramingError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FramingError::Oversized { claimed, max } => { - write!(f, "frame size {claimed} exceeds maximum {max}") - } - FramingError::Io(e) => write!(f, "I/O error: {e}"), - FramingError::Bincode(e) => write!(f, "bincode error: {e}"), - FramingError::UnexpectedEof => write!(f, "unexpected end of stream"), - } - } -} - -impl std::error::Error for FramingError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - FramingError::Io(e) => Some(e), - _ => None, - } - } -} - -impl From<io::Error> for FramingError { - fn from(e: io::Error) -> Self { - FramingError::Io(e) - } -} - -/// Serializes a message and writes it as a length-prefixed frame: -/// `[u32LE length][bincode payload]`. -/// -/// This is a blocking/synchronous write suitable for use with `std::os::unix::net::UnixStream` -/// in blocking mode, or with any `Write` implementor. -/// -/// # Errors -/// -/// Returns `FramingError::Bincode` if the payload length exceeds `u32::MAX` -/// (would be truncated by the length prefix cast). -pub fn write_message<W: Write, M: Serialize>(writer: &mut W, msg: &M) -> Result<(), FramingError> { - let payload = bincode::serde::encode_to_vec(msg, bincode::config::standard()) - .map_err(|e| FramingError::Bincode(e.to_string()))?; - - let len = payload.len(); - if len > u32::MAX as usize { - return Err(FramingError::Bincode(format!( - "payload length {len} exceeds u32::MAX ({}), would be truncated by length prefix", - u32::MAX - ))); - } - - writer.write_all(&(len as u32).to_le_bytes())?; - writer.write_all(&payload)?; - writer.flush()?; - Ok(()) -} - -/// Reads and deserializes a length-prefixed frame from a reader. -/// -/// Reassembles partial reads correctly. Rejects frames whose declared -/// length exceeds `max_frame_size` without panicking or allocating -/// oversized buffers. -pub fn read_message<R: Read, M: for<'de> Deserialize<'de>>( - reader: &mut R, - max_frame_size: usize, -) -> Result<M, FramingError> { - // Read the 4-byte length prefix, reassembling partial reads. - let mut len_buf = [0u8; LENGTH_PREFIX_BYTES]; - read_exact_or_eof(reader, &mut len_buf)?; - let claimed_len = u32::from_le_bytes(len_buf) as usize; - - if claimed_len > max_frame_size { - return Err(FramingError::Oversized { - claimed: claimed_len, - max: max_frame_size, - }); - } - - // Read the payload, reassembling partial reads. - let mut payload = vec![0u8; claimed_len]; - read_exact_or_eof(reader, &mut payload)?; - - let (msg, consumed) = bincode::serde::decode_from_slice(&payload, bincode::config::standard()) - .map_err(|e| FramingError::Bincode(e.to_string()))?; - - // Enforce that the decoder consumed the full payload. - // Trailing bytes after the decoded message indicate a protocol violation - // (e.g., a corrupted length prefix or concatenated payloads). - if consumed != claimed_len { - return Err(FramingError::Bincode(format!( - "decoded {} bytes but payload length was {claimed_len}; trailing bytes are not allowed", - consumed - ))); - } - - Ok(msg) -} - -/// Like `Read::read_exact`, but returns `FramingError::UnexpectedEof` -/// when the reader hits end-of-stream before filling the buffer, instead -/// of the generic `io::ErrorKind::UnexpectedEof`. -fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), FramingError> { - reader.read_exact(buf).map_err(|e| { - if e.kind() == io::ErrorKind::UnexpectedEof { - FramingError::UnexpectedEof - } else { - FramingError::Io(e) - } - }) -} - -// --------------------------------------------------------------------------- -// Version negotiation -// --------------------------------------------------------------------------- - -/// Result of checking a client's protocol version against the server's. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VersionCheck { - /// Versions are compatible. The server should reply with a successful Welcome. - Compatible, - /// Versions are incompatible. The server should reply with a Welcome error and close. - Incompatible(String), -} - -/// Checks whether a client's protocol version is compatible with this server. -/// -/// Current rules: -/// - Version 0 (pre-persistence client) is always rejected. -/// - Matching major versions are accepted. -/// - A client with a newer version than the server is rejected. -/// - A client with an older version than the server is rejected -/// (backward compatibility is not yet supported). -pub fn check_client_version(client_version: u32) -> VersionCheck { - if client_version == 0 { - return VersionCheck::Incompatible( - "pre-persistence client (version 0) is not supported".to_owned(), - ); - } - - if client_version == PROTOCOL_VERSION { - VersionCheck::Compatible - } else if client_version < PROTOCOL_VERSION { - VersionCheck::Incompatible(format!( - "client version {client_version} is older than server version {PROTOCOL_VERSION}; please upgrade your herdr client" - )) - } else { - VersionCheck::Incompatible(format!( - "client version {client_version} is newer than server version {PROTOCOL_VERSION}; please upgrade the herdr server" - )) - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::style::{Color, Modifier}; - - // ---- Round-trip: ClientMessage ---- - - #[test] - fn client_hello_roundtrip() { - let msg = ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols: 80, - rows: 24, - cell_width_px: 8, - cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn client_input_roundtrip() { - let msg = ClientMessage::Input { - data: vec![0x1b, 0x5b, 0x41], // ESC [ A (up arrow) - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn client_clipboard_image_roundtrip() { - let msg = ClientMessage::ClipboardImage { - extension: "png".to_owned(), - data: vec![0x89, b'P', b'N', b'G'], - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn client_input_large_multilingual_payload_roundtrip() { - let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024); - assert!(text.len() > 64 * 1024); - assert!(text.len() < MAX_FRAME_SIZE); - let msg = ClientMessage::Input { - data: text.as_bytes().to_vec(), - }; - - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, consumed): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - - assert_eq!(consumed, encoded.len()); - assert_eq!(decoded, msg); - } - - #[test] - fn client_resize_roundtrip() { - let msg = ClientMessage::Resize { - cols: 80, - rows: 24, - cell_width_px: 8, - cell_height_px: 16, - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn client_detach_roundtrip() { - let msg = ClientMessage::Detach; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn client_attach_terminal_roundtrip() { - let msg = ClientMessage::AttachTerminal { - terminal_id: "term_123".to_owned(), - takeover: true, - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - // ---- Round-trip: ServerMessage ---- - - #[test] - fn server_welcome_roundtrip() { - let msg = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: None, - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn server_welcome_with_error_roundtrip() { - let msg = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some("incompatible version".to_owned()), - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn server_frame_roundtrip_nontrivial() { - // Build a 3×2 frame with varied styles (≥2×2). - let frame = FrameData { - cells: vec![ - CellData { - symbol: "H".into(), - fg: color_to_u32(Color::Red), - bg: color_to_u32(Color::Black), - modifier: Modifier::BOLD.bits(), - skip: false, - hyperlink: None, - }, - CellData { - symbol: "i".into(), - fg: color_to_u32(Color::Green), - bg: color_to_u32(Color::Reset), - modifier: Modifier::ITALIC.bits(), - skip: false, - hyperlink: None, - }, - CellData { - symbol: "!".into(), - fg: color_to_u32(Color::Rgb(255, 128, 0)), - bg: color_to_u32(Color::Indexed(220)), - modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(), - skip: false, - hyperlink: Some(0), - }, - CellData { - symbol: " ".into(), - fg: color_to_u32(Color::Reset), - bg: color_to_u32(Color::Reset), - modifier: Modifier::empty().bits(), - skip: true, - hyperlink: None, - }, - CellData { - symbol: "→".into(), // multi-byte grapheme - fg: color_to_u32(Color::Cyan), - bg: color_to_u32(Color::Blue), - modifier: Modifier::REVERSED.bits(), - skip: false, - hyperlink: None, - }, - CellData { - symbol: "🦀".into(), // emoji, wide grapheme cluster - fg: color_to_u32(Color::Yellow), - bg: color_to_u32(Color::Magenta), - modifier: Modifier::empty().bits(), - skip: false, - hyperlink: None, - }, - ], - width: 3, - height: 2, - cursor: Some(CursorState { - x: 0, - y: 0, - visible: true, - shape: 6, - }), - hyperlinks: vec!["https://example.com".to_owned()], - graphics: Vec::new(), - }; - let msg = ServerMessage::Frame(frame.clone()); - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - match decoded { - ServerMessage::Frame(frame) => { - assert_eq!(frame.cells[2].hyperlink, Some(0)); - assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]); - } - other => panic!("expected frame, got {other:?}"), - } - } - - #[test] - fn server_shutdown_roundtrip() { - let msg = ServerMessage::ServerShutdown { - reason: Some("updating".to_owned()), - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn server_notify_roundtrip() { - for kind in [ - NotifyKind::Sound, - NotifyKind::Toast, - NotifyKind::SystemToast, - ] { - let msg = ServerMessage::Notify { - kind, - message: "agent done".to_owned(), - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - } - - #[test] - fn server_clipboard_roundtrip() { - let msg = ServerMessage::Clipboard { - data: "dGVzdA==".to_owned(), // base64 "test" - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn server_graphics_roundtrip() { - let msg = ServerMessage::Graphics { - bytes: b"\x1b_Ga=d,d=A,q=2;\x1b\\".to_vec(), - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn server_terminal_frame_roundtrip() { - let msg = ServerMessage::Terminal(TerminalFrame { - seq: 7, - width: 120, - height: 40, - full: false, - bytes: b"\x1b[1;1Hhello".to_vec(), - }); - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn server_reload_sound_config_roundtrip() { - let msg = ServerMessage::ReloadSoundConfig; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn server_mouse_capture_roundtrip() { - let msg = ServerMessage::MouseCapture { enabled: true }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } - - // ---- Framing ---- - - #[test] - fn framing_small_message_roundtrip() { - let msg = ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols: 80, - rows: 24, - cell_width_px: 8, - cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - }; - let mut buf = Vec::new(); - write_message(&mut buf, &msg).unwrap(); - let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn framing_large_payload_roundtrip() { - // Create a Frame message that is ≥128 KB. - // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding. - // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB. - let width: u16 = 200; - let height: u16 = 50; - let cells: Vec<CellData> = (0..(width as usize) * (height as usize)) - .map(|i| CellData { - symbol: if i % 256 < 32 { - " ".to_owned() - } else { - format!("{:03}", i % 1000) - }, - fg: color_to_u32(Color::Rgb((i % 256) as u8, ((i / 256) % 256) as u8, 128)), - bg: color_to_u32(Color::Indexed((i % 256) as u8)), - modifier: ((i % 16) as u16), - skip: i % 100 == 0, - hyperlink: None, - }) - .collect(); - - let frame = FrameData { - cells, - width, - height, - cursor: Some(CursorState { - x: 10, - y: 5, - visible: true, - shape: 0, - }), - hyperlinks: Vec::new(), - graphics: Vec::new(), - }; - let msg = ServerMessage::Frame(frame); - - let mut buf = Vec::new(); - write_message(&mut buf, &msg).unwrap(); - // Verify the payload is at least 128 KB - assert!( - buf.len() >= 128 * 1024, - "framed payload should be >= 128 KB, got {} bytes", - buf.len() - ); - - let decoded: ServerMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn framing_multiple_messages_sequential() { - // Write 100+ messages of varying types and read them back. - let mut buf = Vec::new(); - let mut expected = Vec::new(); - - for i in 0..150u32 { - let msg = match i % 5 { - 0 => ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols: (80 + (i % 40) as u16), - rows: (24 + (i % 20) as u16), - cell_width_px: 8, - cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - }, - 1 => ClientMessage::Input { - data: vec![(i % 256) as u8; (i as usize % 50) + 1], - }, - 2 => ClientMessage::ClipboardImage { - extension: "png".to_owned(), - data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8], - }, - 3 => ClientMessage::Resize { - cols: (100 + (i % 30) as u16), - rows: (30 + (i % 10) as u16), - cell_width_px: 8, - cell_height_px: 16, - }, - 4 => ClientMessage::Detach, - _ => unreachable!(), - }; - write_message(&mut buf, &msg).unwrap(); - expected.push(msg); - } - - let mut cursor = buf.as_slice(); - for expected_msg in &expected { - let decoded: ClientMessage = read_message(&mut cursor, MAX_FRAME_SIZE).unwrap(); - assert_eq!(*expected_msg, decoded); - } - } - - #[test] - fn framing_oversized_rejected_without_panic() { - // Craft a frame with a huge length prefix (4 GB claim). - let mut buf: Vec<u8> = (u32::MAX).to_le_bytes().to_vec(); - // Add a few garbage bytes after the length prefix. - buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); - - let result: Result<ClientMessage, FramingError> = - read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); - match result { - Err(FramingError::Oversized { claimed, max }) => { - assert_eq!(claimed, u32::MAX as usize); - assert_eq!(max, MAX_FRAME_SIZE); - } - other => panic!("expected Oversized error, got: {other:?}"), - } - } - - #[test] - fn framing_malformed_payload_rejected_without_panic() { - // Valid length prefix pointing to garbage data. - let payload = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; - let mut buf = (payload.len() as u32).to_le_bytes().to_vec(); - buf.extend_from_slice(&payload); - - let result: Result<ClientMessage, FramingError> = - read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); - assert!(result.is_err(), "malformed payload should be rejected"); - match result { - Err(FramingError::Bincode(_)) => {} // expected - other => panic!("expected Bincode error, got: {other:?}"), - } - } - - #[test] - fn framing_truncated_stream_returns_unexpected_eof() { - // Write a length prefix claiming 100 bytes, but only provide 4. - let mut buf: Vec<u8> = 100u32.to_le_bytes().to_vec(); - buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); - - let result: Result<ClientMessage, FramingError> = - read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); - match result { - Err(FramingError::UnexpectedEof) => {} - other => panic!("expected UnexpectedEof, got: {other:?}"), - } - } - - #[test] - fn framing_zero_length_message() { - // A 1-byte message (smallest possible valid bincode payload). - // Actually, let's test with the smallest real message: Detach. - let msg = ClientMessage::Detach; - let mut buf = Vec::new(); - write_message(&mut buf, &msg).unwrap(); - - // Verify the length prefix is correct - let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; - assert_eq!( - len, - buf.len() - 4, - "length prefix should match payload size" - ); - - let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn framing_partial_read_reassembly() { - // Simulate partial reads by using a reader that yields small chunks. - let msg = ClientMessage::Input { - data: vec![42; 500], // 500-byte input payload - }; - let mut full_buf = Vec::new(); - write_message(&mut full_buf, &msg).unwrap(); - - // Wrap in a chunked reader that only yields 7 bytes at a time. - let mut chunked = ChunkedReader::new(full_buf, 7); - let decoded: ClientMessage = read_message(&mut chunked, MAX_FRAME_SIZE).unwrap(); - assert_eq!(msg, decoded); - } - - // ---- Version negotiation ---- - - #[test] - fn version_compatible() { - assert_eq!( - check_client_version(PROTOCOL_VERSION), - VersionCheck::Compatible - ); - } - - #[test] - fn version_older_client_rejected() { - let result = check_client_version(PROTOCOL_VERSION - 1); - assert!(matches!(result, VersionCheck::Incompatible(_))); - if let VersionCheck::Incompatible(msg) = result { - assert!(msg.contains("older"), "error should mention older version"); - } - } - - #[test] - fn version_newer_client_rejected() { - let result = check_client_version(PROTOCOL_VERSION + 1); - assert!(matches!(result, VersionCheck::Incompatible(_))); - if let VersionCheck::Incompatible(msg) = result { - assert!(msg.contains("newer"), "error should mention newer version"); - } - } - - // ---- Pre-persistence client rejection ---- - - #[test] - fn prepersistence_version_zero_rejected() { - let result = check_client_version(0); - match result { - VersionCheck::Incompatible(msg) => { - assert!( - msg.contains("pre-persistence"), - "error should mention pre-persistence: {msg}" - ); - } - _ => panic!("version 0 should be rejected as incompatible"), - } - } - - #[test] - fn prepersistence_version_zero_welcome_has_error() { - // Simulating what the server would send to a v0 client. - let check = check_client_version(0); - let response = match check { - VersionCheck::Compatible => ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: None, - }, - VersionCheck::Incompatible(reason) => ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(reason), - }, - }; - - match response { - ServerMessage::Welcome { error: Some(_), .. } => {} - other => panic!("expected Welcome with error, got: {other:?}"), - } - } - - // ---- Malformed/oversized input ---- - - #[test] - fn oversized_frame_does_not_panic() { - // Claim 4GB payload — should return Oversized error, not panic. - let mut buf: Vec<u8> = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim - buf.extend_from_slice(&[0; 8]); - - let result: Result<ClientMessage, FramingError> = - read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); - assert!(result.is_err()); - // Did not panic — test passing is proof. - } - - #[test] - fn malformed_frame_does_not_panic() { - // Random garbage bytes after a valid-ish length prefix. - let garbage: Vec<u8> = (0..200).map(|i| (i ^ 0xAA) as u8).collect(); - let mut buf = (garbage.len() as u32).to_le_bytes().to_vec(); - buf.extend_from_slice(&garbage); - - let result: Result<ClientMessage, FramingError> = - read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); - assert!(result.is_err()); - // Did not panic. - } - - #[test] - fn oversized_input_rejected_custom_max() { - // Verify a custom (small) max_frame_size is enforced. - let msg = ClientMessage::Input { - data: vec![0x41; 1000], - }; - let mut buf = Vec::new(); - write_message(&mut buf, &msg).unwrap(); - - let result: Result<ClientMessage, FramingError> = read_message(&mut buf.as_slice(), 64); - // The actual bincode payload for 1000 bytes of input will be > 64 bytes. - assert!( - matches!(result, Err(FramingError::Oversized { .. })), - "expected Oversized with small max_frame_size" - ); - } - - // ---- FrameData ↔ ratatui Buffer conversion ---- - - #[test] - fn frame_data_roundtrip_through_ratatui_buffer() { - let area = ratatui::layout::Rect::new(0, 0, 5, 3); - let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" ")); - - // Write some styled content. - buffer.cell_mut((0, 0)).unwrap().set_symbol("H"); - buffer.cell_mut((0, 0)).unwrap().fg = Color::Red; - buffer.cell_mut((0, 0)).unwrap().modifier = Modifier::BOLD; - - buffer.cell_mut((1, 0)).unwrap().set_symbol("i"); - buffer.cell_mut((1, 0)).unwrap().fg = Color::Green; - buffer.cell_mut((1, 0)).unwrap().modifier = Modifier::ITALIC; - - buffer.cell_mut((2, 0)).unwrap().set_symbol("!"); - buffer.cell_mut((2, 0)).unwrap().fg = Color::Rgb(255, 128, 0); - buffer.cell_mut((2, 0)).unwrap().bg = Color::Indexed(220); - - let cursor = CursorState { - x: 1, - y: 0, - visible: true, - shape: 0, - }; - let frame = FrameData::from_ratatui_buffer(&buffer, Some(cursor.clone())); - - // Verify frame dimensions. - assert_eq!(frame.width, 5); - assert_eq!(frame.height, 3); - assert_eq!(frame.cells.len(), 15); - assert_eq!(frame.cursor, Some(cursor)); - - // Verify specific cells survived the conversion. - assert_eq!(frame.cells[0].symbol, "H"); - assert_eq!(frame.cells[0].fg, color_to_u32(Color::Red)); - assert_eq!(frame.cells[0].modifier, Modifier::BOLD.bits()); - - assert_eq!(frame.cells[1].symbol, "i"); - assert_eq!(frame.cells[1].fg, color_to_u32(Color::Green)); - assert_eq!(frame.cells[1].modifier, Modifier::ITALIC.bits()); - - assert_eq!(frame.cells[2].symbol, "!"); - assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0))); - assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220))); - - let with_links = FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - None, - &[((1, 0), "i".to_owned(), "https://example.com".to_owned())], - ); - assert_eq!(with_links.cells[1].hyperlink, Some(0)); - assert_eq!( - with_links.hyperlinks, - vec!["https://example.com".to_owned()] - ); - - // Convert back to ratatui buffer and compare. - let restored = frame.to_ratatui_buffer().expect("should reconstruct"); - assert_eq!(restored.area, area); - assert_eq!(restored.cell((0, 0)).unwrap().symbol(), "H"); - assert_eq!(restored.cell((0, 0)).unwrap().fg, Color::Red); - assert_eq!(restored.cell((0, 0)).unwrap().modifier, Modifier::BOLD); - assert_eq!(restored.cell((1, 0)).unwrap().symbol(), "i"); - assert_eq!(restored.cell((2, 0)).unwrap().symbol(), "!"); - assert_eq!(restored.cell((2, 0)).unwrap().fg, Color::Rgb(255, 128, 0)); - } - - #[test] - fn frame_data_rejects_mismatched_cell_count() { - let frame = FrameData { - cells: vec![ - CellData { - symbol: "X".into(), - fg: 0, - bg: 0, - modifier: 0, - skip: false, - hyperlink: None, - }; - 5 - ], // 5 cells but 3×2 = 6 expected - width: 3, - height: 2, - cursor: None, - hyperlinks: Vec::new(), - graphics: Vec::new(), - }; - assert!(frame.to_ratatui_buffer().is_none()); - } - - // ---- Color conversion coverage ---- - - #[test] - fn color_roundtrip_all_named_colors() { - let named = [ - Color::Reset, - Color::Black, - Color::Red, - Color::Green, - Color::Yellow, - Color::Blue, - Color::Magenta, - Color::Cyan, - Color::Gray, - Color::DarkGray, - Color::LightRed, - Color::LightGreen, - Color::LightYellow, - Color::LightBlue, - Color::LightMagenta, - Color::LightCyan, - Color::White, - ]; - for c in named { - assert_eq!( - u32_to_color(color_to_u32(c)), - c, - "roundtrip failed for {c:?}" - ); - } - } - - #[test] - fn color_roundtrip_indexed() { - for i in 0..=255u8 { - let c = Color::Indexed(i); - assert_eq!( - u32_to_color(color_to_u32(c)), - c, - "roundtrip failed for Indexed({i})" - ); - } - } - - #[test] - fn color_roundtrip_rgb() { - let c = Color::Rgb(0xAB, 0xCD, 0xEF); - assert_eq!(u32_to_color(color_to_u32(c)), c); - - let c = Color::Rgb(0, 0, 0); - assert_eq!(u32_to_color(color_to_u32(c)), c); - - let c = Color::Rgb(255, 255, 255); - assert_eq!(u32_to_color(color_to_u32(c)), c); - } - - // ---- Modifier conversion ---- - - #[test] - fn modifier_roundtrip() { - let all_mods = [ - Modifier::BOLD, - Modifier::ITALIC, - Modifier::REVERSED, - Modifier::UNDERLINED, - Modifier::DIM, - Modifier::SLOW_BLINK, - Modifier::CROSSED_OUT, - Modifier::BOLD | Modifier::ITALIC, - Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED, - Modifier::empty(), - ]; - for m in all_mods { - assert_eq!( - u16_to_modifier(modifier_to_u16(m)), - m, - "roundtrip failed for {m:?}" - ); - } - } - - #[test] - fn read_message_rejects_trailing_bytes() { - // Encode a valid message, then append an extra byte after it. - let msg = ClientMessage::Detach; - let mut payload = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let original_len = payload.len(); - payload.push(0xDE); // trailing garbage - - // Frame it with the inflated length (original + 1). - let mut buf = (payload.len() as u32).to_le_bytes().to_vec(); - buf.extend_from_slice(&payload); - - let result: Result<ClientMessage, FramingError> = - read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); - match result { - Err(FramingError::Bincode(msg)) => { - assert!( - msg.contains("trailing bytes"), - "error should mention trailing bytes: {msg}" - ); - assert!( - msg.contains(&format!("decoded {original_len}")), - "error should mention decoded byte count: {msg}" - ); - } - other => panic!("expected Bincode error about trailing bytes, got: {other:?}"), - } - } - - #[test] - fn read_message_accepts_exact_payload() { - // A normally-framed message should decode without error. - let msg = ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols: 80, - rows: 24, - cell_width_px: 8, - cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - }; - let mut buf = Vec::new(); - write_message(&mut buf, &msg).unwrap(); - let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn write_message_rejects_oversized_payload() { - // We can't easily create a message that exceeds u32::MAX in a test, - // but we can verify the check exists by testing that normal messages - // have lengths well within the limit and the function doesn't fail. - let msg = ClientMessage::Detach; - let mut buf = Vec::new(); - assert!(write_message(&mut buf, &msg).is_ok()); - } - - // ---- Unix socketpair integration test ---- - - #[test] - fn framing_over_unix_socketpair() { - use std::os::unix::net::UnixStream; - - let (mut a, mut b) = UnixStream::pair().expect("socketpair"); - - let messages = vec![ - ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols: 200, - rows: 60, - cell_width_px: 8, - cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - }, - ClientMessage::Input { - data: b"hello world".to_vec(), - }, - ClientMessage::ClipboardImage { - extension: "png".to_owned(), - data: vec![0x89, b'P', b'N', b'G'], - }, - ClientMessage::Resize { - cols: 100, - rows: 30, - cell_width_px: 8, - cell_height_px: 16, - }, - ClientMessage::Detach, - ]; - - // Set non-blocking so we can write and read in the same test. - a.set_nonblocking(false).unwrap(); - b.set_nonblocking(false).unwrap(); - - for msg in &messages { - write_message(&mut a, msg).unwrap(); - } - - for expected in &messages { - let decoded: ClientMessage = read_message(&mut b, MAX_FRAME_SIZE).unwrap(); - assert_eq!(*expected, decoded); - } - } - - // ---- Helper: chunked reader for simulating partial reads ---- - - /// A `Read` wrapper that yields at most `chunk_size` bytes per `read()` call, - /// simulating partial reads on a real socket. - struct ChunkedReader { - data: Vec<u8>, - pos: usize, - chunk_size: usize, - } - - impl ChunkedReader { - fn new(data: Vec<u8>, chunk_size: usize) -> Self { - Self { - data, - pos: 0, - chunk_size, - } - } - } - - impl Read for ChunkedReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { - if self.pos >= self.data.len() { - return Ok(0); - } - let remaining = self.data.len() - self.pos; - let to_read = buf.len().min(remaining).min(self.chunk_size); - buf[..to_read].copy_from_slice(&self.data[self.pos..self.pos + to_read]); - self.pos += to_read; - Ok(to_read) - } - } -} diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index b6e50444..3548f98a 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -5,10 +5,9 @@ use ratatui::layout::{Position, Rect, Size}; use crate::app::state::AppState; use crate::app::Mode; -use crate::client::blit::{BlitEncoder, EncodedBlit}; -use crate::server::protocol::{ - CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame, -}; +use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; +use crate::protocol::{CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame}; +use crate::terminal::TerminalRuntimeRegistry; /// Per-client render baseline for the negotiated render encoding. pub(crate) enum ClientRenderState { @@ -217,29 +216,33 @@ impl Backend for CursorTrackingBackend { /// This produces the same output as the monolithic binary's terminal draw, /// but writes to a `Buffer` instead of stdout. Cursor visibility is captured /// from explicit frame cursor intent rather than incidental backend state. +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn render_virtual( app_state: &mut AppState, area: Rect, resize_panes: bool, ) -> (ratatui::buffer::Buffer, Option<CursorState>) { - render_virtual_with_cell_size( + let terminal_runtimes = TerminalRuntimeRegistry::new(); + render_virtual_with_runtime_registry( app_state, + &terminal_runtimes, area, resize_panes, crate::kitty_graphics::HostCellSize::default(), ) } -pub(crate) fn render_virtual_with_cell_size( +pub(crate) fn render_virtual_with_runtime_registry( app_state: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> (ratatui::buffer::Buffer, Option<CursorState>) { if resize_panes { - crate::ui::compute_view_with_cell_size(app_state, area, cell_size); + crate::ui::compute_view_with_cell_size(app_state, terminal_runtimes, area, cell_size); } else { - crate::ui::compute_view_without_resizing_panes(app_state, area); + crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); } let backend = CursorTrackingBackend::new(area.width, area.height); @@ -247,13 +250,13 @@ pub(crate) fn render_virtual_with_cell_size( terminal .draw(|frame| { - crate::ui::render(app_state, frame); + crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame); }) .expect("render to TestBackend should never fail"); let buffer = terminal.backend().buffer().clone(); - let cursor = - focused_terminal_cursor(app_state).or_else(|| terminal.backend().rendered_cursor()); + let cursor = focused_terminal_cursor(app_state, terminal_runtimes) + .or_else(|| terminal.backend().rendered_cursor()); (buffer, cursor) } @@ -286,7 +289,10 @@ pub(crate) fn render_terminal_virtual( (buffer, cursor) } -pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), String, String)> { +pub(crate) fn visible_hyperlinks( + app_state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, +) -> Vec<((u16, u16), String, String)> { let Some(ws_idx) = app_state.active else { return Vec::new(); }; @@ -302,7 +308,7 @@ pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), Strin for info in &app_state.view.pane_infos { if let Some(runtime) = tab .terminal_id(info.id) - .and_then(|terminal_id| app_state.terminal_runtimes.get(terminal_id)) + .and_then(|terminal_id| terminal_runtimes.get(terminal_id)) { links.extend(runtime.visible_hyperlinks(info.inner_rect)); } @@ -310,7 +316,10 @@ pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), Strin links } -fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> { +fn focused_terminal_cursor( + app_state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, +) -> Option<CursorState> { if app_state.mode != Mode::Terminal { return None; } @@ -321,7 +330,7 @@ fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> { .pane_infos .iter() .find(|info| info.is_focused)?; - let rt = app_state.runtime_for_pane_in_workspace(ws_idx, info.id)?; + let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let scrolled_back = crate::ui::pane_is_scrolled_back(rt); // Determine whether the IME-anchor reveal applies to this focused pane. diff --git a/src/server/socket_paths.rs b/src/server/socket_paths.rs new file mode 100644 index 00000000..1630ae7c --- /dev/null +++ b/src/server/socket_paths.rs @@ -0,0 +1,180 @@ +use std::io; +use std::path::{Path, PathBuf}; + +/// Legacy environment variable for overriding the client socket path. +/// +/// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`. +/// This variable is kept as a fallback for callers that explicitly need a +/// client-only override when `HERDR_SOCKET_PATH` is not set. +pub const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH"; + +/// Socket permission mode (owner read/write only). +const SOCKET_PERMISSION_MODE: u32 = 0o600; + +/// Returns the path for the client protocol socket. +/// +/// Contract-aligned override behavior: +/// 1. If CLI `--session <name>` is active, use that session's client socket. +/// 2. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by +/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`). +/// This keeps JSON API and client socket overrides consistent. +/// 3. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback). +/// 4. Otherwise, use the active session data directory. +pub fn client_socket_path() -> PathBuf { + if crate::session::explicit_session_requested() { + return crate::session::client_socket_path_for(crate::session::active_name().as_deref()); + } + client_socket_path_from_overrides( + std::env::var(crate::api::SOCKET_PATH_ENV_VAR) + .ok() + .as_deref(), + std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(), + ) +} + +pub(crate) fn client_socket_path_from_overrides( + api_socket_override: Option<&str>, + client_socket_override: Option<&str>, +) -> PathBuf { + if let Some(api_socket_override) = api_socket_override { + return derive_client_socket_from_api_socket(Path::new(api_socket_override)); + } + + if let Some(client_socket_override) = client_socket_override { + return PathBuf::from(client_socket_override); + } + + crate::session::client_socket_path_for(crate::session::active_name().as_deref()) +} + +pub(crate) fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { + let stem = api_socket_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("herdr"); + let parent = api_socket_path.parent().unwrap_or_else(|| Path::new("")); + + if api_socket_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "sock") + { + return parent.join(format!("{stem}-client.sock")); + } + + parent.join(format!("{stem}-client.sock")) +} + +/// Prepares a socket path for binding: creates parent directories, +/// removes stale socket files where no server is listening, and rejects live +/// sockets that are already in use. +pub(crate) fn prepare_socket_path(path: &Path) -> io::Result<()> { + crate::ipc::prepare_socket_path(path, |path| { + format!( + "herdr server is already running (socket busy at {})", + path.display() + ) + }) +} + +/// Restricts socket file permissions to owner-only (0o600). +pub(crate) fn restrict_socket_permissions(path: &Path) -> io::Result<()> { + crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::net::UnixListener; + use std::time::Duration; + + #[test] + fn client_socket_path_derived_from_api_socket_override() { + let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None); + assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); + } + + #[test] + fn client_socket_path_api_override_takes_precedence_over_legacy_client_override() { + let path = client_socket_path_from_overrides( + Some("/tmp/test-herdr.sock"), + Some("/tmp/legacy-client.sock"), + ); + assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); + } + + #[test] + fn client_socket_path_respects_legacy_client_override_without_api_override() { + let path = client_socket_path_from_overrides(None, Some("/tmp/test-herdr-client.sock")); + assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); + } + + #[test] + fn client_socket_path_defaults_to_config_dir() { + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + let path = client_socket_path_from_overrides(None, None); + assert_eq!(path, crate::config::config_dir().join("herdr-client.sock")); + } + + #[test] + fn derive_client_socket_from_api_socket_without_sock_extension() { + let derived = derive_client_socket_from_api_socket(Path::new("/tmp/custom-api")); + assert_eq!(derived, PathBuf::from("/tmp/custom-api-client.sock")); + } + + #[test] + fn prepare_socket_path_removes_stale_socket() { + let dir = PathBuf::from(format!( + "/tmp/hs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::create_dir_all(&dir); + let socket_path = dir.join("stale.sock"); + + { + let _listener = UnixListener::bind(&socket_path).expect("bind stale socket"); + } + + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while std::time::Instant::now() < deadline { + if std::os::unix::net::UnixStream::connect(&socket_path).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + let result = prepare_socket_path(&socket_path); + assert!(result.is_ok(), "should remove stale socket: {result:?}"); + assert!(!socket_path.exists()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn prepare_socket_path_rejects_live_socket() { + let dir = PathBuf::from(format!( + "/tmp/hl-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::create_dir_all(&dir); + let socket_path = dir.join("live.sock"); + + let _listener = UnixListener::bind(&socket_path).expect("bind"); + + let result = prepare_socket_path(&socket_path); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AddrInUse); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/server/terminal_attach.rs b/src/server/terminal_attach.rs new file mode 100644 index 00000000..01fc899f --- /dev/null +++ b/src/server/terminal_attach.rs @@ -0,0 +1,14 @@ +pub(crate) fn paste_payload_for_runtime( + runtime: &crate::terminal::TerminalRuntime, + text: &str, +) -> String { + if runtime + .input_state() + .map(|state| state.bracketed_paste) + .unwrap_or(false) + { + format!("\x1b[200~{text}\x1b[201~") + } else { + text.to_owned() + } +} |
