aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/autodetect.rs6
-rw-r--r--src/server/client_accept.rs50
-rw-r--r--src/server/client_transport.rs2
-rw-r--r--src/server/clients.rs208
-rw-r--r--src/server/headless.rs573
-rw-r--r--src/server/keybindings.rs17
-rw-r--r--src/server/mod.rs7
-rw-r--r--src/server/notifications.rs60
-rw-r--r--src/server/protocol.rs1513
-rw-r--r--src/server/render_stream.rs39
-rw-r--r--src/server/socket_paths.rs180
-rw-r--r--src/server/terminal_attach.rs14
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()
+ }
+}