4775 lines
176 KiB
Rust
4775 lines
176 KiB
Rust
//! 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::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 crossterm::event::{KeyModifiers, MouseEventKind};
|
||
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::config;
|
||
use crate::events::AppEvent;
|
||
use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity};
|
||
use crate::protocol::{
|
||
self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE,
|
||
MAX_GRAPHICS_FRAME_SIZE,
|
||
};
|
||
use crate::server::client_accept::{
|
||
accept_pending_client_connections, reject_pending_client_connections,
|
||
};
|
||
use crate::server::client_transport::ServerEvent;
|
||
use crate::server::clients::{
|
||
events_include_interaction, latest_app_client, render_targets, terminal_attach_client_ids,
|
||
ClientConnection, ClientConnectionMode,
|
||
};
|
||
use crate::server::keybindings::{app_keybindings, apply_keybindings};
|
||
use crate::server::notifications::{
|
||
should_forward_toast_to_clients, toast_message_from_state_change, toast_notify_kind,
|
||
};
|
||
use crate::server::socket_paths::{
|
||
client_socket_path, prepare_socket_path, restrict_socket_permissions,
|
||
};
|
||
use crate::server::terminal_attach::paste_payload_for_runtime;
|
||
|
||
#[cfg(test)]
|
||
use crate::protocol::RenderEncoding;
|
||
#[cfg(test)]
|
||
use crate::server::client_transport::ClientWriter;
|
||
#[cfg(test)]
|
||
use std::fs;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
|
||
/// 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);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Headless server
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The headless server — runs the herdr event loop without a real terminal.
|
||
pub struct HeadlessServer {
|
||
app: app::App,
|
||
api_tx: Option<api::ApiRequestSender>,
|
||
api_server: Option<api::ServerHandle>,
|
||
client_listener: UnixListener,
|
||
client_socket_path: PathBuf,
|
||
client_socket_identity: SocketFileIdentity,
|
||
clients: HashMap<u64, ClientConnection>,
|
||
next_client_id: u64,
|
||
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
|
||
foreground_client_id: Option<u64>,
|
||
/// Server-owned keybindings, restored when foreground clients use server mode.
|
||
server_keybindings: crate::config::LiveKeybindConfig,
|
||
/// Full server config warning shown to clients that use server keybindings.
|
||
server_config_diagnostic: Option<String>,
|
||
/// Server config warning with keybinding diagnostics removed for local-keybinding clients.
|
||
server_config_diagnostic_without_keybindings: Option<String>,
|
||
/// Writable direct attach owner per terminal id string.
|
||
terminal_attach_owners: HashMap<String, u64>,
|
||
/// 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 while exporting live PTYs to a replacement server.
|
||
handoff_in_progress: bool,
|
||
/// Imported panes get one app-safe resize nudge after the first client attaches.
|
||
pending_handoff_repaint_nudge: bool,
|
||
/// Flag set by Ctrl+C or `server stop` signal.
|
||
should_quit: Arc<AtomicBool>,
|
||
/// Channel for receiving server events from client connection threads.
|
||
server_event_rx: mpsc::Receiver<ServerEvent>,
|
||
/// Sender for server events (cloned for each client thread).
|
||
server_event_tx: mpsc::Sender<ServerEvent>,
|
||
}
|
||
|
||
fn apply_terminal_attach_scroll(
|
||
runtime: &crate::terminal::TerminalRuntime,
|
||
source: AttachScrollSource,
|
||
direction: AttachScrollDirection,
|
||
lines: u16,
|
||
column: Option<u16>,
|
||
row: Option<u16>,
|
||
modifiers: u8,
|
||
) -> Result<(), String> {
|
||
let wheel_kind = match direction {
|
||
AttachScrollDirection::Up => MouseEventKind::ScrollUp,
|
||
AttachScrollDirection::Down => MouseEventKind::ScrollDown,
|
||
};
|
||
if let AttachScrollSource::PageKey { input } = source {
|
||
let host_scroll = runtime.input_state().is_some_and(|input_state| {
|
||
!input_state.alternate_screen && !input_state.mouse_reporting_enabled()
|
||
});
|
||
if host_scroll {
|
||
match direction {
|
||
AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize),
|
||
AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize),
|
||
}
|
||
return Ok(());
|
||
}
|
||
return apply_terminal_attach_input(runtime, input);
|
||
}
|
||
|
||
match runtime.wheel_routing() {
|
||
Some(crate::pane::WheelRouting::MouseReport) => {
|
||
runtime.scroll_reset();
|
||
let column = column.unwrap_or(0);
|
||
let row = row.unwrap_or(0);
|
||
let Some(bytes) = runtime.encode_mouse_wheel(
|
||
wheel_kind,
|
||
column,
|
||
row,
|
||
KeyModifiers::from_bits_truncate(modifiers),
|
||
) else {
|
||
return Err(format!(
|
||
"failed to encode terminal attach mouse wheel event: {wheel_kind:?}"
|
||
));
|
||
};
|
||
runtime
|
||
.try_send_bytes(Bytes::from(bytes))
|
||
.map_err(|err| format!("terminal attach mouse wheel input failed: {err}"))?;
|
||
}
|
||
Some(crate::pane::WheelRouting::AlternateScroll) => {
|
||
runtime.scroll_reset();
|
||
let Some(bytes) = runtime.encode_alternate_scroll(wheel_kind) else {
|
||
return Ok(());
|
||
};
|
||
runtime
|
||
.try_send_bytes(Bytes::from(bytes))
|
||
.map_err(|err| format!("terminal attach alternate scroll input failed: {err}"))?;
|
||
}
|
||
Some(crate::pane::WheelRouting::HostScroll) | None => match direction {
|
||
AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize),
|
||
AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize),
|
||
},
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn apply_terminal_attach_input(
|
||
runtime: &crate::terminal::TerminalRuntime,
|
||
data: Vec<u8>,
|
||
) -> Result<(), String> {
|
||
runtime.scroll_reset();
|
||
runtime
|
||
.try_send_bytes(Bytes::from(data))
|
||
.map_err(|err| format!("terminal attach input failed: {err}"))
|
||
}
|
||
|
||
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,
|
||
config_diagnostics: &[String],
|
||
api_tx: Option<api::ApiRequestSender>,
|
||
api_server: Option<api::ServerHandle>,
|
||
) -> io::Result<Self> {
|
||
let client_path = client_socket_path();
|
||
prepare_socket_path(&client_path)?;
|
||
|
||
let listener = UnixListener::bind(&client_path)?;
|
||
restrict_socket_permissions(&client_path)?;
|
||
let client_socket_identity = socket_file_identity(&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);
|
||
let server_keybindings = app_keybindings(&app);
|
||
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
|
||
server_config_diagnostic_summaries(config_diagnostics);
|
||
|
||
Ok(Self {
|
||
app,
|
||
api_tx,
|
||
api_server,
|
||
client_listener: listener,
|
||
client_socket_path: client_path,
|
||
client_socket_identity,
|
||
clients: HashMap::new(),
|
||
next_client_id: 1,
|
||
foreground_client_id: None,
|
||
server_keybindings,
|
||
server_config_diagnostic,
|
||
server_config_diagnostic_without_keybindings,
|
||
terminal_attach_owners: HashMap::new(),
|
||
next_activity_stamp: 1,
|
||
effective_size: (MIN_COLS, MIN_ROWS),
|
||
shutting_down: false,
|
||
handoff_in_progress: false,
|
||
pending_handoff_repaint_nudge: 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 a bounded internal-event batch. API handlers perform an
|
||
// exhaustive forwarding-aware drain before reading pane/runtime state.
|
||
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 let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() {
|
||
self.app.open_new_linked_worktree_dialog(ws_idx);
|
||
needs_render = true;
|
||
}
|
||
|
||
if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() {
|
||
self.app.open_existing_worktree_dialog(ws_idx);
|
||
needs_render = true;
|
||
}
|
||
|
||
if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() {
|
||
if let Err(err) = self.app.create_workspace_with_options(cwd, true) {
|
||
error!(err = %err, "failed to create workspace at requested cwd");
|
||
self.app.state.mode = app::Mode::Navigate;
|
||
}
|
||
needs_render = true;
|
||
}
|
||
|
||
if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() {
|
||
self.app.open_remove_linked_worktree_confirmation(ws_idx);
|
||
needs_render = true;
|
||
}
|
||
|
||
if self.app.state.request_submit_worktree_create {
|
||
self.app.state.request_submit_worktree_create = false;
|
||
self.app.start_worktree_add();
|
||
needs_render = true;
|
||
}
|
||
|
||
if self.app.state.request_submit_worktree_open {
|
||
self.app.state.request_submit_worktree_open = false;
|
||
self.app.open_selected_existing_worktree();
|
||
needs_render = true;
|
||
}
|
||
|
||
if self.app.state.request_submit_worktree_remove {
|
||
self.app.state.request_submit_worktree_remove = false;
|
||
self.app.start_worktree_remove();
|
||
needs_render = true;
|
||
}
|
||
|
||
if self.app.state.request_reload_config {
|
||
self.app.state.request_reload_config = false;
|
||
self.reload_server_config(true);
|
||
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,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
client.cell_size,
|
||
);
|
||
} else {
|
||
crate::ui::compute_view_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
);
|
||
}
|
||
|
||
// Shared runtime size changes affect pane wrapping and foreground-driven
|
||
// 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;
|
||
let server_keybindings = self.server_keybindings.clone();
|
||
apply_keybindings(&mut self.app, &server_keybindings);
|
||
self.sync_visible_server_config_diagnostic(false);
|
||
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;
|
||
let server_keybindings = self.server_keybindings.clone();
|
||
apply_keybindings(&mut self.app, &server_keybindings);
|
||
self.sync_visible_server_config_diagnostic(false);
|
||
return;
|
||
};
|
||
|
||
let terminal_size = client.terminal_size;
|
||
let outer_terminal_focus = client.outer_terminal_focus;
|
||
let host_terminal_theme = client.host_terminal_theme;
|
||
let uses_local_keybindings = client.keybindings.is_some();
|
||
let keybindings = client
|
||
.keybindings
|
||
.as_deref()
|
||
.unwrap_or(&self.server_keybindings)
|
||
.clone();
|
||
|
||
self.effective_size = terminal_size;
|
||
self.app.state.outer_terminal_focus = outer_terminal_focus;
|
||
apply_keybindings(&mut self.app, &keybindings);
|
||
self.sync_visible_server_config_diagnostic(uses_local_keybindings);
|
||
if outer_terminal_focus == Some(true) {
|
||
self.app.state.mark_active_tab_seen();
|
||
}
|
||
if !host_terminal_theme.is_empty() {
|
||
self.app.set_host_terminal_theme(host_terminal_theme);
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn perform_live_handoff(
|
||
&mut self,
|
||
params: crate::api::schema::ServerLiveHandoffParams,
|
||
) -> io::Result<()> {
|
||
info!("starting live handoff");
|
||
let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from);
|
||
let socket_path = crate::server::handoff::handoff_socket_path();
|
||
let token = format!(
|
||
"{}-{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
);
|
||
let listener = match crate::server::handoff::bind_listener(&socket_path) {
|
||
Ok(listener) => listener,
|
||
Err(err) => {
|
||
self.handoff_in_progress = false;
|
||
return Err(err);
|
||
}
|
||
};
|
||
|
||
let mut pane_by_terminal = HashMap::new();
|
||
for ws in &self.app.state.workspaces {
|
||
for tab in &ws.tabs {
|
||
for (pane_id, pane) in &tab.panes {
|
||
pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw());
|
||
}
|
||
}
|
||
}
|
||
if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF {
|
||
let _ = std::fs::remove_file(&socket_path);
|
||
return Err(io::Error::new(
|
||
io::ErrorKind::InvalidInput,
|
||
format!(
|
||
"live handoff supports at most {} panes in one update; close panes or restart herdr normally",
|
||
crate::server::handoff::MAX_FDS_PER_HANDOFF
|
||
),
|
||
));
|
||
}
|
||
|
||
self.handoff_in_progress = true;
|
||
self.disconnect_all_clients_for_handoff();
|
||
let _ = reject_pending_client_connections(&self.client_listener);
|
||
|
||
let mut paused_terminal_ids = Vec::new();
|
||
for terminal_id in pane_by_terminal.keys() {
|
||
if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
|
||
if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
paused_terminal_ids.push(terminal_id.clone());
|
||
}
|
||
}
|
||
|
||
let snapshot = crate::persist::capture(
|
||
&self.app.state.workspaces,
|
||
&self.app.state.terminals,
|
||
&self.app.terminal_runtimes,
|
||
self.app.state.active,
|
||
self.app.state.selected,
|
||
self.app.state.agent_panel_scope,
|
||
self.app.state.sidebar_width,
|
||
self.app.state.sidebar_section_split,
|
||
self.app.state.collapsed_space_keys.clone(),
|
||
);
|
||
|
||
let mut panes = Vec::new();
|
||
for (terminal_id, runtime) in self.app.terminal_runtimes.iter() {
|
||
let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else {
|
||
continue;
|
||
};
|
||
let mut handoff_pane = runtime.handoff_pane(pane_id);
|
||
let has_agent_session = self
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(terminal_id)
|
||
.is_some_and(|terminal| terminal.persisted_agent_session.is_some());
|
||
if !has_agent_session {
|
||
handoff_pane.initial_history_ansi = runtime.handoff_history_ansi();
|
||
}
|
||
panes.push(handoff_pane);
|
||
}
|
||
|
||
let manifest = crate::server::handoff::manifest_for(
|
||
snapshot,
|
||
panes,
|
||
params.expected_protocol,
|
||
params.expected_version,
|
||
);
|
||
let child_pid = match crate::server::handoff::spawn_handoff_import(
|
||
import_exe.as_deref(),
|
||
&socket_path,
|
||
&token,
|
||
) {
|
||
Ok(child_pid) => child_pid,
|
||
Err(err) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
};
|
||
info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server");
|
||
|
||
let mut fds = Vec::new();
|
||
let duplicate_result = (|| {
|
||
for (terminal_id, runtime) in self.app.terminal_runtimes.iter() {
|
||
if !pane_by_terminal.contains_key(terminal_id) {
|
||
continue;
|
||
}
|
||
fds.push(runtime.duplicate_handoff_fd()?);
|
||
}
|
||
Ok::<(), io::Error>(())
|
||
})();
|
||
if let Err(err) = duplicate_result {
|
||
for fd in fds {
|
||
let _ = unsafe { libc::close(fd) };
|
||
}
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
|
||
let mut stream = match crate::server::handoff::accept_and_validate_on(
|
||
listener,
|
||
&socket_path,
|
||
&token,
|
||
&manifest,
|
||
) {
|
||
Ok(stream) => stream,
|
||
Err(err) => {
|
||
for fd in fds {
|
||
let _ = unsafe { libc::close(fd) };
|
||
}
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
};
|
||
|
||
let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds);
|
||
for fd in fds {
|
||
let _ = unsafe { libc::close(fd) };
|
||
}
|
||
if let Err(err) = send_result {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
|
||
if let Some(api_server) = &self.api_server {
|
||
let _ = api_server.remove_socket_file_if_owned();
|
||
} else {
|
||
let _ = std::fs::remove_file(crate::api::socket_path());
|
||
}
|
||
let _ = remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity);
|
||
if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
|
||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||
Ok(()) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
}
|
||
Err(restore_err) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(io::Error::other(format!(
|
||
"handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}"
|
||
)));
|
||
}
|
||
}
|
||
return Err(io::Error::other(format!(
|
||
"handoff replacement server did not become ready: {err}"
|
||
)));
|
||
}
|
||
if let Err(err) = crate::server::handoff::report_committed(&mut stream) {
|
||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||
Ok(()) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
}
|
||
Err(restore_err) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(io::Error::other(format!(
|
||
"handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}"
|
||
)));
|
||
}
|
||
}
|
||
return Err(err);
|
||
}
|
||
|
||
for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() {
|
||
if !pane_by_terminal.contains_key(&terminal_id) {
|
||
continue;
|
||
}
|
||
debug!(terminal = %terminal_id, "preserving pane runtime for handoff");
|
||
runtime.preserve_for_handoff();
|
||
}
|
||
crate::server::handoff::wait_owned_ack(&mut stream);
|
||
|
||
self.shutting_down = true;
|
||
self.app.state.should_quit = true;
|
||
self.app.no_session = true;
|
||
info!("live handoff completed; old server exiting");
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn perform_live_handoff(
|
||
&mut self,
|
||
_params: crate::api::schema::ServerLiveHandoffParams,
|
||
) -> io::Result<()> {
|
||
Err(io::Error::other("live handoff is only supported on Unix"))
|
||
}
|
||
|
||
fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) {
|
||
let visible = if uses_local_keybindings {
|
||
&self.server_config_diagnostic_without_keybindings
|
||
} else {
|
||
&self.server_config_diagnostic
|
||
};
|
||
if self.app.state.config_diagnostic == self.server_config_diagnostic
|
||
|| self.app.state.config_diagnostic == self.server_config_diagnostic_without_keybindings
|
||
{
|
||
self.app.state.config_diagnostic = visible.clone();
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
|
||
let api_tx = self
|
||
.api_tx
|
||
.clone()
|
||
.ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?;
|
||
let api_server = api::start_server(api_tx, self.app.event_hub.clone())?;
|
||
|
||
let client_path = client_socket_path();
|
||
prepare_socket_path(&client_path)?;
|
||
let listener = UnixListener::bind(&client_path)?;
|
||
restrict_socket_permissions(&client_path)?;
|
||
let client_socket_identity = socket_file_identity(&client_path)?;
|
||
listener.set_nonblocking(true)?;
|
||
|
||
self.api_server = Some(api_server);
|
||
self.client_listener = listener;
|
||
self.client_socket_path = client_path;
|
||
self.client_socket_identity = client_socket_identity;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
|
||
let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2);
|
||
wait_for_old_public_sockets_to_close(timeout)?;
|
||
self.restore_public_sockets_after_failed_handoff()
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn rollback_handoff_before_commit(
|
||
&mut self,
|
||
socket_path: &Path,
|
||
paused_terminal_ids: &[crate::terminal::TerminalId],
|
||
) {
|
||
for terminal_id in paused_terminal_ids {
|
||
if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
|
||
runtime.set_handoff_reader_paused(false);
|
||
}
|
||
}
|
||
self.handoff_in_progress = false;
|
||
let _ = std::fs::remove_file(socket_path);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn nudge_handoff_panes_on_first_client_attach(&mut self) {
|
||
if !self.pending_handoff_repaint_nudge {
|
||
return;
|
||
}
|
||
self.pending_handoff_repaint_nudge = false;
|
||
self.app
|
||
.terminal_runtimes
|
||
.nudge_child_redraw_after_handoff();
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn nudge_handoff_panes_on_first_client_attach(&mut self) {}
|
||
|
||
fn reload_server_config(&mut self, notify_success: bool) -> crate::config::ConfigReloadReport {
|
||
let server_keybindings = self.server_keybindings.clone();
|
||
apply_keybindings(&mut self.app, &server_keybindings);
|
||
let report = self.app.apply_config_from_disk(notify_success);
|
||
self.app.take_config_reloaded_from_disk();
|
||
self.server_keybindings = app_keybindings(&self.app);
|
||
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
|
||
server_config_diagnostic_summaries(&report.diagnostics);
|
||
self.server_config_diagnostic = server_config_diagnostic;
|
||
self.server_config_diagnostic_without_keybindings =
|
||
server_config_diagnostic_without_keybindings;
|
||
self.sync_foreground_client_state();
|
||
report
|
||
}
|
||
|
||
fn foreground_client_outer_focus(&self) -> Option<bool> {
|
||
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 = latest_app_client(&self.clients);
|
||
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 client_removal_needs_shared_resize(&self, client_id: u64) -> bool {
|
||
if self.foreground_client_id == Some(client_id) {
|
||
return true;
|
||
}
|
||
matches!(
|
||
self.clients.get(&client_id).map(|client| &client.mode),
|
||
Some(ClientConnectionMode::TerminalAttach { .. })
|
||
) && self.foreground_client_id.is_some()
|
||
}
|
||
|
||
fn remove_client_and_resize_if_needed(&mut self, client_id: u64) {
|
||
let needs_shared_resize = self.client_removal_needs_shared_resize(client_id);
|
||
let foreground_changed = self.remove_client(client_id);
|
||
if needs_shared_resize || foreground_changed {
|
||
self.resize_shared_runtime_to_effective_size();
|
||
}
|
||
}
|
||
|
||
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::<Vec<_>>();
|
||
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;
|
||
};
|
||
|
||
if !client.update_host_theme_from_events(events) {
|
||
return false;
|
||
}
|
||
|
||
if self.foreground_client_id == Some(client_id) {
|
||
self.app.set_host_terminal_theme(client.host_terminal_theme)
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn update_client_outer_focus_from_events(
|
||
&mut self,
|
||
client_id: u64,
|
||
events: &[crate::raw_input::RawInputEvent],
|
||
) {
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
return;
|
||
};
|
||
let Some(next_focus) = client.update_outer_focus_from_events(events) else {
|
||
return;
|
||
};
|
||
if self.foreground_client_id == Some(client_id) {
|
||
self.app.state.outer_terminal_focus = Some(next_focus);
|
||
}
|
||
}
|
||
|
||
/// Accepts pending client connections from the non-blocking listener.
|
||
fn accept_client_connections(&mut self) -> io::Result<()> {
|
||
if self.handoff_in_progress {
|
||
return reject_pending_client_connections(&self.client_listener);
|
||
}
|
||
accept_pending_client_connections(
|
||
&self.client_listener,
|
||
&mut self.next_client_id,
|
||
&self.should_quit,
|
||
&self.server_event_tx,
|
||
)
|
||
}
|
||
|
||
/// Drains server events from the dedicated channel.
|
||
///
|
||
/// 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<crate::terminal::TerminalId> {
|
||
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.terminal_runtimes.get(&terminal_id)
|
||
}
|
||
|
||
fn write_client_clipboard_image(
|
||
&mut self,
|
||
client_id: u64,
|
||
extension: &str,
|
||
data: &[u8],
|
||
) -> std::io::Result<String> {
|
||
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 handle_terminal_attach_scroll(
|
||
&mut self,
|
||
client_id: u64,
|
||
source: AttachScrollSource,
|
||
direction: AttachScrollDirection,
|
||
lines: u16,
|
||
column: Option<u16>,
|
||
row: Option<u16>,
|
||
modifiers: u8,
|
||
) -> bool {
|
||
let Some(ClientConnection {
|
||
mode: ClientConnectionMode::TerminalAttach { terminal_id },
|
||
..
|
||
}) = self.clients.get(&client_id)
|
||
else {
|
||
return false;
|
||
};
|
||
let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) else {
|
||
return false;
|
||
};
|
||
|
||
if let Err(err) =
|
||
apply_terminal_attach_scroll(runtime, source, direction, lines, column, row, modifiers)
|
||
{
|
||
warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach scroll failed");
|
||
}
|
||
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 {
|
||
self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT)
|
||
.1
|
||
}
|
||
|
||
fn drain_all_internal_events_with_forwarding(&mut self) -> bool {
|
||
let mut changed = false;
|
||
loop {
|
||
let (had_event, batch_changed) =
|
||
self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT);
|
||
changed |= batch_changed;
|
||
if !had_event {
|
||
break;
|
||
}
|
||
}
|
||
changed
|
||
}
|
||
|
||
fn drain_internal_events_with_forwarding_up_to(&mut self, limit: usize) -> (bool, bool) {
|
||
let mut had_event = false;
|
||
let mut changed = false;
|
||
for _ in 0..limit {
|
||
let Ok(ev) = self.app.event_rx.try_recv() else {
|
||
break;
|
||
};
|
||
had_event = true;
|
||
changed |= self.handle_internal_event_with_forwarding(ev);
|
||
}
|
||
(had_event, 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<Vec<u8>, 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<Vec<u8>, 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<u64> = 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 {
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
/// 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"
|
||
);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) {
|
||
let client_ids = terminal_attach_client_ids(&self.clients, terminal_id);
|
||
|
||
for client_id in client_ids {
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(reason.clone()),
|
||
},
|
||
);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
fn disconnect_all_clients_for_handoff(&mut self) {
|
||
let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
|
||
for client_id in client_ids {
|
||
self.send_client_graphics_cleanup(client_id);
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(
|
||
"live update in progress; reconnect after handoff completes".to_owned(),
|
||
),
|
||
},
|
||
);
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
client.writer = None;
|
||
}
|
||
let _ = self.remove_client(client_id);
|
||
}
|
||
self.foreground_client_id = None;
|
||
self.sync_foreground_client_state();
|
||
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_and_resize_if_needed(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_and_resize_if_needed(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_and_resize_if_needed(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.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 {
|
||
if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) {
|
||
return false;
|
||
}
|
||
|
||
match ev {
|
||
ServerEvent::ClientConnected {
|
||
client_id,
|
||
cols,
|
||
rows,
|
||
cell_width_px,
|
||
cell_height_px,
|
||
keybindings,
|
||
writer,
|
||
render_encoding,
|
||
} => {
|
||
if self.handoff_in_progress {
|
||
if let Ok(message) =
|
||
Self::frame_server_message(&ServerMessage::ServerShutdown {
|
||
reason: Some(
|
||
"live update in progress; reconnect after handoff completes"
|
||
.to_owned(),
|
||
),
|
||
})
|
||
{
|
||
let _ = writer.control.send(message);
|
||
}
|
||
return false;
|
||
}
|
||
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,
|
||
keybindings,
|
||
(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();
|
||
self.nudge_handoff_panes_on_first_client_attach();
|
||
true
|
||
}
|
||
ServerEvent::ClientAttachTerminal {
|
||
client_id,
|
||
terminal_id,
|
||
takeover,
|
||
} => self.attach_terminal_client(client_id, terminal_id, takeover),
|
||
ServerEvent::ClientAttachScroll {
|
||
client_id,
|
||
source,
|
||
direction,
|
||
lines,
|
||
column,
|
||
row,
|
||
modifiers,
|
||
} => self.handle_terminal_attach_scroll(
|
||
client_id, source, direction, lines, column, row, modifiers,
|
||
),
|
||
ServerEvent::ClientInput { client_id, data } => {
|
||
if self.handoff_in_progress {
|
||
debug!(
|
||
client_id,
|
||
len = data.len(),
|
||
"ignored client input during handoff"
|
||
);
|
||
return false;
|
||
}
|
||
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) = apply_terminal_attach_input(runtime, data) {
|
||
warn!(client_id, terminal_id = %terminal_id, err = %err);
|
||
}
|
||
}
|
||
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 = 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));
|
||
if self.app.take_config_reloaded_from_disk() {
|
||
self.reload_server_config(false);
|
||
} else {
|
||
self.sync_foreground_client_state();
|
||
}
|
||
|
||
// 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");
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
true
|
||
}
|
||
ServerEvent::ClientDisconnected { client_id } => {
|
||
info!(client_id, "client disconnected");
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
fn ignore_client_event_during_handoff(ev: &ServerEvent) -> bool {
|
||
!matches!(
|
||
ev,
|
||
ServerEvent::ClientConnected { .. }
|
||
| ServerEvent::ClientDisconnected { .. }
|
||
| ServerEvent::ClientWriterDrained { .. }
|
||
| ServerEvent::QuitSignal
|
||
)
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
|
||
let response = match self.perform_live_handoff(params.clone()) {
|
||
Ok(()) => serde_json::to_string(&api::schema::SuccessResponse {
|
||
id: msg.request.id,
|
||
result: api::schema::ResponseResult::Ok {},
|
||
}),
|
||
Err(err) => serde_json::to_string(&api::schema::ErrorResponse {
|
||
id: msg.request.id,
|
||
error: api::schema::ErrorBody {
|
||
code: "handoff_failed".into(),
|
||
message: err.to_string(),
|
||
},
|
||
}),
|
||
}
|
||
.unwrap_or_else(|_| "{}".to_string());
|
||
let _ = msg.respond_to.send(response);
|
||
return true;
|
||
}
|
||
|
||
let changed = api::request_changes_ui(&msg.request);
|
||
let changed = self.drain_all_internal_events_with_forwarding() || changed;
|
||
|
||
// 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 = if matches!(
|
||
&msg.request.method,
|
||
api::schema::Method::ServerReloadConfig(_)
|
||
) {
|
||
let report = self.reload_server_config(true);
|
||
serde_json::to_string(&api::schema::SuccessResponse {
|
||
id: msg.request.id.clone(),
|
||
result: api::schema::ResponseResult::ConfigReload {
|
||
status: report.status,
|
||
diagnostics: report.diagnostics,
|
||
},
|
||
})
|
||
.unwrap_or_else(|err| {
|
||
serde_json::to_string(&api::schema::ErrorResponse {
|
||
id: String::new(),
|
||
error: api::schema::ErrorBody {
|
||
code: "serialization_error".into(),
|
||
message: err.to_string(),
|
||
},
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string())
|
||
})
|
||
} else {
|
||
self.app
|
||
.handle_api_request_after_internal_events_drained(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_from(&self.app.terminal_runtimes);
|
||
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<u64> = 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 {
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
/// Renders the current state to client-sized virtual buffers and streams
|
||
/// frames to all connected clients.
|
||
fn render_and_stream(&mut self) {
|
||
let render_targets = render_targets(&self.clients, self.foreground_client_id);
|
||
|
||
if render_targets.is_empty() {
|
||
let (cols, rows) = self.effective_size;
|
||
let area = Rect::new(0, 0, cols, rows);
|
||
let resize_panes = self.app.state.view.pane_infos.is_empty();
|
||
let _ = crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
resize_panes,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
);
|
||
debug!(
|
||
cols,
|
||
rows, resize_panes, "rendered virtual frame with no attached clients"
|
||
);
|
||
return;
|
||
}
|
||
|
||
let mut broken_clients: Vec<u64> = 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_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
is_foreground,
|
||
cell_size,
|
||
)
|
||
} else {
|
||
crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
is_foreground,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
)
|
||
};
|
||
let hyperlinks = crate::server::render_stream::visible_hyperlinks(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
);
|
||
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();
|
||
let graphics_surface_reset_pending = client.graphics_surface_reset_pending;
|
||
if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() {
|
||
if graphics_surface_reset_pending {
|
||
frame.graphics = next_graphics_cache.clear_bytes();
|
||
}
|
||
frame
|
||
.graphics
|
||
.extend(crate::kitty_graphics::encode_local_pane_graphics(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
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.graphics_surface_reset_pending = false;
|
||
}
|
||
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 {
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
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::<Vec<_>>();
|
||
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) =
|
||
remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity)
|
||
{
|
||
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::<Vec<_>>();
|
||
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<AtomicBool>, server_event_tx: mpsc::Sender<ServerEvent>) {
|
||
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<Instant>) {
|
||
match deadline {
|
||
Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
|
||
None => std::future::pending().await,
|
||
}
|
||
}
|
||
|
||
fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
|
||
let without_keybindings = diagnostics
|
||
.iter()
|
||
.filter(|diagnostic| !is_keybinding_config_diagnostic(diagnostic))
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
(
|
||
config::config_diagnostic_summary(diagnostics),
|
||
config::config_diagnostic_summary(&without_keybindings),
|
||
)
|
||
}
|
||
|
||
fn is_keybinding_config_diagnostic(diagnostic: &str) -> bool {
|
||
diagnostic.contains("keybinding") || diagnostic.contains("keys.")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Run the headless server. This is the entry point called from main.rs.
|
||
pub fn run_server() -> io::Result<()> {
|
||
init_logging();
|
||
|
||
let args: Vec<String> = std::env::args().collect();
|
||
if args.get(2).map(String::as_str) == Some("--handoff-import") {
|
||
let socket_path = args
|
||
.get(3)
|
||
.map(PathBuf::from)
|
||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?;
|
||
let token = args
|
||
.get(4)
|
||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?;
|
||
return run_handoff_import_server(&socket_path, token);
|
||
}
|
||
|
||
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.clone(), 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,
|
||
&loaded_config.diagnostics,
|
||
Some(api_tx.clone()),
|
||
Some(_api_server),
|
||
) {
|
||
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"
|
||
);
|
||
print_ready_message(&api::socket_path(), &client_socket_path());
|
||
|
||
server.run().await
|
||
});
|
||
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
crate::logging::shutdown("server");
|
||
result
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> {
|
||
let loaded_config = config::Config::load();
|
||
let mut received = crate::server::handoff::receive(socket_path, token)?;
|
||
crate::server::handoff::log_import_result(received.manifest.panes.len());
|
||
|
||
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||
let event_hub = api::EventHub::default();
|
||
|
||
let mut imports = HashMap::new();
|
||
for (pane, fd) in received.manifest.panes.iter().zip(received.fds) {
|
||
imports.insert(
|
||
pane.pane_id,
|
||
crate::persist::ImportedPaneRuntime {
|
||
master_fd: fd,
|
||
child_pid: pane.child_pid,
|
||
rows: pane.rows,
|
||
cols: pane.cols,
|
||
cell_width_px: pane.cell_width_px,
|
||
cell_height_px: pane.cell_height_px,
|
||
initial_history_ansi: pane.initial_history_ansi.clone(),
|
||
},
|
||
);
|
||
}
|
||
|
||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||
.enable_all()
|
||
.build()
|
||
.map_err(io::Error::other)?;
|
||
|
||
let result = rt.block_on(async {
|
||
let mut app = app::App::new_from_handoff(
|
||
&loaded_config.config,
|
||
config::config_diagnostic_summary(&loaded_config.diagnostics),
|
||
api_rx,
|
||
event_hub.clone(),
|
||
&received.manifest.snapshot,
|
||
&mut imports,
|
||
)?;
|
||
app.state.local_sound_playback = false;
|
||
app.local_terminal_notifications = false;
|
||
crate::server::handoff::report_restored(&mut received.stream)?;
|
||
if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") {
|
||
return Err(io::Error::other(
|
||
"test handoff import failure after restored",
|
||
));
|
||
}
|
||
wait_for_old_public_sockets_to_close(Duration::from_secs(5))?;
|
||
|
||
let api_server = api::start_server(api_tx.clone(), event_hub.clone())?;
|
||
let mut server = HeadlessServer::new(
|
||
app,
|
||
&loaded_config.diagnostics,
|
||
Some(api_tx.clone()),
|
||
Some(api_server),
|
||
)?;
|
||
crate::server::handoff::report_ready(&mut received.stream)?;
|
||
crate::server::handoff::wait_committed(&mut received.stream)?;
|
||
server.app.assume_handoff_ownership();
|
||
server.app.unpause_handoff_readers();
|
||
server.pending_handoff_repaint_nudge = true;
|
||
if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) {
|
||
warn!(err = %err, "failed to report handoff ownership; continuing as owner");
|
||
}
|
||
info!("handoff import server started");
|
||
print_ready_message(&api::socket_path(), &client_socket_path());
|
||
server.run().await
|
||
});
|
||
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
crate::logging::shutdown("server");
|
||
result
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> {
|
||
use std::os::unix::net::UnixStream;
|
||
|
||
let deadline = Instant::now() + timeout;
|
||
let api_socket = api::socket_path();
|
||
let client_socket = client_socket_path();
|
||
while Instant::now() < deadline {
|
||
let api_open = api_socket.exists() && UnixStream::connect(&api_socket).is_ok();
|
||
let client_open = client_socket.exists() && UnixStream::connect(&client_socket).is_ok();
|
||
if !api_open && !client_open {
|
||
return Ok(());
|
||
}
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
Err(io::Error::new(
|
||
io::ErrorKind::TimedOut,
|
||
"old server sockets did not close before handoff import bind",
|
||
))
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> {
|
||
Err(io::Error::other("live handoff is only supported on Unix"))
|
||
}
|
||
|
||
fn print_ready_message(api_socket: &Path, client_socket: &Path) {
|
||
eprintln!("herdr server running; you can use any herdr CLI command in another terminal.");
|
||
eprintln!("api socket: {}", api_socket.display());
|
||
eprintln!("client socket: {}", client_socket.display());
|
||
eprintln!(
|
||
"logs: {}",
|
||
crate::session::data_dir()
|
||
.join("herdr-server.log")
|
||
.display()
|
||
);
|
||
eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`.");
|
||
}
|
||
|
||
/// 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::app::AppState;
|
||
use crate::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");
|
||
let client_socket_identity =
|
||
socket_file_identity(&socket_path).expect("test listener socket identity");
|
||
listener
|
||
.set_nonblocking(true)
|
||
.expect("set listener nonblocking");
|
||
let (server_event_tx, server_event_rx) = mpsc::channel(64);
|
||
let server_keybindings = app_keybindings(&app);
|
||
|
||
HeadlessServer {
|
||
app,
|
||
api_tx: None,
|
||
api_server: None,
|
||
client_listener: listener,
|
||
client_socket_path: socket_path,
|
||
client_socket_identity,
|
||
clients: HashMap::new(),
|
||
next_client_id: 1,
|
||
foreground_client_id: None,
|
||
server_keybindings,
|
||
server_config_diagnostic: None,
|
||
server_config_diagnostic_without_keybindings: None,
|
||
terminal_attach_owners: HashMap::new(),
|
||
next_activity_stamp: 1,
|
||
effective_size: (MIN_COLS, MIN_ROWS),
|
||
shutting_down: false,
|
||
handoff_in_progress: false,
|
||
pending_handoff_repaint_nudge: false,
|
||
should_quit: Arc::new(AtomicBool::new(false)),
|
||
server_event_rx,
|
||
server_event_tx,
|
||
}
|
||
}
|
||
|
||
fn read_server_message(bytes: Vec<u8>) -> 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<u8>) -> FrameData {
|
||
match read_server_message(bytes) {
|
||
ServerMessage::Frame(frame) => frame,
|
||
other => panic!("expected frame, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
fn read_server_shutdown_reason(bytes: Vec<u8>) -> Option<String> {
|
||
match read_server_message(bytes) {
|
||
ServerMessage::ServerShutdown { reason } => reason,
|
||
other => panic!("expected shutdown, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn headless_api_request_drains_all_pending_internal_events_before_reading_state() {
|
||
let mut server = test_headless_server();
|
||
for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT {
|
||
server
|
||
.app
|
||
.event_tx
|
||
.try_send(AppEvent::UpdateReady {
|
||
version: format!("4.0.{i}"),
|
||
install_command: "herdr install".into(),
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
assert!(
|
||
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "headless_stop_after_events".into(),
|
||
method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()),
|
||
},
|
||
respond_to,
|
||
})
|
||
);
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||
|
||
assert_eq!(response["result"]["type"], "ok");
|
||
let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT);
|
||
assert_eq!(
|
||
server.app.state.update_available.as_deref(),
|
||
Some(expected_version.as_str())
|
||
);
|
||
assert!(server.app.event_rx.try_recv().is_err());
|
||
}
|
||
|
||
fn test_client_writer() -> (
|
||
ClientWriter,
|
||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||
) {
|
||
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 foreground_client_applies_client_keybindings() {
|
||
let mut server = test_headless_server();
|
||
let local_config: crate::config::Config = toml::from_str(
|
||
r#"
|
||
[keys]
|
||
prefix = "ctrl+a"
|
||
new_tab = "prefix+t"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let local_keybindings = local_config.live_keybinds().unwrap();
|
||
let (writer_a, _control_a, _render_a) = test_client_writer();
|
||
let (writer_b, _control_b, _render_b) = test_client_writer();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_keybindings)),
|
||
writer: writer_a,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('a')
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_tab
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+t"));
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: None,
|
||
writer: writer_b,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('b')
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_tab
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+c"));
|
||
}
|
||
|
||
#[test]
|
||
fn local_keybinding_client_hides_server_keybinding_warnings() {
|
||
let mut server = test_headless_server();
|
||
let diagnostics = vec![
|
||
"unsafe direct keybinding: keys.close_pane = \"x\" would intercept typing".to_owned(),
|
||
"theme warning".to_owned(),
|
||
];
|
||
let (full, without_keybindings) = server_config_diagnostic_summaries(&diagnostics);
|
||
server.server_config_diagnostic = full.clone();
|
||
server.server_config_diagnostic_without_keybindings = without_keybindings.clone();
|
||
server.app.state.config_diagnostic = full;
|
||
let local_keybindings = crate::config::Config::default().live_keybinds().unwrap();
|
||
let (writer_a, _control_a, _render_a) = test_client_writer();
|
||
let (writer_b, _control_b, _render_b) = test_client_writer();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_keybindings)),
|
||
writer: writer_a,
|
||
}));
|
||
assert_eq!(server.app.state.config_diagnostic, without_keybindings);
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: None,
|
||
writer: writer_b,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.config_diagnostic,
|
||
server.server_config_diagnostic
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_keybinding_client_keeps_local_keybindings_after_settings_save() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"herdr-headless-settings-{}-{}.toml",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos())
|
||
.unwrap_or(0)
|
||
));
|
||
std::fs::write(&path, "onboarding = false\n").unwrap();
|
||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||
|
||
let mut server = test_headless_server();
|
||
let local_config: crate::config::Config = toml::from_str(
|
||
r#"
|
||
[keys]
|
||
prefix = "ctrl+a"
|
||
new_workspace = "prefix+n"
|
||
next_tab = ""
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let local_keybindings = local_config.live_keybinds().unwrap();
|
||
let (writer, _control, _render) = test_client_writer();
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_keybindings)),
|
||
writer,
|
||
}));
|
||
server.app.state.mode = crate::app::Mode::Settings;
|
||
server.app.state.settings.section = crate::app::state::SettingsSection::Toast;
|
||
server.app.state.settings.list.selected = 1;
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\r".to_vec(),
|
||
}));
|
||
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('a')
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_workspace
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+n"));
|
||
assert!(server.app.state.toast.is_none());
|
||
let content = std::fs::read_to_string(&path).unwrap();
|
||
assert!(content.contains("delivery = \"herdr\""));
|
||
|
||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_server_keybindings_do_not_cache_local_keybindings_after_settings_save() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"herdr-headless-invalid-settings-{}-{}.toml",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos())
|
||
.unwrap_or(0)
|
||
));
|
||
std::fs::write(
|
||
&path,
|
||
"onboarding = false\n[keys]\nnew_workspace = \"x\"\n[ui.toast]\ndelivery = \"off\"\n",
|
||
)
|
||
.unwrap();
|
||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||
|
||
let mut server = test_headless_server();
|
||
let previous_server_config: crate::config::Config =
|
||
toml::from_str("[keys]\nprefix = \"ctrl+c\"\nnew_workspace = \"prefix+m\"\n").unwrap();
|
||
server.server_keybindings = previous_server_config.live_keybinds().unwrap();
|
||
let local_config: crate::config::Config = toml::from_str(
|
||
r#"
|
||
[keys]
|
||
prefix = "ctrl+a"
|
||
new_workspace = "prefix+n"
|
||
next_tab = ""
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let (writer_a, _control_a, _render_a) = test_client_writer();
|
||
let (writer_b, _control_b, _render_b) = test_client_writer();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_config.live_keybinds().unwrap())),
|
||
writer: writer_a,
|
||
}));
|
||
server.app.state.mode = crate::app::Mode::Settings;
|
||
server.app.state.settings.section = crate::app::state::SettingsSection::Toast;
|
||
server.app.state.settings.list.selected = 1;
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\r".to_vec(),
|
||
}));
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: None,
|
||
writer: writer_b,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('c')
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_workspace
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+m"));
|
||
|
||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
#[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,
|
||
keybindings: None,
|
||
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,
|
||
keybindings: None,
|
||
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 terminal_attach_scroll_moves_attached_runtime_viewport() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
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);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::Wheel,
|
||
AttachScrollDirection::Up,
|
||
3,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("scroll up");
|
||
let metrics = runtime.scroll_metrics().expect("scroll metrics");
|
||
assert_eq!(metrics.offset_from_bottom, 3);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::Wheel,
|
||
AttachScrollDirection::Down,
|
||
2,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("scroll down");
|
||
let metrics = runtime.scroll_metrics().expect("scroll metrics");
|
||
assert_eq!(metrics.offset_from_bottom, 1);
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_input_resets_scrolled_viewport() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = Vec::new();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let (runtime, mut input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
20, 5, 4096, &bytes, 4,
|
||
);
|
||
|
||
runtime.scroll_up(4);
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
4
|
||
);
|
||
|
||
apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input");
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
0
|
||
);
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded input"),
|
||
Bytes::from("x")
|
||
);
|
||
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_host_scrolls_plain_terminal() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = Vec::new();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let (runtime, mut input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
20, 5, 4096, &bytes, 4,
|
||
);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::PageKey {
|
||
input: b"\x1b[5~".to_vec(),
|
||
},
|
||
AttachScrollDirection::Up,
|
||
4,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("page key scroll");
|
||
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
4
|
||
);
|
||
assert!(input_rx.try_recv().is_err());
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_forwards_when_mouse_reporting() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = b"\x1b[?1000h".to_vec();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let (runtime, mut input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
20, 5, 4096, &bytes, 4,
|
||
);
|
||
runtime.scroll_up(3);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::PageKey {
|
||
input: b"\x1b[5~".to_vec(),
|
||
},
|
||
AttachScrollDirection::Up,
|
||
4,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("page key forward");
|
||
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
0
|
||
);
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded page key"),
|
||
Bytes::from_static(b"\x1b[5~")
|
||
);
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[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_exposes_hidden_pane_cursor_when_reveal_hidden_for_cjk_ime() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
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: true,
|
||
shape: state.cjk_ime_cursor_shape,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_keeps_cursor_hidden_when_scrolled_back_even_with_reveal_hidden_for_cjk_ime(
|
||
) {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
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 terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||
let runtime = state
|
||
.runtime_for_pane(&terminal_runtimes, pane_id)
|
||
.expect("pane runtime after initial render");
|
||
runtime.scroll_up(6);
|
||
assert!(crate::ui::pane_is_scrolled_back(runtime));
|
||
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
|
||
assert!(
|
||
cursor.as_ref().is_none_or(|cursor| !cursor.visible),
|
||
"scrolled-back focused pane should keep the cursor hidden even when reveal_hidden_cursor_for_cjk_ime is true; got {cursor:?}",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_fallback_cursor_when_viewport_none_and_reveal_hidden_for_cjk_ime() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
// Feed only ?25l with no prior cursor movement — exercises the fallback
|
||
// path for TUIs whose viewport has no cursor position.
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"\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,
|
||
y: pane.inner_rect.y,
|
||
visible: true,
|
||
shape: state.cjk_ime_cursor_shape,
|
||
}),
|
||
"fallback should anchor at pane top-left with the configured shape",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_skips_reveal_when_focused_pane_has_no_detected_agent() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
// Filter only Claude, but the test pane has no detected agent, so the
|
||
// reveal must not apply.
|
||
state.cjk_ime_agent_filter_configured = true;
|
||
state.cjk_ime_agents = vec![crate::detect::Agent::Claude];
|
||
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);
|
||
|
||
assert!(
|
||
cursor.as_ref().is_none_or(|cursor| !cursor.visible),
|
||
"agent filter should suppress reveal when the focused pane's detected agent is not on the list; got {cursor:?}",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_skips_reveal_when_agent_filter_has_no_valid_entries() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
state.cjk_ime_agent_filter_configured = true;
|
||
state.cjk_ime_agents = Vec::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);
|
||
|
||
assert!(
|
||
cursor.as_ref().is_none_or(|cursor| !cursor.visible),
|
||
"agent filter with no valid entries should suppress reveal; got {cursor:?}",
|
||
);
|
||
}
|
||
|
||
#[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 terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||
let runtime = state
|
||
.runtime_for_pane(&terminal_runtimes, pane_id)
|
||
.expect("pane runtime after initial render");
|
||
runtime.scroll_up(6);
|
||
assert!(crate::ui::pane_is_scrolled_back(runtime));
|
||
|
||
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(&server.app.terminal_runtimes, active_pane)
|
||
.unwrap()
|
||
.current_size(),
|
||
expected
|
||
);
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.state
|
||
.runtime_for_pane(&server.app.terminal_runtimes, background_pane)
|
||
.unwrap()
|
||
.current_size(),
|
||
expected
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_disconnect_restores_app_pane_size() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone();
|
||
let terminal_id_string = terminal_id.to_string();
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.ensure_test_terminals();
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.app.terminal_runtimes.insert(
|
||
terminal_id.clone(),
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
|
||
);
|
||
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 expected_app_size = server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size();
|
||
assert_ne!(expected_app_size, (24, 80));
|
||
|
||
let (writer, _control_rx, _render_rx) = test_client_writer();
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::TerminalAnsi,
|
||
keybindings: None,
|
||
writer,
|
||
}));
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientAttachTerminal {
|
||
client_id: 2,
|
||
terminal_id: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
assert_eq!(server.foreground_client_id, Some(1));
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size(),
|
||
(24, 80)
|
||
);
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 }));
|
||
|
||
assert!(!server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size(),
|
||
expected_app_size
|
||
);
|
||
drop(server);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[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),
|
||
agent_session_id: None,
|
||
agent_session_path: None,
|
||
}),
|
||
},
|
||
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<String> = 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 ")
|
||
);
|
||
}
|
||
}
|