//! Headless server mode — runs the herdr event loop without a real terminal. //! //! The server: //! - Does not enter raw mode or read stdin //! - Creates and listens on both `herdr.sock` (existing JSON API) and //! `herdr-client.sock` (new binary protocol) //! - Initializes AppState and all PTYs from session restore or fresh state //! - Runs the main event loop (drain events, drain API requests, scheduled tasks) //! - Renders to a virtual ratatui Buffer in memory //! - Accepts client connections on the client socket //! - Streams frames to connected clients after each render //! - Routes client input events through the existing input pipeline //! - Continues running after client disconnect //! - Handles stale socket cleanup, explicit server stop, minimum terminal size, //! and pane spawn failure during restore use std::collections::HashMap; use std::fs; use std::io; use std::os::unix::net::UnixListener; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use ratatui::layout::Rect; use tokio::sync::mpsc; use tracing::{debug, error, 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::server::render_stream::ClientRenderState; // --------------------------------------------------------------------------- // Loop event enum for the headless server event loop // --------------------------------------------------------------------------- /// Events that the headless server event loop can process. enum LoopEvent { Timer, Internal(AppEvent), Api(api::ApiRequestMessage), ServerEvent(ServerEvent), RenderRequested, } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Default shared runtime size (columns, rows) when no clients are attached. 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); /// How often the idle headless loop wakes to poll the std UnixListener for new /// client connections. /// /// The listener is non-blocking and not integrated into `tokio::select!`, so /// a low-frequency wake is required to notice new thin-client attaches while /// otherwise idle. Keep this much slower than the old resize-poll cadence to /// 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 { 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 { 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 ` 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")) } // --------------------------------------------------------------------------- // 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, /// 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, /// 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 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, /// Temporary files staged from this client's local clipboard image pastes. staged_clipboard_files: Vec, /// Channels for sending framed ServerMessage data to the client writer thread. writer: Option, } 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, last_activity: u64, render_encoding: RenderEncoding, writer: Option, ) -> Self { Self::new_with_mode( ClientConnectionMode::App, terminal_size, cell_size, host_terminal_theme, outer_terminal_focus, last_activity, render_encoding, writer, ) } fn new_with_mode( mode: ClientConnectionMode, terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, host_terminal_theme: crate::terminal_theme::TerminalTheme, outer_terminal_focus: Option, last_activity: u64, render_encoding: RenderEncoding, writer: Option, ) -> Self { Self { mode, 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(), 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(); } 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 // --------------------------------------------------------------------------- /// The headless server — runs the herdr event loop without a real terminal. pub struct HeadlessServer { app: app::App, client_listener: UnixListener, client_socket_path: PathBuf, clients: HashMap, next_client_id: u64, /// The client currently driving the shared pane runtime size and theme. foreground_client_id: Option, /// Writable direct attach owner per terminal id string. terminal_attach_owners: HashMap, /// Monotonic activity counter used to pick the most recently active client. next_activity_stamp: u64, /// Shared pane runtime size derived from the foreground client, /// or MIN_COLS × MIN_ROWS when no clients are connected. effective_size: (u16, u16), /// Flag set when shutdown is initiated. shutting_down: bool, /// Flag set by Ctrl+C or `server stop` signal. should_quit: Arc, /// Channel for receiving server events from client connection threads. server_event_rx: mpsc::Receiver, /// Sender for server events (cloned for each client thread). server_event_tx: mpsc::Sender, } impl HeadlessServer { /// Creates and starts the headless server. /// /// This: /// 1. Prepares the client socket path (cleans up stale sockets) /// 2. Binds the client socket listener /// 3. Returns the server ready to run pub fn new(app: app::App) -> io::Result { let client_path = client_socket_path(); prepare_socket_path(&client_path)?; let listener = UnixListener::bind(&client_path)?; restrict_socket_permissions(&client_path)?; info!(path = %client_path.display(), "client protocol socket listening"); // Set non-blocking on the listener so we can poll it from the event loop. listener.set_nonblocking(true)?; let should_quit = Arc::new(AtomicBool::new(false)); // Channel for server events from client threads. let (server_event_tx, server_event_rx) = mpsc::channel(64); Ok(Self { app, client_listener: listener, client_socket_path: client_path, clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, should_quit, server_event_rx, server_event_tx, }) } /// Runs the headless server event loop until shutdown. /// /// This is the server's main loop — analogous to `App::run()` but without /// a real terminal. It: /// - Drains internal events (pane death, state changes) /// - Drains API requests (from the JSON socket) /// - Accepts new client connections /// - Reads client messages and routes input /// - Handles scheduled tasks (resize poll, animation, session save, etc.) /// - Renders virtually and streams frames to clients pub async fn run(&mut self) -> io::Result<()> { crate::logging::startup("server"); // Register SIGINT handler for graceful shutdown. let should_quit = self.should_quit.clone(); let quit_notify = self.server_event_tx.clone(); ctrlc_handler(should_quit, quit_notify); // No input_rx needed — server doesn't read stdin. // We use None for input_rx so the event loop doesn't try to read from stdin. self.app.input_rx = None; let mut needs_render = true; loop { // If shutdown has been initiated, complete it and exit. if self.shutting_down { self.complete_shutdown()?; break; } // Check if we should start shutting down. if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) { self.initiate_shutdown(); continue; } // 1. Check render_dirty flag from PTY reader tasks. if self.app.render_dirty.load(Ordering::Acquire) { needs_render = true; } // 2. Drain internal events. if self.drain_internal_events_with_forwarding() { needs_render = true; } // 3. Drain API requests. if self.drain_api_requests_with_shutdown_check() { needs_render = true; } self.app.sync_focus_events(); self.app.sync_session_save_schedule(); // 4. Accept new client connections. self.accept_client_connections()?; // 5. Drain server events from client threads. if self.drain_server_events() { needs_render = true; } // 6. Handle scheduled tasks. let now = Instant::now(); if self.handle_scheduled_tasks_headless(now) { needs_render = true; } // Handle deferred requests. if self.app.state.request_complete_onboarding { self.app.state.request_complete_onboarding = false; self.app.open_settings_from_onboarding(); needs_render = true; } if self.app.state.request_new_workspace { self.app.state.request_new_workspace = false; self.app.create_workspace(); needs_render = true; } if self.app.state.request_new_tab { self.app.state.request_new_tab = false; self.app.create_tab(); needs_render = true; } if self.app.state.request_reload_config { self.app.state.request_reload_config = false; self.app.reload_config(); needs_render = true; } self.drain_client_sound_config_reload_request(); self.stream_host_mouse_capture_mode(); self.app.sync_headless_animation_timer(now); // 7. Render virtually and stream frames. if needs_render && self.app.can_render_now(now) { self.app.render_dirty.swap(false, Ordering::AcqRel); self.render_and_stream(); self.app.last_render_at = Some(now); needs_render = false; continue; } // 8. Wait for next event. let next_deadline = self .app .next_headless_loop_deadline(now, needs_render) .map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL)) .or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL)); let event = { tokio::select! { maybe_api = self.app.api_rx.recv() => match maybe_api { Some(msg) => LoopEvent::Api(msg), None => LoopEvent::Timer, }, maybe_ev = self.app.event_rx.recv() => match maybe_ev { Some(ev) => LoopEvent::Internal(ev), None => LoopEvent::Timer, }, maybe_server_ev = self.server_event_rx.recv() => match maybe_server_ev { Some(ev) => LoopEvent::ServerEvent(ev), None => LoopEvent::Timer, }, _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer, _ = self.app.render_notify.notified() => LoopEvent::RenderRequested, } }; match event { LoopEvent::Timer => {} LoopEvent::Internal(ev) => { if self.handle_internal_event_with_forwarding(ev) { needs_render = true; } } LoopEvent::Api(msg) => { if self.handle_api_request_with_shutdown_check(msg) { needs_render = true; } } LoopEvent::ServerEvent(ev) => { if self.handle_server_event(ev) { needs_render = true; } } LoopEvent::RenderRequested => { if self.app.render_dirty.load(Ordering::Acquire) { needs_render = true; } } } } // Save session on exit. if !self.app.no_session { self.app.save_session_now(); } info!("headless server exiting"); Ok(()) } fn allocate_activity_stamp(&mut self) -> u64 { let stamp = self.next_activity_stamp; self.next_activity_stamp = self.next_activity_stamp.saturating_add(1); stamp } fn resize_shared_runtime_to_effective_size(&mut self) { if self.foreground_client_id.is_none() { return; } let Some(client_id) = self.foreground_client_id else { return; }; let Some(client) = self.clients.get(&client_id) else { return; }; 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); } else { crate::ui::compute_view(&mut self.app.state, area); } // Shared runtime size changes affect pane wrapping and foreground-driven // rendering semantics. Force one fresh frame to every remaining client // even if the next rendered buffer compares equal to its cached frame. for client in self.clients.values_mut() { client.request_full_redraw(); } } fn sync_foreground_client_state(&mut self) { let Some(client_id) = self.foreground_client_id else { self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; return; }; let Some(client) = self.clients.get(&client_id) else { self.foreground_client_id = None; self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; return; }; self.effective_size = client.terminal_size; self.app.state.outer_terminal_focus = client.outer_terminal_focus; if client.outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } if !client.host_terminal_theme.is_empty() { self.app.set_host_terminal_theme(client.host_terminal_theme); } } fn foreground_client_outer_focus(&self) -> Option { let client_id = self.foreground_client_id?; self.clients.get(&client_id)?.outer_terminal_focus } fn active_tab_suppresses_notifications(&self, is_active_tab: bool) -> bool { crate::app::actions::active_tab_suppresses_notifications( is_active_tab, self.foreground_client_outer_focus(), ) } fn promote_client_to_foreground(&mut self, client_id: u64) -> bool { let stamp = self.allocate_activity_stamp(); let Some(client) = self.clients.get_mut(&client_id) else { return false; }; client.last_activity = stamp; let changed = self.foreground_client_id != Some(client_id); self.foreground_client_id = Some(client_id); self.sync_foreground_client_state(); changed } 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 changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; self.sync_foreground_client_state(); changed } fn remove_client(&mut self, client_id: u64) -> bool { let was_foreground = self.foreground_client_id == Some(client_id); self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); if let Some(removed) = removed { crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { self.terminal_attach_owners.remove(&terminal_id); if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) { self.app .state .direct_attach_resize_locks .remove(&terminal_id); } } } if was_foreground { self.promote_latest_remaining_client() } else { false } } fn send_client_graphics_cleanup(&mut self, client_id: u64) { let (writer, bytes) = match self.clients.get_mut(&client_id) { Some(client) => { let bytes = client.graphics_cache.clear_bytes(); (client.writer.as_ref().cloned(), bytes) } None => return, }; if bytes.is_empty() { return; } let Some(writer) = writer else { return; }; let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else { return; }; let _ = writer.control.send(serialized); } fn send_all_clients_graphics_cleanup(&mut self) { let client_ids = self.clients.keys().copied().collect::>(); for client_id in client_ids { self.send_client_graphics_cleanup(client_id); } } fn update_client_host_theme_from_events( &mut self, client_id: u64, events: &[crate::raw_input::RawInputEvent], ) -> bool { let Some(client) = self.clients.get_mut(&client_id) else { 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 { return false; } client.host_terminal_theme = next_theme; if self.foreground_client_id == Some(client_id) { self.app.set_host_terminal_theme(next_theme) } else { false } } fn update_client_outer_focus_from_events( &mut self, 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 { return; }; let Some(client) = self.clients.get_mut(&client_id) 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(()) } /// Drains server events from the dedicated channel. /// /// Returns true if any input was processed (requiring a re-render). fn drain_server_events(&mut self) -> bool { let mut changed = false; while let Ok(ev) = self.server_event_rx.try_recv() { changed |= self.handle_server_event(ev); } changed } fn terminal_id_by_string(&self, terminal_id: &str) -> Option { self.app .state .terminals .keys() .find(|id| id.to_string() == terminal_id) .cloned() } fn runtime_for_terminal_id_string( &self, 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) } fn write_client_clipboard_image( &mut self, client_id: u64, extension: &str, data: &[u8], ) -> std::io::Result { let staged = crate::server::clipboard_image::stage(client_id, extension, data)?; if let Some(client) = self.clients.get_mut(&client_id) { client.staged_clipboard_files.push(staged.path); } info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image"); Ok(staged.paste_text) } fn paste_client_clipboard_image_path(&mut self, client_id: u64, path: String) -> bool { if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, .. }) = self.clients.get(&client_id) { if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { let payload = paste_payload_for_runtime(runtime, &path); if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) { warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed"); } } return true; } let foreground_changed = self.promote_client_to_foreground(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } if let Some(client) = self.clients.get_mut(&client_id) { client.request_semantic_redraw_after_input(); } self.app.route_client_events( vec![crate::raw_input::RawInputEvent::Paste(path)], self.foreground_client_id == Some(client_id), ); true } fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { self.app .state .workspaces .iter() .find_map(|ws| { ws.tabs.iter().find_map(|tab| { let pane = tab.panes.get(&pane_id)?; self.app .state .terminals .get(&pane.attached_terminal_id) .map(|terminal| terminal.state) }) }) .unwrap_or(crate::detect::AgentState::Unknown) } /// Handles a single internal event with forwarding logic for clipboard, /// sound, and toast notifications to connected clients. /// /// ALL internal events MUST be routed through this method to ensure /// clipboard/notify forwarding is never bypassed. Do not call /// `self.app.handle_internal_event()` directly for any internal event /// in the headless server — use this method instead. /// /// Returns true if the event changed visual state (requiring a re-render). fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { match &ev { AppEvent::ClipboardWrite { content } => { // Clipboard writes are client-local side effects. Forward them only to // the foreground client instead of broadcasting to every attached client. if let Some(client_id) = self.foreground_client_id { let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); self.send_to_client(client_id, ServerMessage::Clipboard { data }); } // ClipboardWrite doesn't change visual state — no render needed. false } AppEvent::StateChanged { pane_id, agent, .. } => { // Capture toast before handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = *agent; // Find the previous effective state of this pane before the event // is processed. Notifications must follow effective state changes, // not raw fallback reports that may be masked by hook authority. let prev_state = self.pane_effective_state(pane_id_val); // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the // sound policy so reloads can keep server-side notification policy live. self.sync_foreground_client_state(); self.app.handle_internal_event(ev); // Forward sound notification to clients when server-side sound policy allows it. let is_active_tab = self .app .state .active .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) .is_some_and(|ws| { ws.find_tab_index_for_pane(pane_id_val) .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, prev_state, next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg.to_owned(), }); } } let toast_msg = if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { self.app .state .toast .as_ref() .map(|toast| format!("{}: {}", toast.title, toast.context)) } else { toast_message_from_state_change( &self.app.state, pane_id_val, suppress_active_tab_notifications, prev_state, next_state, ) } } else { None }; if let Some(msg) = toast_msg { self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg, }); } true } AppEvent::HookStateReported { pane_id, agent_label, .. } => { // Hook reports can be stale or no-op after sequence rejection. // Forward only effective state changes observed after handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = crate::detect::parse_agent_label(agent_label); // Capture the previous effective state for this pane. Hook reports // are already folded into pane.state; raw hook transitions must not // produce a second notification path. let prev_state = self.pane_effective_state(pane_id_val); self.sync_foreground_client_state(); self.app.handle_internal_event(ev); // Forward sound notification based on the effective transition when // server-side sound policy allows it. let is_active_tab = self .app .state .active .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) .is_some_and(|ws| { ws.find_tab_index_for_pane(pane_id_val) .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, prev_state, next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg.to_owned(), }); } } let toast_msg = if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { self.app .state .toast .as_ref() .map(|toast| format!("{}: {}", toast.title, toast.context)) } else { toast_message_from_state_change( &self.app.state, pane_id_val, suppress_active_tab_notifications, prev_state, next_state, ) } } else { None }; if let Some(msg) = toast_msg { self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg, }); } true } AppEvent::UpdateReady { version, install_command, } => { let toast_before = self.app.state.toast.clone(); let version = version.clone(); let install_command = install_command.clone(); self.app.handle_internal_event(ev); let toast_msg = if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { self.app .state .toast .as_ref() .map(|toast| format!("{}: {}", toast.title, toast.context)) } else { Some(format!( "v{version} available: detach, then run `{install_command}`" )) } } else { None }; if let Some(msg) = toast_msg { self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg, }); } true } AppEvent::PaneDied { pane_id } => { let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { ws.tabs.iter().find_map(|tab| { tab.panes .get(pane_id) .map(|pane| pane.attached_terminal_id.to_string()) }) }); self.app.handle_internal_event(ev); if let Some(terminal_id) = terminal_id { self.shutdown_terminal_attach_clients( &terminal_id, format!("terminal {terminal_id} exited"), ); } true } _ => { self.app.handle_internal_event(ev); true } } } /// Drains internal events, forwarding clipboard, sound, and toast /// notifications to connected clients instead of processing them locally. /// /// In the monolithic mode: /// - `ClipboardWrite` events are written to stdout via `write_osc52_bytes`. /// - Sound notifications are played locally via `sound::play`. /// - Toast notifications are set on AppState and rendered into the frame. /// /// In the headless server, there is no stdout terminal or audio subsystem, /// so we: /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the /// foreground client only. /// - Detect when a sound would be played and forward as /// `ServerMessage::Notify { kind: Sound }` to the foreground client. /// - Detect when a toast is set on AppState and forward as /// `ServerMessage::Notify` to the foreground client for terminal/system delivery. fn drain_internal_events_with_forwarding(&mut self) -> bool { let mut changed = false; while let Ok(ev) = self.app.event_rx.try_recv() { changed |= self.handle_internal_event_with_forwarding(ev); } changed } fn drain_client_sound_config_reload_request(&mut self) { if !self.app.state.request_client_sound_config_reload { return; } self.app.state.request_client_sound_config_reload = false; self.send_to_all_clients(ServerMessage::ReloadSoundConfig); } /// Encodes a server message into a length-prefixed frame. fn frame_server_message(msg: &ServerMessage) -> Result, protocol::FramingError> { Self::frame_server_message_with_max(msg, MAX_FRAME_SIZE) } /// Encodes a server message using an explicit payload cap. fn frame_server_message_with_max( msg: &ServerMessage, max_frame_size: usize, ) -> Result, protocol::FramingError> { let mut framed = Vec::new(); protocol::write_message(&mut framed, msg)?; let payload_len = framed.len().saturating_sub(4); if payload_len > max_frame_size { return Err(protocol::FramingError::Oversized { claimed: payload_len, max: max_frame_size, }); } Ok(framed) } /// Sends a message to all connected clients. /// Broken connections are tracked and cleaned up. fn send_to_all_clients(&mut self, msg: ServerMessage) { let serialized = match Self::frame_server_message(&msg) { Ok(framed) => framed, Err(err) => { warn!(err = %err, "failed to serialize message for clients"); return; } }; let mut broken_clients: Vec = Vec::new(); for (&client_id, client) in &mut self.clients { if let Some(writer) = &client.writer { if writer.control.send(serialized.clone()).is_err() { debug!(client_id, "client writer channel closed during broadcast"); broken_clients.push(client_id); } } } // Remove broken clients. for client_id in broken_clients { let foreground_changed = self.remove_client(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } } } /// Sends a client-local side effect to the foreground client only. fn send_to_foreground_client(&mut self, msg: ServerMessage) -> bool { let Some(client_id) = self.foreground_client_id else { return false; }; self.send_to_client(client_id, msg) } /// Sends a message to a specific client. Returns false if the client /// was not found or the send failed (client removed). fn send_to_client(&mut self, client_id: u64, msg: ServerMessage) -> bool { let serialized = match Self::frame_server_message(&msg) { Ok(framed) => framed, Err(err) => { warn!(client_id, err = %err, "failed to serialize message for client"); return false; } }; if let Some(client) = self.clients.get(&client_id) { if let Some(writer) = &client.writer { if writer.control.send(serialized).is_err() { debug!( client_id, "client writer channel closed during targeted send" ); let foreground_changed = self.remove_client(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } return false; } } true } else { false } } fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) { let client_ids: Vec = 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(); for client_id in client_ids { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(reason.clone()), }, ); let foreground_changed = self.remove_client(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } } } fn attach_terminal_client( &mut self, client_id: u64, terminal_id: String, takeover: bool, ) -> bool { let Some(real_terminal_id) = self.terminal_id_by_string(&terminal_id) else { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(format!( "terminal attach failed: terminal {terminal_id} not found" )), }, ); self.remove_client(client_id); return false; }; if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() { if existing_owner != client_id && !takeover { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(format!( "terminal attach failed: terminal {terminal_id} already has an attached client; retry with --takeover" )), }, ); self.remove_client(client_id); return false; } if existing_owner != client_id { self.send_to_client( existing_owner, ServerMessage::ServerShutdown { reason: Some("terminal attach taken over".to_owned()), }, ); self.remove_client(existing_owner); } } let stamp = self.allocate_activity_stamp(); let Some(client) = self.clients.get_mut(&client_id) else { return false; }; let (cols, rows) = client.terminal_size; let cell_size = client.cell_size; client.mode = ClientConnectionMode::TerminalAttach { terminal_id: terminal_id.clone(), }; client.render_state.reset_baseline(); client.last_activity = stamp; let was_foreground = self.foreground_client_id == Some(client_id); if was_foreground { self.promote_latest_remaining_client(); } info!(client_id, cols, rows, terminal_id = %terminal_id, "terminal attach client connected"); self.terminal_attach_owners .insert(terminal_id.clone(), client_id); self.app .state .direct_attach_resize_locks .insert(real_terminal_id.clone()); if let Some(runtime) = self.app.state.terminal_runtimes.get(&real_terminal_id) { runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px); } true } /// Handles a server event. Returns true if the event requires a re-render. fn handle_server_event(&mut self, ev: ServerEvent) -> bool { match ev { ServerEvent::ClientConnected { client_id, cols, rows, cell_width_px, cell_height_px, writer, render_encoding, } => { info!( client_id, cols, rows, cell_width_px, cell_height_px, ?render_encoding, "client connected" ); let last_activity = self.allocate_activity_stamp(); self.clients.insert( client_id, ClientConnection::new_with_mode( ClientConnectionMode::App, (cols, rows), crate::kitty_graphics::HostCellSize { width_px: cell_width_px, height_px: cell_height_px, }, crate::terminal_theme::TerminalTheme::default(), None, last_activity, render_encoding, Some(writer), ), ); self.foreground_client_id = Some(client_id); self.sync_foreground_client_state(); self.resize_shared_runtime_to_effective_size(); true } ServerEvent::ClientAttachTerminal { client_id, terminal_id, takeover, } => self.attach_terminal_client(client_id, terminal_id, takeover), ServerEvent::ClientInput { client_id, data } => { debug!(client_id, len = data.len(), "client input received"); if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, .. }) = self.clients.get(&client_id) { if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { if let Err(err) = runtime.try_send_bytes(Bytes::from(data)) { warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach input failed"); } } return true; } let events = crate::raw_input::parse_raw_input_bytes_sync(&data); let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(&events); if let Some(client) = self.clients.get_mut(&client_id) { if host_surface_redraw { client.request_full_redraw(); } else { // Ensure semantic clients receive one post-input frame even if the // semantic buffer compares equal. Terminal-ANSI clients must keep their // server-side blit baseline; resetting it here forces a full redraw on // every keypress and makes remote sessions feel extremely slow. client.request_semantic_redraw_after_input(); } } self.update_client_outer_focus_from_events(client_id, &events); let interaction = Self::events_include_interaction(&events); let foreground_changed = if interaction { self.promote_client_to_foreground(client_id) } else { false }; if foreground_changed { self.resize_shared_runtime_to_effective_size(); } let theme_changed = self.update_client_host_theme_from_events(client_id, &events); self.app .route_client_events(events, self.foreground_client_id == Some(client_id)); // Check if the detach keybind was triggered during input processing. if self.app.state.detach_requested { self.app.state.detach_requested = false; info!(client_id, "client detach requested via keybind"); // Clear client-local host graphics before sending ServerShutdown // so the outer terminal does not retain stale images. self.send_client_graphics_cleanup(client_id); // Send a ServerShutdown with "detached" reason to this client // so it exits cleanly (not with a connection-lost error). // The client will close its connection after receiving this, // which triggers a ClientDisconnected event that removes it. self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some("detached".to_owned()), }, ); // Don't remove the client here — let the client disconnect // naturally after receiving the ServerShutdown. The client's // read loop will see EOF and the server will get a // ClientDisconnected event which handles cleanup. // // However, we do need to stop sending frames to this client // since it's detaching. Drop the writer channel so no more // frames are queued for this client. if let Some(client) = self.clients.get_mut(&client_id) { client.writer = None; } // No re-render needed for remaining clients. false } else { foreground_changed || theme_changed || interaction } } ServerEvent::ClientClipboardImage { client_id, extension, data, } => { debug!( client_id, len = data.len(), extension = %extension, "client clipboard image received" ); match self.write_client_clipboard_image(client_id, &extension, &data) { Ok(path) => self.paste_client_clipboard_image_path(client_id, path), Err(err) => { warn!(client_id, err = %err, "failed to stage client clipboard image"); true } } } ServerEvent::ClientResize { client_id, cols, rows, cell_width_px, cell_height_px, } => { info!( client_id, cols, rows, cell_width_px, cell_height_px, "client resize" ); let direct_terminal_id = if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, terminal_size, cell_size, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); *cell_size = crate::kitty_graphics::HostCellSize { width_px: cell_width_px, height_px: cell_height_px, }; render_state.reset_baseline(); Some(terminal_id.clone()) } else { None }; if let Some(terminal_id) = direct_terminal_id { if let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) { runtime.resize(rows, cols, cell_width_px, cell_height_px); } return true; } if let Some(client) = self.clients.get_mut(&client_id) { client.terminal_size = (cols, rows); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: cell_width_px, height_px: cell_height_px, }; } self.promote_client_to_foreground(client_id); self.resize_shared_runtime_to_effective_size(); true } ServerEvent::ClientDetach { client_id } => { info!(client_id, "client detached"); let foreground_changed = self.remove_client(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } true } ServerEvent::ClientDisconnected { client_id } => { info!(client_id, "client disconnected"); let foreground_changed = self.remove_client(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } true } ServerEvent::ClientWriterDrained { client_id } => { let Some(client) = self.clients.get_mut(&client_id) else { return false; }; if client.render_pending { client.render_pending = false; true } else { false } } ServerEvent::QuitSignal => { // The quit check at the top of the loop handles this. // No render needed — the next iteration will initiate shutdown. false } } } /// Drains API requests with shutdown awareness. /// /// During shutdown, remaining requests get a `server_unavailable` error. fn drain_api_requests_with_shutdown_check(&mut self) -> bool { let mut changed = false; while let Ok(msg) = self.app.api_rx.try_recv() { changed |= self.handle_api_request_with_shutdown_check(msg); } changed } /// Handles a single API request with shutdown awareness. /// /// Also forwards any toast/sound notifications that result from the API /// request to connected clients. API methods like `pane.report_agent` /// trigger internal events that may set toast state or would normally /// play sounds — in headless mode we forward these to clients instead. fn handle_api_request_with_shutdown_check(&mut self, msg: api::ApiRequestMessage) -> bool { if self.shutting_down { // During shutdown, respond with server_unavailable. let response = serde_json::to_string(&api::schema::ErrorResponse { id: msg.request.id, error: api::schema::ErrorBody { code: "server_unavailable".into(), message: "server is shutting down".into(), }, }) .unwrap_or_else(|_| { r#"{"id":"","error":{"code":"server_unavailable","message":"server is shutting down"}}"# .to_string() }); let _ = msg.respond_to.send(response); return false; } let changed = api::request_changes_ui(&msg.request); // Capture toast and effective pane states before the API call so we can // forward resulting client-local notifications. API requests like // pane.report_agent trigger handle_internal_event internally, which // bypasses drain_internal_events_with_forwarding. Headless mode disables // local sound playback, so sound notifications need to be forwarded here. let toast_before = self.app.state.toast.clone(); let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = { let terminals = &self.app.state.terminals; self.app .state .workspaces .iter() .enumerate() .flat_map(|(ws_idx, ws)| { ws.tabs.iter().flat_map(move |tab| { tab.panes.iter().filter_map(move |(&pane_id, pane)| { terminals .get(&pane.attached_terminal_id) .map(|terminal| (ws_idx, pane_id, terminal.state)) }) }) }) .collect() }; self.sync_foreground_client_state(); let response = self.app.handle_api_request(msg.request); let _ = msg.respond_to.send(response); // Forward new toast state only when a client-local delivery mode is selected. // Herdr delivery renders the toast in-frame and must not ask clients to // show a terminal or system notification. let toast_after = self.app.state.toast.clone(); let forwarded_toast_from_state = if should_forward_toast_to_clients(self.app.state.toast_config.delivery) && toast_after.is_some() && toast_after != toast_before { if let Some(toast) = &toast_after { let msg_text = format!("{}: {}", toast.title, toast.context); debug!(msg = %msg_text, "forwarding toast notification from API request"); self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg_text, }); true } else { false } } else { false }; // Forward notifications for effective pane state changes that occurred // during the API request. Hook authority is already folded into // pane.state, so raw hook transitions must not produce separate sounds. for (ws_idx, pane_id, prev_state) in &pane_states_before { let pane_after = self .app .state .workspaces .get(*ws_idx) .and_then(|ws| ws.tabs.iter().find_map(|tab| tab.panes.get(pane_id))); let Some(pane_after) = pane_after else { continue; }; let Some(terminal_after) = self .app .state .terminals .get(&pane_after.attached_terminal_id) else { continue; }; let new_state = terminal_after.state; if new_state == *prev_state { continue; } let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id); let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); let agent = terminal_after.effective_known_agent(); debug!( ws_idx, pane_id = pane_id.raw(), prev_state = ?prev_state, new_state = ?new_state, agent = ?agent, "pane effective state changed during API request, checking notification" ); if !forwarded_toast_from_state && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if let Some(kind) = crate::app::actions::notification_toast_for_state_change( suppress_active_tab_notifications, *prev_state, new_state, ) { if let Some(agent_label) = self .app .state .terminals .get(&pane_after.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label()) { let event_text = match kind { crate::app::state::ToastKind::NeedsAttention => "needs attention", crate::app::state::ToastKind::Finished => "finished", crate::app::state::ToastKind::UpdateInstalled => "updated", }; let msg_text = format!( "{} {}: {}", agent_label, event_text, crate::app::actions::notification_context( &self.app.state.workspaces[*ws_idx], *ws_idx, *pane_id, ) ); self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg_text, }); } } } // Forward sound notification when server-side sound policy allows it. // Clients still decide locally whether they can execute the side effect. if self.app.state.sound.allows(agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, *prev_state, new_state, ) { let msg_text = match sound { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; debug!(sound = ?sound, "forwarding sound notification from API request"); self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg_text.to_owned(), }); } } } changed } fn stream_host_mouse_capture_mode(&mut self) { let enabled = self.app.state.should_capture_host_mouse(); let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) { Ok(framed) => framed, Err(err) => { warn!(err = %err, "failed to serialize mouse capture mode for clients"); return; } }; let mut broken_clients: Vec = Vec::new(); for (&client_id, client) in &mut self.clients { if !matches!(client.mode, ClientConnectionMode::App) { continue; } if client.host_mouse_capture_active == Some(enabled) { continue; } let Some(writer) = &client.writer else { continue; }; if writer.control.send(serialized.clone()).is_err() { debug!( client_id, "client writer channel closed during mouse capture update" ); broken_clients.push(client_id); continue; } client.host_mouse_capture_active = Some(enabled); } for client_id in broken_clients { let foreground_changed = self.remove_client(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } } } /// 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 = 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)); 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( &mut self.app.state, area, resize_panes, ); debug!( cols, rows, resize_panes, "rendered virtual frame with no attached clients" ); return; } let mut broken_clients: Vec = Vec::new(); for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { let area = Rect::new(0, 0, cols, rows); let is_app_client = matches!(mode, ClientConnectionMode::App); let mut frame = match mode { 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( &mut self.app.state, area, is_foreground, cell_size, ) } else { crate::server::render_stream::render_virtual( &mut self.app.state, area, is_foreground, ) }; let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state); FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) } ClientConnectionMode::TerminalAttach { terminal_id } => { let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(format!( "terminal attach ended: terminal {terminal_id} not found" )), }, ); broken_clients.push(client_id); continue; }; let (buffer, cursor) = crate::server::render_stream::render_terminal_virtual(runtime, area); let hyperlinks = runtime.visible_hyperlinks(area); FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) } }; let Some(client) = self.clients.get_mut(&client_id) else { continue; }; let mut next_graphics_cache = client.graphics_cache.clone(); if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() { frame.graphics = crate::kitty_graphics::encode_local_pane_graphics( &self.app.state, cell_size, &mut next_graphics_cache, ); } else { frame.graphics = next_graphics_cache.clear_bytes(); } let Some(writer) = client.writer.as_ref().cloned() else { continue; }; let mut commit_graphics_cache = true; if frame.graphics.len() > MAX_GRAPHICS_FRAME_SIZE { warn!( client_id, graphics_bytes = frame.graphics.len(), max = MAX_GRAPHICS_FRAME_SIZE, "dropping oversized graphics payload for client frame" ); frame.graphics.clear(); commit_graphics_cache = false; } let Some(mut prepared) = client.render_state.prepare_frame(&frame) else { client.render_pending = false; continue; }; let mut frame_to_commit = frame.clone(); let max_frame_size = if frame.graphics.is_empty() { MAX_FRAME_SIZE } else { MAX_GRAPHICS_FRAME_SIZE }; let serialized = match Self::frame_server_message_with_max( prepared.message(), max_frame_size, ) { Ok(framed) => framed, Err(protocol::FramingError::Oversized { claimed, max }) if !frame.graphics.is_empty() => { warn!( client_id, claimed, max, "dropping graphics from oversized frame for client" ); let mut text_only_frame = frame.clone(); text_only_frame.graphics.clear(); let Some(text_only_prepared) = client.render_state.prepare_frame(&text_only_frame) else { client.render_pending = false; continue; }; let framed = match Self::frame_server_message(text_only_prepared.message()) { Ok(framed) => framed, Err(err) => { warn!(client_id, err = %err, "failed to serialize text-only frame for client"); broken_clients.push(client_id); continue; } }; prepared = text_only_prepared; frame_to_commit = text_only_frame; commit_graphics_cache = false; framed } Err(protocol::FramingError::Oversized { claimed, max }) => { warn!( client_id, claimed, max, "skipping oversized frame for client" ); continue; } Err(err) => { warn!(client_id, err = %err, "failed to serialize frame for client"); broken_clients.push(client_id); continue; } }; match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; if commit_graphics_cache { client.graphics_cache = next_graphics_cache; } client .render_state .commit_sent_frame(frame_to_commit, prepared); } Err(std::sync::mpsc::TrySendError::Full(_)) => { client.render_pending = true; debug!(client_id, "render queue full, deferring latest frame"); continue; } Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { debug!(client_id, "client writer channel closed, marking as broken"); broken_clients.push(client_id); continue; } } } if !broken_clients.is_empty() { for client_id in broken_clients { let foreground_changed = self.remove_client(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } } } let (cols, rows) = self.effective_size; debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); } /// Handle scheduled tasks for the headless server. /// /// Similar to `App::handle_scheduled_tasks` but without resize polling /// (the server doesn't have a terminal to resize). fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool { let mut changed = false; self.app.sync_headless_animation_timer(now); // No resize polling needed — server has no terminal. // Client resize messages drive size changes instead. if self .app .config_diagnostic_deadline .is_some_and(|deadline| now >= deadline) { self.app.config_diagnostic_deadline = None; self.app.state.config_diagnostic = None; changed = true; } if self .app .toast_deadline .is_some_and(|deadline| now >= deadline) { self.app.toast_deadline = None; self.app.state.toast = None; changed = true; } if self .app .next_animation_tick .is_some_and(|deadline| now >= deadline) { self.app.state.spinner_tick = self .app .state .spinner_tick .wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP); self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL); changed = true; } if self .app .selection_autoscroll_deadline .is_some_and(|deadline| now >= deadline) { self.app.tick_selection_autoscroll(now); changed = true; } self.app.start_git_status_refresh_if_due(now); if self .app .next_auto_update_check .is_some_and(|deadline| now >= deadline) { self.app.run_auto_update_check(); } if self .app .session_save_deadline .is_some_and(|deadline| now >= deadline) { self.app.save_session_now(); } self.app.sync_headless_animation_timer(now); changed } /// Initiates graceful shutdown. fn initiate_shutdown(&mut self) { if self.shutting_down { return; } info!("server shutdown initiated"); self.shutting_down = true; // Clear client-local host graphics, then send ServerShutdown to all connected clients. self.send_all_clients_graphics_cleanup(); let shutdown_msg = ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), }; self.send_to_all_clients(shutdown_msg); // Give client writer threads a moment to flush the shutdown message. // A short sleep ensures the message is written to the socket before // we close the connections. std::thread::sleep(Duration::from_millis(50)); // Signal the main loop to exit. self.should_quit.store(true, Ordering::Release); self.app.state.should_quit = true; } /// Completes the shutdown sequence: send ServerShutdown to clients, /// close client connections, remove socket files, and clean up. fn complete_shutdown(&mut self) -> io::Result<()> { info!("completing server shutdown"); // Send ServerShutdown to all remaining clients. if !self.clients.is_empty() { self.send_all_clients_graphics_cleanup(); let shutdown_msg = ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), }; self.send_to_all_clients(shutdown_msg); // Give writer threads a moment to flush before closing. std::thread::sleep(Duration::from_millis(50)); } // Drain remaining API requests with server_unavailable. self.drain_api_requests_with_shutdown_check(); // Close all client connections. let staged_files = self .clients .drain() .flat_map(|(_, client)| client.staged_clipboard_files) .collect::>(); crate::server::clipboard_image::remove_files(staged_files); // Remove socket files. self.cleanup_sockets()?; Ok(()) } /// Removes socket files created by the server. fn cleanup_sockets(&self) -> io::Result<()> { if let Err(err) = fs::remove_file(&self.client_socket_path) { if err.kind() != io::ErrorKind::NotFound { warn!( path = %self.client_socket_path.display(), err = %err, "failed to remove client socket on shutdown" ); } } Ok(()) } } impl Drop for HeadlessServer { fn drop(&mut self) { let staged_files = self .clients .drain() .flat_map(|(_, client)| client.staged_clipboard_files) .collect::>(); crate::server::clipboard_image::remove_files(staged_files); let _ = self.cleanup_sockets(); } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Installs a Ctrl+C handler that sets the should_quit flag and wakes up /// the event loop by sending a QuitSignal on the server event channel. fn ctrlc_handler(should_quit: Arc, server_event_tx: mpsc::Sender) { let _ = ctrlc::set_handler(move || { should_quit.store(true, Ordering::Release); // Wake up the event loop so the quit flag is checked promptly. let _ = server_event_tx.try_send(ServerEvent::QuitSignal); }); } /// Sleep until a deadline, or return pending if none. async fn sleep_until_or_pending(deadline: Option) { match deadline { Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, None => std::future::pending().await, } } // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- /// Run the headless server. This is the entry point called from main.rs. pub fn run_server() -> io::Result<()> { init_logging(); let loaded_config = config::Config::load(); let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let event_hub = api::EventHub::default(); // Start the JSON API socket server. let _api_server = match api::start_server(api_tx, event_hub.clone()) { Ok(server) => server, Err(err) if err.kind() == io::ErrorKind::AddrInUse => { eprintln!("error: herdr server is already running"); eprintln!("api socket: {}", api::socket_path().display()); std::process::exit(1); } Err(err) => return Err(err), }; let no_session = false; // Server always does session persistence. let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(io::Error::other)?; let result = rt.block_on(async { // Create the App (with AppState, event channels, etc.). let mut app = app::App::new( &loaded_config.config, no_session, config::config_diagnostic_summary(&loaded_config.diagnostics), api_rx, event_hub, ); // The server runs headless — disable local notification side effects. // Sound and terminal notifications are forwarded to connected clients // as ServerMessage::Notify instead of emitted by the server process. app.state.local_sound_playback = false; app.local_terminal_notifications = false; // Create the headless server. let mut server = match HeadlessServer::new(app) { Ok(server) => server, Err(err) if err.kind() == io::ErrorKind::AddrInUse => { eprintln!("error: herdr server is already running"); eprintln!("client socket: {}", client_socket_path().display()); std::process::exit(1); } Err(err) => return Err(err), }; info!( api_socket = %api::socket_path().display(), client_socket = %client_socket_path().display(), "herdr server started" ); server.run().await }); rt.shutdown_timeout(Duration::from_millis(100)); crate::logging::shutdown("server"); result } /// Initialize logging for the server process. fn init_logging() { crate::logging::init_file_logging("herdr-server.log"); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::server::protocol::CursorState; fn test_headless_server() -> HeadlessServer { let config = crate::config::Config::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = crate::app::App::new(&config, true, None, api_rx, api::EventHub::default()); app.state.local_sound_playback = false; app.local_terminal_notifications = false; let dir = std::env::temp_dir().join(format!( "hh-{}-{}", 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("client.sock"); let _ = fs::remove_file(&socket_path); let listener = UnixListener::bind(&socket_path).expect("bind test listener"); listener .set_nonblocking(true) .expect("set listener nonblocking"); let (server_event_tx, server_event_rx) = mpsc::channel(64); HeadlessServer { app, client_listener: listener, client_socket_path: socket_path, clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, should_quit: Arc::new(AtomicBool::new(false)), server_event_rx, server_event_tx, } } fn read_server_message(bytes: Vec) -> ServerMessage { let mut cursor = std::io::Cursor::new(bytes); protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") } fn read_server_frame(bytes: Vec) -> FrameData { match read_server_message(bytes) { ServerMessage::Frame(frame) => frame, other => panic!("expected frame, got {other:?}"), } } fn read_server_shutdown_reason(bytes: Vec) -> Option { match read_server_message(bytes) { ServerMessage::ServerShutdown { reason } => reason, other => panic!("expected shutdown, got {other:?}"), } } fn test_client_writer() -> ( ClientWriter, std::sync::mpsc::Receiver>, std::sync::mpsc::Receiver>, ) { let (control_tx, control_rx) = std::sync::mpsc::channel(); let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); ( ClientWriter { control: control_tx, render: render_tx, }, control_rx, render_rx, ) } #[test] fn terminal_attach_rejects_missing_terminal_and_removes_client() { let mut server = test_headless_server(); let (writer, control_rx, _render_rx) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 7, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, writer, })); assert!(server.clients.contains_key(&7)); assert!( !server.handle_server_event(ServerEvent::ClientAttachTerminal { client_id: 7, terminal_id: "term_missing".to_owned(), takeover: false, }) ); assert!(!server.clients.contains_key(&7)); let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); assert_eq!( reason, Some("terminal attach failed: terminal term_missing not found".to_owned()) ); } #[test] fn terminal_attach_client_exits_when_attached_pane_dies() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("attached"); let pane_id = workspace.tabs[0].root_pane; server.app.state.workspaces = vec![workspace]; server.app.state.ensure_test_terminals(); let terminal_id = server.app.state.workspaces[0] .pane_state(pane_id) .expect("pane") .attached_terminal_id .to_string(); let (writer, control_rx, _render_rx) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 7, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, writer, })); assert!( server.handle_server_event(ServerEvent::ClientAttachTerminal { client_id: 7, terminal_id: terminal_id.clone(), takeover: false, }) ); assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); assert!(!server.clients.contains_key(&7)); assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); } #[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); let (buffer, _cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(buffer.area.width, 80); assert_eq!(buffer.area.height, 24); } #[test] fn virtual_render_without_frame_cursor_keeps_cursor_hidden() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(cursor, None); } #[tokio::test] async fn virtual_render_preserves_explicit_frame_cursor_position() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos .iter() .find(|info| info.id == pane_id) .expect("focused pane info"); assert_eq!( cursor, Some(CursorState { x: pane.inner_rect.x + 4, y: pane.inner_rect.y, visible: true, shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), }) ); } #[tokio::test] async fn virtual_render_preserves_hidden_focused_pane_cursor_position() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos .iter() .find(|info| info.id == pane_id) .expect("focused pane info"); assert_eq!( cursor, Some(CursorState { x: pane.inner_rect.x + 4, y: pane.inner_rect.y, visible: false, shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), }) ); } #[tokio::test] async fn virtual_render_omits_focused_pane_cursor_while_mobile_switcher_open() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Navigate; let area = Rect::new(0, 0, 44, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(cursor, None); } #[tokio::test] async fn virtual_render_hides_focused_pane_cursor_while_scrolled_back() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let mut bytes = Vec::new(); for line in 0..80 { bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); } let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); ws.insert_test_runtime(pane_id, runtime); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); let runtime = state .runtime_for_pane(pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert!( cursor.as_ref().is_none_or(|cursor| !cursor.visible), "cursor: {cursor:?}" ); } #[test] fn latest_active_client_drives_shared_size_theme_and_fallback() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (160, 45), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0xaa, g: 0xbb, b: 0xcc, }), background: Some(crate::terminal_theme::RgbColor { r: 0x11, g: 0x22, b: 0x33, }), }, None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0x10, g: 0x20, b: 0x30, }), background: Some(crate::terminal_theme::RgbColor { r: 0xdd, g: 0xee, b: 0xff, }), }, None, 2, RenderEncoding::SemanticFrame, None, ), ); assert!(server.promote_client_to_foreground(1)); assert_eq!(server.foreground_client_id, Some(1)); assert_eq!(server.effective_size, (160, 45)); assert_eq!( server.app.state.host_terminal_theme, server.clients[&1].host_terminal_theme ); assert!(server.promote_client_to_foreground(2)); assert_eq!(server.foreground_client_id, Some(2)); assert_eq!(server.effective_size, (80, 24)); assert_eq!( server.app.state.host_terminal_theme, server.clients[&2].host_terminal_theme ); assert!(server.remove_client(2)); assert_eq!(server.foreground_client_id, Some(1)); assert_eq!(server.effective_size, (160, 45)); assert_eq!( server.app.state.host_terminal_theme, server.clients[&1].host_terminal_theme ); } #[test] fn focus_lost_updates_client_without_promoting_foreground() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 2, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(2); server.sync_foreground_client_state(); let changed = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[O".to_vec(), }); assert!(!changed); assert_eq!(server.foreground_client_id, Some(2)); assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); assert_eq!(server.app.state.outer_terminal_focus, Some(true)); } #[test] fn focus_gained_promotes_client_to_foreground() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 2, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(2); server.sync_foreground_client_state(); let changed = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[I".to_vec(), }); assert!(changed); assert_eq!(server.foreground_client_id, Some(1)); assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); assert_eq!(server.app.state.outer_terminal_focus, Some(true)); } #[test] fn foreground_client_focus_event_updates_app_focus_state() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 1, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); let changed = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[O".to_vec(), }); assert!(!changed); assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); assert_eq!(server.app.state.outer_terminal_focus, Some(false)); } #[test] fn render_and_stream_uses_each_client_terminal_size() { let mut server = test_headless_server(); server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer(); let (phone_tx, _phone_control_rx, phone_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(desktop_tx), ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, RenderEncoding::SemanticFrame, Some(phone_tx), ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); server.resize_shared_runtime_to_effective_size(); server.render_and_stream(); let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame")); let phone_frame = read_server_frame(phone_rx.recv().expect("phone frame")); assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40)); assert_eq!((phone_frame.width, phone_frame.height), (80, 24)); } #[tokio::test] async fn resize_shared_runtime_resizes_background_tabs() { let mut server = test_headless_server(); let mut workspace = crate::workspace::Workspace::test_new("test"); let background_tab = workspace.test_add_tab(Some("background")); let active_pane = workspace.tabs[0].root_pane; let background_pane = workspace.tabs[background_tab].root_pane; workspace.tabs[0].runtimes.insert( active_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), ); workspace.tabs[background_tab].runtimes.insert( background_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), ); server.app.state.workspaces = vec![workspace]; server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); server.resize_shared_runtime_to_effective_size(); let terminal_area = server.app.state.view.terminal_area; let expected = (terminal_area.height, terminal_area.width.saturating_sub(1)); assert_eq!( server .app .state .runtime_for_pane(active_pane) .unwrap() .current_size(), expected ); assert_eq!( server .app .state .runtime_for_pane(background_pane) .unwrap() .current_size(), expected ); } #[test] fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); match read_server_message( client_rx .recv_timeout(Duration::from_millis(100)) .expect("terminal frame"), ) { ServerMessage::Terminal(frame) => { assert_eq!(frame.seq, 1); assert_eq!((frame.width, frame.height), (80, 24)); assert!(frame.full); assert!(!frame.bytes.is_empty()); } other => panic!("expected terminal frame, got {other:?}"), } assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); } #[test] fn terminal_ansi_input_does_not_reset_blit_baseline() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial terminal frame"); assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); assert!(!server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: Vec::new(), })); server.render_and_stream(); assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); } #[test] fn outer_focus_gained_forces_terminal_ansi_full_redraw() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial terminal frame"); assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[I".to_vec(), })); server.render_and_stream(); match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { ServerMessage::Terminal(frame) => { assert_eq!(frame.seq, 2); assert!(frame.full); } other => panic!("expected terminal frame, got {other:?}"), } } #[test] fn full_render_queue_does_not_advance_terminal_ansi_baseline() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("serialize dummy message"); client_tx .render .send(queued) .expect("pre-fill render queue"); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 0 ); assert!(matches!( read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), ServerMessage::ReloadSoundConfig )); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); } #[test] fn writer_drained_retries_pending_terminal_ansi_render() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("serialize dummy message"); client_tx .render .send(queued) .expect("pre-fill render queue"); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); assert!(server.clients.get(&1).unwrap().render_pending); assert!(matches!( read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), ServerMessage::ReloadSoundConfig )); assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 })); server.render_and_stream(); match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 1), other => panic!("expected terminal frame, got {other:?}"), } assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); assert!(!server.clients.get(&1).unwrap().render_pending); } #[test] fn render_and_stream_skips_identical_frame_sends() { let mut server = test_headless_server(); server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; let (client_tx, _client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); server.resize_shared_runtime_to_effective_size(); server.render_and_stream(); let first = client_rx.recv_timeout(Duration::from_millis(100)); assert!(first.is_ok(), "expected first frame to be sent"); server.render_and_stream(); assert!( client_rx.recv_timeout(Duration::from_millis(50)).is_err(), "identical frame should not be sent twice" ); } #[test] fn client_sound_reload_request_refreshes_attached_clients() { let mut server = test_headless_server(); let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(client_tx), ), ); server.app.state.request_client_sound_config_reload = true; server.drain_client_sound_config_reload_request(); match read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("client sound reload message"), ) { ServerMessage::ReloadSoundConfig => {} other => panic!("expected ReloadSoundConfig, got {other:?}"), } assert!(!server.app.state.request_client_sound_config_reload); } #[test] fn clipboard_write_targets_foreground_client_only() { let mut server = test_headless_server(); let (background_tx, background_control_rx, _background_rx) = test_client_writer(); let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(background_tx), ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, RenderEncoding::SemanticFrame, Some(foreground_tx), ), ); server.foreground_client_id = Some(2); server.sync_foreground_client_state(); let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { content: b"test".to_vec(), }); assert!(!changed); match read_server_message( foreground_control_rx .recv_timeout(Duration::from_millis(100)) .expect("foreground clipboard message"), ) { ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="), other => panic!("expected clipboard message, got {other:?}"), } assert!( background_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "background client should not receive clipboard writes" ); } #[test] fn client_local_notifications_target_foreground_client_only() { let mut server = test_headless_server(); let (background_tx, background_control_rx, _background_rx) = test_client_writer(); let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(background_tx), ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, RenderEncoding::SemanticFrame, Some(foreground_tx), ), ); server.foreground_client_id = Some(2); server.sync_foreground_client_state(); assert!(server.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: "pi finished: workspace 1".to_string(), })); match read_server_message( foreground_control_rx .recv_timeout(Duration::from_millis(100)) .expect("foreground toast message"), ) { ServerMessage::Notify { kind, message } => { assert_eq!(kind, protocol::NotifyKind::Toast); assert_eq!(message, "pi finished: workspace 1"); } other => panic!("expected toast notify, got {other:?}"), } assert!( background_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "background client should not receive client-local notifications" ); } #[test] fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() { let mut server = test_headless_server(); let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { version: "9.9.9".to_string(), install_command: "herdr update".into(), }); assert!(changed); assert!(server.app.state.toast.is_some()); assert!( client_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "herdr delivery should render in-frame instead of forwarding a client-local notification" ); } #[test] fn system_toast_delivery_forwards_system_notify_kind() { let mut server = test_headless_server(); let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { version: "9.9.9".to_string(), install_command: "herdr update".into(), }); assert!(changed); match read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("system toast message"), ) { ServerMessage::Notify { kind, message } => { assert_eq!(kind, protocol::NotifyKind::SystemToast); assert_eq!(message, "v9.9.9 available: detach, then run `herdr update`"); } other => panic!("expected system toast notify, got {other:?}"), } } #[test] fn stale_api_agent_report_does_not_forward_done_sound() { let mut server = test_headless_server(); let background = crate::workspace::Workspace::test_new("background"); let pane_id = background.tabs[0].root_pane; let public_pane_id = format!("{}-1", background.id); let foreground = crate::workspace::Workspace::test_new("foreground"); server.app.state.workspaces = vec![background, foreground]; server.app.state.ensure_test_terminals(); let terminal_id = server.app.state.workspaces[0] .pane_state(pane_id) .unwrap() .attached_terminal_id .clone(); server .app .state .terminals .get_mut(&terminal_id) .unwrap() .set_hook_authority( "herdr:pi".into(), "pi".into(), crate::detect::AgentState::Working, None, Some(20), ); server.app.state.active = Some(1); server.app.state.selected = 1; server.app.state.mode = crate::app::Mode::Terminal; let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); let (respond_to, response_rx) = std::sync::mpsc::channel(); let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { request: api::schema::Request { id: "stale".into(), method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { pane_id: public_pane_id, source: "herdr:pi".into(), agent: "pi".into(), state: api::schema::PaneAgentState::Idle, message: None, custom_status: None, seq: Some(19), }), }, respond_to, }); assert!(changed); assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); assert_eq!( server.app.state.terminals.get(&terminal_id).unwrap().state, crate::detect::AgentState::Working ); assert!( client_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "stale idle report must not forward a done sound" ); } /// Verify that no direct calls to `self.app.handle_internal_event` /// exist outside of `handle_internal_event_with_forwarding` in this /// module. This ensures the forwarding bypass cannot be reintroduced. /// /// The search pattern looks for `handle_internal_event` calls that /// are NOT inside the `handle_internal_event_with_forwarding` method. #[test] fn no_handle_internal_event_bypass_in_module() { let source = include_str!("headless.rs"); // Find all lines containing handle_internal_event let mut bypass_lines: Vec = Vec::new(); let mut inside_forwarding_method = false; let mut forwarding_method_brace_depth = 0u32; for (i, line) in source.lines().enumerate() { let line_num = i + 1; // Track when we're inside handle_internal_event_with_forwarding if line.contains("fn handle_internal_event_with_forwarding") { inside_forwarding_method = true; forwarding_method_brace_depth = 0; } if inside_forwarding_method { // Count braces to track when we exit the method for ch in line.chars() { match ch { '{' => forwarding_method_brace_depth += 1, '}' => { forwarding_method_brace_depth = forwarding_method_brace_depth.saturating_sub(1); if forwarding_method_brace_depth == 0 { inside_forwarding_method = false; } } _ => {} } } } else if line.contains("self.app.handle_internal_event(") && !line.trim().starts_with("///") && !line.contains("contains(") { // Direct call to handle_internal_event outside the forwarding method bypass_lines.push(format!("line {}: {}", line_num, line.trim())); } } assert!( bypass_lines.is_empty(), "Found direct calls to self.app.handle_internal_event outside \ handle_internal_event_with_forwarding (bypass risk):\n {}", bypass_lines.join("\n ") ); } }