Files
herdr/src/server/headless.rs
T

3435 lines
135 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, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use interprocess::local_socket::traits::Listener as _;
#[cfg(windows)]
use interprocess::local_socket::traits::Stream as _;
#[cfg(unix)]
use interprocess::local_socket::ListenerNonblockingMode;
use ratatui::layout::Rect;
use tokio::sync::mpsc;
#[cfg(windows)]
use tracing::error;
use tracing::{debug, info, warn};
use base64::Engine;
use bytes::Bytes;
use crate::api;
use crate::app;
use crate::config;
use crate::events::AppEvent;
use crate::ipc::{
bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalListener,
SocketFileIdentity,
};
use crate::protocol::{
self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE,
};
#[cfg(unix)]
use crate::server::client_accept::{
accept_pending_client_connections, reject_pending_client_connections,
};
use crate::server::client_shell::{
render_pane_surface as render_client_shell_pane_surface,
snapshot_with_completions as client_shell_snapshot,
};
use crate::server::client_transport::ServerEvent;
use crate::server::clients::{
latest_shell_client, render_targets, terminal_stream_client_ids, ClientConnection,
ClientConnectionMode, ClientShellInputTarget, DeferredRender,
};
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::pane_input::{
apply_client_pane_input_events, apply_client_popup_input_events, apply_terminal_attach_input,
apply_terminal_attach_scroll, terminal_attach_mouse_position,
};
use crate::server::shutdown::{ServerStop, ShutdownReason};
use crate::server::socket_paths::{
client_socket_path, prepare_socket_path, restrict_socket_permissions,
};
use crate::server::terminal_attach::paste_payload_for_runtime;
mod bootstrap;
mod client_views;
mod endpoint_requests;
mod lifecycle;
mod native_graphics;
mod notifications;
mod render;
mod retained_surface;
mod surface_interest;
// Producers can refill even a bounded channel while it is being drained.
// Yield to scheduled work and rendering between batches; select! below
// immediately wakes for any messages left in either external queue.
const EXTERNAL_EVENT_DRAIN_LIMIT: usize = 64;
pub use bootstrap::run_server;
use lifecycle::wait_for_live_handoff_response_write;
#[cfg(unix)]
use lifecycle::wait_for_old_public_sockets_to_close;
use crate::protocol::MAX_GRAPHICS_FRAME_SIZE;
#[cfg(test)]
use crate::protocol::RenderEncoding;
#[cfg(test)]
use crate::server::client_transport::ClientWriter;
#[cfg(test)]
use std::fs;
fn sound_notify_message(sound: crate::sound::Sound) -> &'static str {
match sound {
crate::sound::Sound::Done => "agent done",
crate::sound::Sound::Request => "agent attention",
}
}
fn notification_show_result(
id: String,
shown: bool,
reason: api::schema::NotificationShowReason,
) -> String {
serde_json::to_string(&api::schema::SuccessResponse {
id,
result: api::schema::ResponseResult::NotificationShow { shown, reason },
})
.unwrap_or_else(|_| "{}".to_string())
}
fn non_empty_body(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_owned())
}
// ---------------------------------------------------------------------------
// Loop event enum for the headless server event loop
// ---------------------------------------------------------------------------
/// Events that the headless server event loop can process.
enum LoopEvent {
Timer,
Internal(AppEvent),
Api(Box<api::ApiRequestMessage>),
ServerEvent(ServerEvent),
RenderRequested,
}
/// Presentation work caused by a server event.
///
/// Keeping this classification separate from event delivery makes the input
/// contract explicit: events may reach a PTY without necessarily repainting.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RenderImpact {
None,
Full,
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// 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 local listener 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
// ---------------------------------------------------------------------------
struct AltScreenReadSpec {
terminal_id: crate::terminal::TerminalId,
lines: usize,
unwrap: bool,
initial: crate::terminal::ScreenSnapshot,
content_seq: u64,
}
enum AltScreenReadConflict {
None,
Frozen(crate::pane::TerminalReadSnapshot),
Defer,
}
/// The headless server — runs the herdr event loop without a real terminal.
pub struct HeadlessServer {
app: app::App,
#[cfg(unix)]
api_tx: Option<api::ApiRequestSender>,
// Kept on every platform so dropping HeadlessServer owns API server shutdown.
#[cfg_attr(windows, allow(dead_code))]
api_server: Option<api::ServerHandle>,
#[cfg(unix)]
client_listener: LocalListener,
client_socket_path: PathBuf,
client_socket_identity: SocketFileIdentity,
clients: HashMap<u64, ClientConnection>,
native_graphics: native_graphics::NativeGraphics,
#[cfg(unix)]
next_client_id: u64,
/// The client currently driving session-wide host presentation and side effects.
foreground_client_id: Option<u64>,
/// Ephemeral shell connection controlling PTY geometry for each stable tab id.
tab_geometry_controllers: HashMap<String, u64>,
/// Stable tab id whose viewers may see and interact with the one terminal popup.
popup_owner_tab_id: Option<String>,
/// Process-local identity used to reject shell replacements from an earlier server boot.
client_shell_boot_id: String,
/// Outer window title last pushed, paired with the client that received it.
/// Keying on the client means a newly attached terminal is written to even
/// when the title itself has not changed, without every code path that
/// changes the foreground client having to remember to invalidate this.
sent_window_title: Option<(u64, Option<String>)>,
/// Window title set through `client.window_title.set`. While present it wins
/// over the configured `ui.window_title` until the API clears it again.
api_window_title: Option<String>,
/// 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>,
/// Deferred application-history reads currently driving alternate-screen viewports.
pending_alt_screen_reads: Vec<crate::server::alt_screen_read::PendingAltScreenRead>,
/// Reads waiting for an alternate-screen traversal of the same terminal to finish.
deferred_alt_screen_reads: Vec<api::ApiRequestMessage>,
/// Monotonic activity counter used to pick the most recently active client.
next_activity_stamp: u64,
/// Configured virtual terminal size used when no clients are connected.
headless_size: (u16, u16),
/// Shared pane runtime size derived from the foreground client, or the
/// configured headless size 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.
#[cfg(unix)]
pending_handoff_repaint_nudge: bool,
/// Flag set by a stop signal or `server stop`; shares `server_stop`'s flag.
should_quit: Arc<AtomicBool>,
server_stop: ServerStop,
host_shutdown_requested: Arc<AtomicBool>,
#[cfg(test)]
host_shutdown_probe: fn() -> bool,
/// 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>,
}
#[cfg(windows)]
fn spawn_windows_client_accept_thread(
listener: LocalListener,
should_quit: Arc<AtomicBool>,
server_event_tx: mpsc::Sender<ServerEvent>,
) -> io::Result<std::thread::JoinHandle<()>> {
crate::thread_spawn::spawn_named("herdr-client-accept", move || {
let mut next_client_id = 1_u64;
while !should_quit.load(Ordering::Acquire) {
let stream = match listener.accept() {
Ok(stream) => stream,
Err(err) => {
if should_quit.load(Ordering::Acquire) {
break;
}
error!(err = %err, "client listener accept failed");
std::thread::sleep(Duration::from_millis(50));
continue;
}
};
let client_id = next_client_id;
next_client_id = next_client_id.saturating_add(1);
if let Err(err) = stream.set_nonblocking(true) {
warn!(err = %err, "failed to set client stream nonblocking");
continue;
}
let should_quit = should_quit.clone();
let server_event_tx = server_event_tx.clone();
let spawned = crate::thread_spawn::spawn_named("herdr-client-conn", move || {
if let Err(err) = crate::server::client_transport::handle_client_handshake(
stream,
client_id,
&server_event_tx,
&should_quit,
) {
debug!(client_id, err = %err, "client handshake failed");
}
});
if let Err(err) = spawned {
warn!(client_id, err = %err, "failed to spawn client connection thread; dropping connection");
}
}
})
}
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>,
server_stop: ServerStop,
) -> io::Result<Self> {
let should_quit = server_stop.flag().clone();
let client_path = client_socket_path();
prepare_socket_path(&client_path)?;
let listener = bind_local_listener(&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 Unix so we can poll it from the event loop.
#[cfg(unix)]
listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
// Channel for server events from client threads.
let (server_event_tx, server_event_rx) = mpsc::channel(64);
#[cfg(windows)]
spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone())?;
let server_keybindings = app_keybindings(&app);
let headless_size = app.state.headless_size;
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
server_config_diagnostic_summaries(config_diagnostics);
#[cfg(not(unix))]
let _ = api_tx;
Ok(Self {
app,
#[cfg(unix)]
api_tx,
api_server,
#[cfg(unix)]
client_listener: listener,
client_socket_path: client_path,
client_socket_identity,
clients: HashMap::new(),
native_graphics: Default::default(),
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
tab_geometry_controllers: HashMap::new(),
popup_owner_tab_id: None,
client_shell_boot_id: format!(
"{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
),
sent_window_title: None,
api_window_title: None,
server_keybindings,
server_config_diagnostic,
server_config_diagnostic_without_keybindings,
terminal_attach_owners: HashMap::new(),
pending_alt_screen_reads: Vec::new(),
deferred_alt_screen_reads: Vec::new(),
next_activity_stamp: 1,
headless_size,
effective_size: headless_size,
shutting_down: false,
host_shutdown_requested: Arc::new(AtomicBool::new(false)),
#[cfg(test)]
host_shutdown_probe: crate::platform::host_shutdown_in_progress,
handoff_in_progress: false,
#[cfg(unix)]
pending_handoff_repaint_nudge: false,
should_quit,
server_stop,
server_event_rx,
server_event_tx,
})
}
/// Runs the headless server event loop until shutdown.
///
/// This is the server's main runtime loop. 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 (session save, metadata expiry, etc.)
/// - Renders virtually and streams frames to clients
pub async fn run(&mut self) -> io::Result<()> {
crate::logging::startup("server");
let server_stop = self.server_stop.clone();
let quit_notify = self.server_event_tx.clone();
crate::platform::spawn_server_signal_monitor(move |signal| {
server_stop.request(ShutdownReason::Signal(signal));
let _ = quit_notify.try_send(ServerEvent::QuitSignal);
});
let quit_notify = self.server_event_tx.clone();
let _host_shutdown = crate::platform::HostShutdownMonitor::start(
self.host_shutdown_requested.clone(),
move || {
let _ = quit_notify.try_send(ServerEvent::QuitSignal);
},
);
let mut needs_render = true;
let mut needs_full_render = true;
let mut needs_graphics_render = false;
loop {
crate::render_prof::event("loop.tick");
crate::render_prof::flush_if_due();
self.app.reap_finished_detached_processes();
// If shutdown has been initiated, complete it and exit.
if self.shutting_down {
self.complete_shutdown().await?;
break;
}
// Preserve the session before applying shutdown-time process exits.
// On Linux, logind's delay lock stays held until the final session save.
if self.host_shutdown_requested() {
self.initiate_shutdown();
continue;
}
// Check if we should start shutting down.
if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) {
self.drain_internal_events_with_forwarding_up_to(
crate::app::APP_EVENT_CHANNEL_CAPACITY,
);
self.initiate_shutdown();
continue;
}
// 1. Check the coalesced render signal from PTY readers and generic runtime work.
if self.app.render_dirty.is_pending() {
needs_render = true;
crate::render_prof::event("render.request.signal");
}
// 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;
needs_full_render = true;
needs_graphics_render = false;
crate::render_prof::event("full_render_cause.internal_events");
}
if self.should_quit.load(Ordering::Acquire) {
continue;
}
if self.app.expire_due_metadata(Instant::now()) {
needs_render = true;
needs_full_render = true;
crate::render_prof::event("full_render_cause.metadata_expiry");
}
// 3. Drain API requests.
if self.drain_api_requests_with_shutdown_check() {
needs_render = true;
needs_full_render = true;
crate::render_prof::event("full_render_cause.api_requests");
}
if self.should_quit.load(Ordering::Acquire) {
continue;
}
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;
needs_full_render = true;
crate::render_prof::event("full_render_cause.server_events");
}
if self.should_quit.load(Ordering::Acquire) {
continue;
}
// 6. Handle scheduled tasks.
let now = Instant::now();
if self.handle_scheduled_tasks_headless(now, needs_render) {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
crate::render_prof::event("full_render_cause.scheduled_tasks");
}
self.poll_pending_alt_screen_reads(now);
if self.process_deferred_alt_screen_reads() {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
if latest_shell_client(&self.clients).is_some() && self.app.ensure_default_workspace() {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
crate::render_prof::event("full_render_cause.default_workspace");
}
if self.expire_native_graphics(now) {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
self.drain_client_config_reload_request();
self.sync_immediate_pty_sources();
self.stream_host_mouse_capture_mode();
self.stream_direct_terminal_keyboard_mode();
// 7. Render virtually and stream frames. Hidden-only PTY work keeps a
// bounded classification cadence without delaying presentation work
// that joins the same coalesced request.
let render_cadence_due = self.app.can_render_now(now);
if needs_render
&& (render_cadence_due
|| (self.app.can_present_now(now)
&& self.has_pending_presentation_work(
needs_full_render,
needs_graphics_render,
)))
{
crate::render_prof::event("render.attempt");
let render_request = self.app.render_dirty.take();
let pty_dirty = !render_request.pty_sources.is_empty();
if pty_dirty {
crate::render_prof::event("render.attempt.pty_dirty");
crate::render_prof::counter(
"render.attempt.pty_sources",
render_request.pty_sources.len() as u64,
);
}
if render_request.generic {
needs_full_render = true;
crate::render_prof::event("full_render_cause.generic_dirty");
}
let (sidebar_title_changed, outer_title_synced) =
self.sync_terminal_title_sources(&render_request.terminal_title_sources);
if sidebar_title_changed {
needs_full_render = true;
crate::render_prof::event("full_render_cause.terminal_title_sidebar");
}
if needs_full_render && !outer_title_synced {
self.sync_window_title();
}
if !needs_full_render && !needs_graphics_render && !pty_dirty {
// A synchronized-output OSC title can be the only pending work.
// Its deferred PTY repaint has its own signal; do not manufacture
// a full UI render for this client-local side effect.
needs_render = false;
continue;
}
let hidden_only = pty_dirty
&& !needs_full_render
&& !needs_graphics_render
&& !self.pty_sources_visible_to_any_render_target(&render_request.pty_sources);
if hidden_only {
crate::render_prof::event("render.skipped.hidden_sources");
} else if !needs_full_render
&& !needs_graphics_render
&& self.render_retained_pane_surface_and_stream(&render_request.pty_sources)
{
crate::render_prof::event("retained_surface.invoke");
} else {
crate::render_prof::event("full_render.invoke");
self.render_and_stream();
}
self.app.record_render_attempt(now, !hidden_only);
needs_render = false;
needs_full_render = false;
needs_graphics_render = false;
continue;
}
// 8. Wait for next event.
let next_deadline = self
.app
.next_headless_loop_deadline_with_git_refresh(
now,
needs_render,
self.git_refresh_scheduled(),
)
.map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL))
.or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL));
let next_deadline = self
.pending_alt_screen_reads
.iter()
.map(|pending| pending.next_deadline())
.fold(next_deadline, |deadline, pending| {
Some(deadline.map_or(pending, |current| current.min(pending)))
});
let event = {
tokio::select! {
maybe_api = self.app.api_rx.recv() => match maybe_api {
Some(msg) => LoopEvent::Api(Box::new(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,
}
};
if self.should_quit.load(Ordering::Acquire) || self.host_shutdown_requested() {
match event {
LoopEvent::Internal(ev) => {
self.handle_internal_event_with_forwarding(ev);
}
LoopEvent::ServerEvent(
ServerEvent::ClientConnected { writer, .. }
| ServerEvent::ClientShellConnected { writer, .. },
) => {
if let Ok(message) =
Self::frame_server_message(&ServerMessage::ServerShutdown {
reason: Some("server is shutting down".to_owned()),
})
{
let _ = writer.control.send(message);
}
}
_ => {}
}
continue;
}
match event {
LoopEvent::Timer => {}
LoopEvent::Internal(ev) => {
if self.handle_internal_event_with_forwarding(ev) {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
}
LoopEvent::Api(msg) => {
if self.handle_api_request_with_shutdown_check(*msg) {
needs_render = true;
needs_full_render = true;
}
}
LoopEvent::ServerEvent(ev) => {
if self.handle_server_event_with_render_impact(ev) == RenderImpact::Full {
needs_render = true;
needs_full_render = true;
}
}
LoopEvent::RenderRequested => {
if self.app.render_dirty.is_pending() {
needs_render = true;
}
}
}
}
// Save session on exit.
if self.app.policy.persist_session {
self.app.save_session_on_shutdown();
}
info!("headless server exiting");
Ok(())
}
fn host_shutdown_requested(&self) -> bool {
if self.host_shutdown_requested.load(Ordering::Acquire) {
return true;
}
#[cfg(not(test))]
let in_progress = crate::platform::host_shutdown_in_progress();
#[cfg(test)]
let in_progress = (self.host_shutdown_probe)();
if in_progress {
self.host_shutdown_requested.store(true, Ordering::Release);
// Stop this tick before autosave or another API request can run.
self.should_quit.store(true, Ordering::Release);
}
in_progress
}
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
}
#[cfg(unix)]
fn resize_shared_runtime_to_effective_size(&mut self) {
self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true);
}
fn resize_shared_runtime_to_effective_size_before_input(&mut self) {
self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(false);
}
fn resize_shared_runtime_to_effective_size_with_pending_agent_resumes(
&mut self,
start_pending_agent_resumes: bool,
) {
let Some(client_id) = self.foreground_client_id else {
return;
};
let Some(client) = self.clients.get(&client_id) else {
return;
};
if matches!(client.mode, ClientConnectionMode::ClientShell) {
self.resize_shell_tab_if_controller(client_id, start_pending_agent_resumes);
return;
}
let cell_size = client.cell_size;
let (cols, rows) = self.effective_size;
let area = Rect::new(0, 0, cols, rows);
if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
crate::ui::compute_view_with_cell_size(
&mut self.app.state,
&self.app.terminal_runtimes,
area,
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_recompute();
}
if !start_pending_agent_resumes {
self.app.pending_agent_resume_deadline = None;
return;
}
let now = Instant::now();
self.app.sync_pending_agent_resume_deadline(now);
if self
.app
.start_pending_agent_resumes(now, self.app.pending_agent_resume_due(now))
{
for client in self.clients.values_mut() {
client.request_recompute();
}
}
}
fn sync_runtime_view_geometry(&mut self) {
crate::ui::compute_view_without_resizing_panes(
&mut self.app.state,
&self.app.terminal_runtimes,
Rect::new(0, 0, self.effective_size.0, self.effective_size.1),
);
}
fn sync_foreground_client_state(&mut self) {
self.app.pixel_mouse_available = self.foreground_client_id.is_some_and(|id| {
self.clients
.get(&id)
.is_some_and(|client| client.pixel_mouse)
});
let Some(client_id) = self.foreground_client_id else {
self.effective_size = self.headless_size;
self.app.state.outer_terminal_focus = None;
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
self.sync_runtime_view_geometry();
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 = self.headless_size;
self.app.state.outer_terminal_focus = None;
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
self.sync_runtime_view_geometry();
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 host_cell_size = if self.app.state.kitty_graphics_enabled && client.cell_size.is_known()
{
client.cell_size
} else {
crate::kitty_graphics::HostCellSize::default()
};
let host_terminal_theme = client.host_terminal_theme;
let host_terminal_appearance = client.host_terminal_appearance;
let host_terminal_appearance_explicit = client.host_terminal_appearance_explicit;
let outer_terminal_focus = client.outer_terminal_focus;
self.effective_size = terminal_size;
self.sync_runtime_view_geometry();
self.app.state.outer_terminal_focus = outer_terminal_focus;
self.app.state.host_cell_size = host_cell_size;
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
self.sync_visible_server_config_diagnostic(false);
if outer_terminal_focus == Some(true) {
self.app.state.mark_active_tab_seen();
}
self.app.set_host_terminal_appearance_state(
host_terminal_appearance,
host_terminal_appearance_explicit,
);
self.app.set_host_terminal_theme(host_terminal_theme);
}
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();
}
}
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);
self.headless_size = self.app.state.headless_size;
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;
};
if !client.shell_surface_active {
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_shell_client(&self.clients);
let changed = next_foreground != self.foreground_client_id;
self.foreground_client_id = next_foreground;
self.sync_foreground_client_state();
changed
}
fn app_client_count(&self) -> usize {
self.clients
.values()
.filter(|client| client.is_active_shell_client() && client.writer.is_some())
.count()
}
fn client_supports_direct_graphics(&self, client_id: u64) -> bool {
self.clients.get(&client_id).is_some_and(|client| {
client.is_active_shell_client()
&& client.writer.is_some()
&& client.direct_graphics
&& client.pixel_mouse
})
}
fn has_app_client(&self) -> bool {
self.app_client_count() > 0
}
/// Periodic Git refresh follows attached clients. A refresh whose worker
/// failed to start still retries without one, so restored metadata settles.
fn git_refresh_scheduled(&self) -> bool {
self.has_app_client() || self.app.git_refresh_spawn_retry_pending
}
fn remove_client(&mut self, client_id: u64) -> bool {
self.disconnect_native_graphics(client_id);
let disconnected_focus = self
.clients
.get(&client_id)
.filter(|client| {
client.is_active_shell_client() && client.outer_terminal_focus == Some(true)
})
.and_then(|_| self.shell_focus_target(client_id));
let should_release_focus = disconnected_focus.as_ref().is_some_and(|target| {
!self.clients.iter().any(|(&other_id, client)| {
other_id != client_id
&& client.is_active_shell_client()
&& client.outer_terminal_focus == Some(true)
&& self.shell_tab_id_for_client(other_id).as_deref()
== Some(target.tab_id.as_str())
})
});
let was_foreground = self.foreground_client_id == Some(client_id);
let removed = self.clients.remove(&client_id);
self.tab_geometry_controllers
.retain(|_, controller_id| *controller_id != client_id);
if let Some(mut removed) = removed {
let held_inputs = removed.drain_shell_held_inputs();
self.release_client_shell_inputs(client_id, held_inputs);
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 should_release_focus {
if let Some(target) = disconnected_focus.as_ref() {
self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost);
}
}
if was_foreground {
self.promote_latest_remaining_client()
} else {
false
}
}
fn release_client_shell_inputs(
&mut self,
client_id: u64,
held_inputs: Vec<crate::server::clients::ClientShellHeldInput>,
) {
for held in held_inputs {
let result = match held.target {
ClientShellInputTarget::Pane(pane_id) => {
let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id)
else {
continue;
};
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
workspace_index,
runtime_pane_id,
) else {
continue;
};
apply_client_pane_input_events(runtime, &[held.release])
}
ClientShellInputTarget::Popup(terminal_id) => {
let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
continue;
};
apply_client_popup_input_events(runtime, &[held.release])
}
};
if let Err(err) = result {
warn!(client_id, err = %err, "client shell teardown release failed");
}
}
}
fn remove_client_and_resize_if_needed(&mut self, client_id: u64) {
let restore_shell_controller = self.clients.get(&client_id).and_then(|client| {
let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else {
return None;
};
self.shell_geometry_controller_for_terminal(terminal_id)
});
self.remove_client(client_id);
if let Some((controller_id, target)) = restore_shell_controller {
self.restore_shell_tab_geometry(controller_id, target);
} else {
self.resize_tabs_for_only_shell_client(true);
}
}
/// Accepts pending client connections from the non-blocking listener.
#[cfg(unix)]
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,
)
}
/// Windows named-pipe clients can block in connect unless the server has a
/// pending blocking accept. The dedicated accept thread handles that path.
#[cfg(windows)]
fn accept_client_connections(&mut self) -> io::Result<()> {
Ok(())
}
/// Drains server events from the dedicated channel.
fn drain_server_events(&mut self) -> bool {
let mut changed = false;
for _ in 0..EXTERNAL_EVENT_DRAIN_LIMIT {
if self.should_quit.load(Ordering::Acquire) {
break;
}
let Ok(ev) = self.server_event_rx.try_recv() else {
break;
};
changed |= self.handle_server_event_with_render_impact(ev) == RenderImpact::Full;
}
changed
}
async fn reject_late_client_connections(&mut self) {
self.server_event_rx.close();
while let Some(event) = self.server_event_rx.recv().await {
if let ServerEvent::ClientConnected { writer, .. }
| ServerEvent::ClientShellConnected { writer, .. } = event
{
if let Ok(message) = Self::frame_server_message(&ServerMessage::ServerShutdown {
reason: Some("server is shutting down".to_owned()),
}) {
let _ = writer.control.send(message);
}
}
}
}
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 resolve_terminal_target_id_string(&self, target: &str) -> Option<String> {
if self.terminal_id_by_string(target).is_some() {
return Some(target.to_owned());
}
self.app
.resolve_terminal_target(target)
.ok()
.map(|resolved| resolved.terminal_id)
}
fn client_clipboard_image_target_is_valid(
&self,
client_id: u64,
target: &protocol::ClientClipboardImageTarget,
) -> bool {
match target {
protocol::ClientClipboardImageTarget::DirectTerminal => {
self.clients.get(&client_id).is_some_and(|client| {
matches!(client.mode, ClientConnectionMode::TerminalAttach { .. })
})
}
protocol::ClientClipboardImageTarget::Pane(pane_id) => {
!self.handoff_in_progress
&& self.app.state.popup_pane.is_none()
&& self
.clients
.get(&client_id)
.is_some_and(ClientConnection::is_active_shell_client)
&& self.app.parse_pane_id(pane_id).is_some()
}
protocol::ClientClipboardImageTarget::Popup(terminal_id) => {
!self.handoff_in_progress
&& self
.clients
.get(&client_id)
.is_some_and(ClientConnection::is_active_shell_client)
&& self
.app
.state
.popup_pane
.as_ref()
.is_some_and(|popup| popup.terminal_id.as_str() == terminal_id)
}
}
}
fn stage_client_clipboard_image(
&self,
client_id: u64,
extension: &str,
data: &[u8],
) -> std::io::Result<crate::server::clipboard_image::StagedClipboardImage> {
let staged = crate::server::clipboard_image::stage(client_id, extension, data)?;
info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image");
Ok(staged)
}
fn paste_client_clipboard_image_path(
&mut self,
client_id: u64,
target: protocol::ClientClipboardImageTarget,
path: String,
) -> bool {
match target {
protocol::ClientClipboardImageTarget::DirectTerminal => {
let Some(ClientConnection {
mode: ClientConnectionMode::TerminalAttach { terminal_id },
..
}) = self.clients.get(&client_id)
else {
return false;
};
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");
}
}
true
}
protocol::ClientClipboardImageTarget::Pane(pane_id) => {
if self.handoff_in_progress
|| !self
.clients
.get(&client_id)
.is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id)
else {
return false;
};
let popup_blocks_input = self.app.state.popup_pane.is_some()
&& self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id);
if popup_blocks_input
|| !self.shell_client_views_pane(client_id, workspace_index, runtime_pane_id)
{
return false;
}
let foreground_changed = self.promote_client_to_foreground(client_id);
let geometry_changed = self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
workspace_index,
runtime_pane_id,
) else {
return foreground_changed | geometry_changed;
};
if let Err(err) = apply_client_pane_input_events(
runtime,
&[protocol::ClientPaneInputEvent::Paste(path)],
) {
warn!(client_id, pane_id, err = %err, "client shell clipboard image paste failed");
}
true
}
protocol::ClientClipboardImageTarget::Popup(terminal_id) => {
if self.handoff_in_progress
|| !self
.clients
.get(&client_id)
.is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
let Some(popup_terminal_id) = self
.app
.state
.popup_pane
.as_ref()
.map(|popup| popup.terminal_id.clone())
else {
return false;
};
if popup_terminal_id.as_str() != terminal_id
|| self.popup_owner_tab_id != self.shell_tab_id_for_client(client_id)
{
return false;
}
let foreground_changed = self.promote_client_to_foreground(client_id);
let geometry_changed = self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
return foreground_changed | geometry_changed;
};
if let Err(err) = apply_client_popup_input_events(
runtime,
&[protocol::ClientPaneInputEvent::Paste(path)],
) {
warn!(client_id, terminal_id, err = %err, "client shell popup clipboard image paste failed");
}
true
}
}
}
fn resolve_terminal_session_target(
&mut self,
client_id: u64,
target: &str,
action: &str,
) -> Option<String> {
if !self.client_is_pending_terminal_mode(client_id) {
self.send_to_client(
client_id,
ServerMessage::ServerShutdown {
reason: Some(
format!(
"terminal session {action} failed: connection is not pending terminal session"
),
),
},
);
self.remove_client_and_resize_if_needed(client_id);
return None;
}
let Some(terminal_id) = self.resolve_terminal_target_id_string(target) else {
self.send_to_client(
client_id,
ServerMessage::ServerShutdown {
reason: Some(format!(
"terminal session {action} failed: terminal target {target} not found"
)),
},
);
self.remove_client_and_resize_if_needed(client_id);
return None;
};
Some(terminal_id)
}
fn observe_terminal_client(&mut self, client_id: u64, target: String) -> bool {
let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "observe")
else {
return false;
};
let stamp = self.allocate_activity_stamp();
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
let (cols, rows) = client.terminal_size;
client.mode = ClientConnectionMode::TerminalObserve {
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 observe client connected");
true
}
fn control_terminal_client(&mut self, client_id: u64, target: String, takeover: bool) -> bool {
let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "control")
else {
return false;
};
self.attach_terminal_client(client_id, terminal_id, takeover)
}
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 handle_terminal_attach_mouse(
&mut self,
client_id: u64,
kind: protocol::ClientMouseKind,
position: protocol::ClientMousePosition,
geometry: Option<protocol::ClientMouseGeometry>,
modifiers: u8,
lines: u16,
) -> bool {
if self.handoff_in_progress {
return false;
}
let Some(client) = self.clients.get(&client_id) else {
return false;
};
let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else {
return false;
};
let terminal_id = terminal_id.clone();
let terminal_size = client.terminal_size;
let cell_size = client.cell_size;
let pixel_mouse = client.pixel_mouse;
let host_sgr_pixels_active = client.host_sgr_pixels_active == Some(true);
let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
return false;
};
let Some(position) = terminal_attach_mouse_position(
runtime,
terminal_size,
cell_size,
pixel_mouse,
host_sgr_pixels_active,
position,
geometry,
) else {
return false;
};
let event = protocol::ClientPaneInputEvent::Mouse {
kind,
position,
geometry: None,
modifiers,
lines: lines.max(1),
};
if let Err(err) = apply_client_pane_input_events(runtime, &[event]) {
warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach mouse input failed");
}
true
}
/// Pulls only titles reported dirty by the PTY parser. A focused pane title
/// is forwarded as an independent client side effect; only sidebar title
/// tokens require a UI render.
fn sync_terminal_title_sources(
&mut self,
sources: &HashSet<crate::layout::PaneId>,
) -> (bool, bool) {
let focused_source = self
.foreground_window_title_target()
.or_else(|| self.default_shell_target())
.and_then(|target| {
self.app
.state
.workspaces
.get(target.workspace_index)?
.tabs
.get(target.tab_index)
})
.map(|tab| tab.layout.focused())
.is_some_and(|pane_id| sources.contains(&pane_id));
let changes = self.app.sync_terminal_titles(sources);
let outer_title_synced = focused_source && self.app.window_title_uses_terminal_title();
if outer_title_synced {
self.sync_window_title();
}
(
self.app.terminal_title_sidebar_changed(&changes),
outer_title_synced,
)
}
fn foreground_window_title_target(&self) -> Option<crate::ui::TabSurfaceTarget> {
self.foreground_client_id
.filter(|client_id| {
self.clients
.get(client_id)
.is_some_and(|client| client.is_active_shell_client())
})
.and_then(|client_id| self.shell_target_for_client(client_id))
}
/// Renders `ui.window_title` against the foreground client view. `None` means
/// window titles are disabled or every token resolved empty, which leaves
/// the client on Herdr's default title.
fn configured_window_title(&self) -> Option<String> {
self.foreground_window_title_target()
.map_or_else(
|| self.app.window_title(),
|target| {
self.app
.window_title_for(target.workspace_index, target.tab_index)
},
)
.and_then(|title| crate::config::sanitize_window_title_text(&title))
}
/// Pushes the configured outer window title to the foreground client when it
/// changed. Herdr consumes each pane's own `OSC 0`/`OSC 2`, so without this
/// the host terminal title never follows the session — which is what window
/// managers read for tab and group bar labels.
fn sync_window_title(&mut self) {
let title = match &self.api_window_title {
Some(title) => Some(title.clone()),
None if self.app.window_title_configured() => self.configured_window_title(),
None => return,
};
if let (Some(client_id), Some((sent_client_id, sent_title))) =
(self.foreground_client_id, self.sent_window_title.as_ref())
{
if *sent_client_id == client_id && *sent_title == title {
return;
}
}
self.send_window_title(title);
}
/// Sends a window title and remembers it only when a foreground client took
/// it, so the next client to attach is written to rather than skipped.
fn send_window_title(&mut self, title: Option<String>) -> bool {
let Some(client_id) = self.foreground_client_id else {
self.sent_window_title = None;
return false;
};
// A detached client keeps its entry with no writer, and a targeted send
// to one reports success without queuing anything. Caching the title
// against that client would skip the send once it attaches again.
if self
.clients
.get(&client_id)
.is_none_or(|client| client.writer.is_none())
{
self.sent_window_title = None;
return false;
}
let sent = self.send_to_client(
client_id,
ServerMessage::WindowTitle {
title: title.clone(),
},
);
self.sent_window_title = sent.then_some((client_id, title));
sent
}
fn handle_client_window_title_api(&mut self, id: String, title: Option<String>) -> String {
use api::schema::{ClientWindowTitleReason, ResponseResult};
let title = match title {
Some(title) => match crate::config::sanitize_window_title_text(&title) {
Some(title) => Some(title),
None => {
return serde_json::to_string(&api::schema::ErrorResponse {
id,
error: api::schema::ErrorBody {
code: "invalid_params".into(),
message: "window title is empty".into(),
},
})
.unwrap_or_else(|_| "{}".to_string());
}
},
None => None,
};
let set_title = title.is_some();
// An explicit title suppresses `ui.window_title` until it is cleared,
// and clearing restores the configured title rather than only "herdr".
self.api_window_title = title.clone();
let title = title.or_else(|| self.configured_window_title());
let changed = self.send_window_title(title);
let reason = match (changed, set_title) {
(true, true) => ClientWindowTitleReason::Set,
(true, false) => ClientWindowTitleReason::Cleared,
(false, _) => ClientWindowTitleReason::NoForegroundClient,
};
serde_json::to_string(&api::schema::SuccessResponse {
id,
result: ResponseResult::ClientWindowTitle { changed, reason },
})
.unwrap_or_else(|_| "{}".to_string())
}
fn drain_client_config_reload_request(&mut self) {
if !self.app.state.request_client_config_reload {
return;
}
self.app.state.request_client_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 an ephemeral semantic event to every connected client-rendered shell.
fn send_to_client_shells(&mut self, msg: ServerMessage) -> bool {
let serialized = match Self::frame_server_message(&msg) {
Ok(framed) => framed,
Err(err) => {
warn!(err = %err, "failed to serialize message for client shells");
return false;
}
};
let client_ids = self
.clients
.iter()
.filter_map(|(&client_id, client)| {
matches!(client.mode, ClientConnectionMode::ClientShell).then_some(client_id)
})
.collect::<Vec<_>>();
let mut sent = false;
for client_id in client_ids {
let Some(client) = self.clients.get(&client_id) else {
continue;
};
let Some(writer) = &client.writer else {
continue;
};
if writer.control.send(serialized.clone()).is_ok() {
sent = true;
} else {
self.remove_client_and_resize_if_needed(client_id);
}
}
sent
}
/// 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_stream_clients(&mut self, terminal_id: &str, reason: String) {
let client_ids = terminal_stream_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 send_terminal_stream_detach_shutdown(&mut self, client_id: u64) {
if matches!(
self.clients.get(&client_id).map(|client| &client.mode),
Some(
ClientConnectionMode::TerminalAttach { .. }
| ClientConnectionMode::TerminalObserve { .. }
)
) {
self.send_to_client(
client_id,
ServerMessage::ServerShutdown {
reason: Some("detached".to_owned()),
},
);
}
}
#[cfg(unix)]
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_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 {
if !self.client_is_pending_terminal_mode(client_id) {
self.send_to_client(
client_id,
ServerMessage::ServerShutdown {
reason: Some(
"terminal attach failed: connection is not pending terminal attach"
.to_owned(),
),
},
);
self.remove_client_and_resize_if_needed(client_id);
return false;
}
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 self
.pending_alt_screen_reads
.iter()
.any(|pending| pending.terminal_id == real_terminal_id)
{
self.send_to_client(
client_id,
ServerMessage::ServerShutdown {
reason: Some(format!(
"terminal attach failed: terminal {terminal_id} has a read in progress; retry"
)),
},
);
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());
self.app
.start_pending_agent_resume_for_terminal(&real_terminal_id, rows, cols, true);
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
}
fn client_is_pending_terminal_mode(&self, client_id: u64) -> bool {
self.clients
.get(&client_id)
.is_some_and(|client| matches!(client.mode, ClientConnectionMode::TerminalPending))
}
/// 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,
pixel_mouse,
writer,
} => {
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, "direct terminal client connected"
);
let last_activity = self.allocate_activity_stamp();
let observed = crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
};
let pixel_mouse = pixel_mouse && observed.is_known();
let mut connection = ClientConnection::new_with_mode(
ClientConnectionMode::TerminalPending,
(cols, rows),
observed,
last_activity,
protocol::RenderEncoding::TerminalAnsi,
Some(writer),
);
connection.pixel_mouse = pixel_mouse;
self.clients.insert(client_id, connection);
false
}
ServerEvent::ClientShellConnected {
client_id,
surface_cols,
surface_rows,
cell_width_px,
cell_height_px,
pixel_mouse,
direct_graphics,
endpoint_keybindings,
mouse_capture,
surface_active,
surface_reuse,
surface_delta,
surface_scroll,
writer,
} => {
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 = surface_cols,
rows = surface_rows,
cell_width_px,
cell_height_px,
surface_active,
render_encoding = ?protocol::RenderEncoding::SemanticFrame,
"client connected"
);
self.app.ensure_default_workspace();
let first_app_client = self.app_client_count() == 0;
let last_activity = self.allocate_activity_stamp();
let observed = crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
};
let mut connection = ClientConnection::new_with_mode(
ClientConnectionMode::ClientShell,
(surface_cols, surface_rows),
observed,
last_activity,
protocol::RenderEncoding::SemanticFrame,
Some(writer),
);
connection.pixel_mouse = pixel_mouse && observed.is_known();
connection.direct_graphics = direct_graphics;
connection.shell_uses_endpoint_keybindings = endpoint_keybindings;
connection.shell_mouse_capture = mouse_capture;
connection.shell_surface_active = surface_active;
connection.render_state.enable_surface_reuse(surface_reuse);
connection.render_state.enable_surface_delta(surface_delta);
connection
.render_state
.enable_surface_scroll(surface_scroll);
connection.shell_projection_revision = 1;
let config_diagnostic = if endpoint_keybindings {
self.server_config_diagnostic.as_deref()
} else {
self.server_config_diagnostic_without_keybindings.as_deref()
};
let (seed_snapshot, completion_projection) = client_shell_snapshot(
&self.app,
&self.client_shell_boot_id,
connection.shell_projection_revision,
config_diagnostic,
None,
);
let location =
crate::server::clients::ClientShellLocation::from_snapshot(&seed_snapshot);
let agent_view = self.app.state.agent_view_override.clone();
let projection_message = match agent_view.as_ref() {
Some(view) => match crate::protocol::endpoint::agent_view_projection_message(
&seed_snapshot.boot_id,
seed_snapshot.revision,
Some(view),
) {
Ok(message) => Some(message),
Err(err) => {
warn!(client_id, err = %err, "failed to encode endpoint agent view");
return false;
}
},
None => None,
};
let snapshot_message =
match crate::protocol::endpoint::snapshot_message(&seed_snapshot) {
Ok(message) => message,
Err(err) => {
warn!(client_id, err = %err, "failed to encode endpoint snapshot");
return false;
}
};
let completion_message = match crate::protocol::endpoint::agent_completions_message(
&completion_projection,
) {
Ok(message) => message,
Err(err) => {
warn!(client_id, err = %err, "failed to encode agent completions");
return false;
}
};
connection.shell_location = Some(location);
connection.shell_snapshot = Some(seed_snapshot);
connection.shell_agent_completions = Some(completion_projection);
connection.shell_agent_view = agent_view;
self.clients.insert(client_id, connection);
if self.app.state.popup_pane.is_some() && self.popup_owner_tab_id.is_none() {
self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id);
}
if let Some(message) = projection_message {
self.send_to_client(client_id, message);
}
self.send_to_client(client_id, completion_message);
self.send_to_client(client_id, snapshot_message);
if surface_active {
self.foreground_client_id = Some(client_id);
}
if first_app_client {
self.app.mark_git_status_refresh_due(Instant::now());
}
self.sync_foreground_client_state();
self.claim_unowned_shell_tab_geometry(client_id, true);
self.nudge_handoff_panes_on_first_client_attach();
true
}
ServerEvent::GraphicsTransmissionResult {
client_id,
transfer_id,
image_id,
success,
} => self
.native_result(client_id, transfer_id, image_id, success)
.unwrap_or(false),
ServerEvent::GraphicsTransmissionStarted {
client_id,
transfer_id,
image_id,
} => {
self.native_started(client_id, transfer_id, image_id);
false
}
ServerEvent::ClientAttachTerminal {
client_id,
terminal_id,
takeover,
} => self.attach_terminal_client(client_id, terminal_id, takeover),
ServerEvent::ClientObserveTerminal { client_id, target } => {
self.observe_terminal_client(client_id, target)
}
ServerEvent::ClientControlTerminal {
client_id,
target,
takeover,
} => self.control_terminal_client(client_id, target, 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::ClientAttachMouse {
client_id,
kind,
position,
geometry,
modifiers,
lines,
} => self.handle_terminal_attach_mouse(
client_id, kind, position, geometry, modifiers, lines,
),
ServerEvent::ClientInput { client_id, data } => {
if self.handoff_in_progress {
debug!(
client_id,
len = data.len(),
"ignored direct terminal input during handoff"
);
return false;
}
let Some(ClientConnection {
mode: ClientConnectionMode::TerminalAttach { terminal_id },
..
}) = self.clients.get(&client_id)
else {
return false;
};
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);
}
}
true
}
ServerEvent::ClientPasteRejected {
client_id,
size,
max,
} => {
let detail = format!("Input message is {size} bytes; Herdr's limit is {max} bytes");
let message = if matches!(
self.clients.get(&client_id).map(|client| &client.mode),
Some(ClientConnectionMode::ClientShell)
) {
ServerMessage::ClientShellError {
message: format!("Paste rejected: {detail}"),
}
} else {
ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
message: "Paste rejected".to_owned(),
body: Some(detail),
}
};
self.send_to_client(client_id, message);
false
}
ServerEvent::ClientClipboardImage {
client_id,
target,
extension,
data,
} => {
debug!(
client_id,
len = data.len(),
extension = %extension,
"client clipboard image received"
);
if !self.client_clipboard_image_target_is_valid(client_id, &target) {
return false;
}
match self.stage_client_clipboard_image(client_id, &extension, &data) {
Ok(staged) => {
let routed = self.paste_client_clipboard_image_path(
client_id,
target,
staged.paste_text,
);
if routed {
if let Some(client) = self.clients.get_mut(&client_id) {
client.staged_clipboard_files.push(staged.path);
} else {
crate::server::clipboard_image::remove_files(vec![staged.path]);
return false;
}
} else {
crate::server::clipboard_image::remove_files(vec![staged.path]);
}
routed
}
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,
pixel_mouse,
} => {
info!(
client_id,
cols, rows, cell_width_px, cell_height_px, pixel_mouse, "client resize"
);
let observed = crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
};
let pixel_mouse = pixel_mouse && observed.is_known();
let direct_terminal_id = if let Some(ClientConnection {
mode: ClientConnectionMode::TerminalAttach { terminal_id },
terminal_size,
cell_size,
pixel_mouse: client_pixel_mouse,
render_state,
..
}) = self.clients.get_mut(&client_id)
{
*terminal_size = (cols, rows);
*cell_size = observed;
*client_pixel_mouse = pixel_mouse;
render_state.request_repaint();
Some((terminal_id.clone(), *cell_size))
} else {
None
};
if let Some((terminal_id, cell_size)) = direct_terminal_id {
if let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) {
runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px);
}
return true;
}
if let Some(ClientConnection {
mode:
ClientConnectionMode::TerminalObserve { .. }
| ClientConnectionMode::TerminalPending,
terminal_size,
cell_size,
pixel_mouse: client_pixel_mouse,
render_state,
..
}) = self.clients.get_mut(&client_id)
{
*terminal_size = (cols, rows);
*cell_size = observed;
*client_pixel_mouse = pixel_mouse;
render_state.request_repaint();
return true;
}
false
}
ServerEvent::ClientShellResize {
client_id,
surface_cols,
surface_rows,
cell_width_px,
cell_height_px,
pixel_mouse,
} => {
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
if !matches!(client.mode, ClientConnectionMode::ClientShell) {
return false;
}
client.terminal_size = (surface_cols, surface_rows);
let observed = crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
};
if observed.is_known() {
client.cell_size = observed;
}
client.pixel_mouse = pixel_mouse && observed.is_known();
if !client.shell_surface_active {
return false;
}
client.request_repaint();
self.promote_client_to_foreground(client_id);
self.resize_shell_tab_if_controller(client_id, true);
true
}
ServerEvent::ClientShellHostTheme { client_id, update } => {
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
if !matches!(client.mode, ClientConnectionMode::ClientShell) {
return false;
}
if !client.update_host_theme(&update) {
return false;
}
if !client.shell_surface_active || self.foreground_client_id != Some(client_id) {
return false;
}
let mut changed = self.app.set_host_terminal_appearance_state(
client.host_terminal_appearance,
client.host_terminal_appearance_explicit,
);
changed |= self.app.set_host_terminal_theme(client.host_terminal_theme);
if changed {
self.resize_shared_runtime_to_effective_size_before_input();
}
changed
}
ServerEvent::ClientShellFocus { client_id, focused } => {
let Some(client) = self.clients.get(&client_id) else {
return false;
};
if !client.is_active_shell_client() || client.outer_terminal_focus == Some(focused)
{
return false;
}
let tab_id = self.shell_tab_id_for_client(client_id);
let another_focused_viewer = self.clients.iter().any(|(&other_id, client)| {
other_id != client_id
&& client.is_active_shell_client()
&& client.outer_terminal_focus == Some(true)
&& self.shell_tab_id_for_client(other_id) == tab_id
});
if let Some(client) = self.clients.get_mut(&client_id) {
client.outer_terminal_focus = Some(focused);
}
if focused {
self.promote_client_to_foreground(client_id);
self.claim_shell_tab_geometry(client_id, false);
if !another_focused_viewer {
if let Some(target) = self.shell_focus_target(client_id) {
self.send_shell_focus_target(
&target,
crate::ghostty::FocusEvent::Gained,
);
}
}
true
} else {
if self.foreground_client_id == Some(client_id) {
self.app.state.outer_terminal_focus = Some(false);
}
if !another_focused_viewer {
if let Some(target) = self.shell_focus_target(client_id) {
self.send_shell_focus_target(&target, crate::ghostty::FocusEvent::Lost);
}
}
true
}
}
ServerEvent::ClientShellMouseCapture { client_id, enabled } => {
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
if !matches!(client.mode, ClientConnectionMode::ClientShell)
|| client.shell_mouse_capture == enabled
{
return false;
}
client.shell_mouse_capture = enabled;
client.host_mouse_capture_active = None;
true
}
ServerEvent::ClientShellPresentationSync { client_id, token } => {
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
if !client.is_active_shell_client() {
return false;
}
client.host_mouse_capture_active = None;
client.host_sgr_pixels_active = None;
client.host_keyboard_report_all_active = None;
self.sent_window_title = None;
self.stream_host_mouse_capture_mode();
self.stream_direct_terminal_keyboard_mode();
self.sync_window_title();
self.send_to_client(
client_id,
ServerMessage::EndpointControl {
kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND.into(),
data: token,
},
)
}
ServerEvent::ClientShellPaneInput {
client_id,
pane_id,
events,
} => {
if self.handoff_in_progress
|| !self
.clients
.get(&client_id)
.is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| {
client.pixel_mouse && client.host_sgr_pixels_active == Some(true)
});
let mut events = events;
let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id)
else {
return false;
};
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
workspace_index,
runtime_pane_id,
) else {
return false;
};
super::pane_input::downgrade_ineligible_pixel_mouse(
&mut events,
pixel_mouse,
runtime.current_size(),
runtime.pixel_size(),
);
let popup_blocks_input = self.app.state.popup_pane.is_some()
&& self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id);
if popup_blocks_input
|| !self.shell_client_views_pane(client_id, workspace_index, runtime_pane_id)
{
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
workspace_index,
runtime_pane_id,
) else {
return false;
};
let releases = events
.into_iter()
.filter(client_pane_input_releases_press)
.collect::<Vec<_>>();
if releases.is_empty() {
return false;
}
if let Some(client) = self.clients.get_mut(&client_id) {
client.track_shell_input(
ClientShellInputTarget::Pane(pane_id.clone()),
&releases,
);
}
let scroll_before = runtime.scroll_metrics();
if let Err(err) = apply_client_pane_input_events(runtime, &releases) {
warn!(client_id, pane_id, err = %err, "targeted client shell release failed");
}
return runtime.scroll_metrics() != scroll_before;
}
let interaction = client_pane_input_has_interaction(&events);
if let Some(client) = self.clients.get_mut(&client_id) {
client
.track_shell_input(ClientShellInputTarget::Pane(pane_id.clone()), &events);
}
let foreground_changed =
interaction && self.promote_client_to_foreground(client_id);
let geometry_changed =
interaction && self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
workspace_index,
runtime_pane_id,
) else {
return foreground_changed | geometry_changed;
};
let scroll_before = runtime.scroll_metrics();
if let Err(err) = apply_client_pane_input_events(runtime, &events) {
warn!(client_id, pane_id, err = %err, "targeted client shell input failed");
}
foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before
}
ServerEvent::ClientShellPopupInput {
client_id,
terminal_id,
events,
} => {
if self.handoff_in_progress
|| !self
.clients
.get(&client_id)
.is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| {
client.pixel_mouse && client.host_sgr_pixels_active == Some(true)
});
let mut events = events;
let Some(popup_terminal_id) = self
.app
.state
.popup_pane
.as_ref()
.map(|popup| popup.terminal_id.clone())
else {
return false;
};
if popup_terminal_id.as_str() != terminal_id {
return false;
}
let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
return false;
};
super::pane_input::downgrade_ineligible_pixel_mouse(
&mut events,
pixel_mouse,
runtime.current_size(),
runtime.pixel_size(),
);
if self.popup_owner_tab_id != self.shell_tab_id_for_client(client_id) {
let releases = events
.into_iter()
.filter(client_pane_input_releases_press)
.collect::<Vec<_>>();
if releases.is_empty() {
return false;
}
if let Some(client) = self.clients.get_mut(&client_id) {
client.track_shell_input(
ClientShellInputTarget::Popup(terminal_id.clone()),
&releases,
);
}
let scroll_before = runtime.scroll_metrics();
if let Err(err) = apply_client_popup_input_events(runtime, &releases) {
warn!(client_id, terminal_id, err = %err, "targeted client popup release failed");
}
return runtime.scroll_metrics() != scroll_before;
}
let interaction = client_pane_input_has_interaction(&events);
if let Some(client) = self.clients.get_mut(&client_id) {
client.track_shell_input(
ClientShellInputTarget::Popup(terminal_id.clone()),
&events,
);
}
let foreground_changed =
interaction && self.promote_client_to_foreground(client_id);
let geometry_changed =
interaction && self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
return foreground_changed | geometry_changed;
};
let scroll_before = runtime.scroll_metrics();
if let Err(err) = apply_client_popup_input_events(runtime, &events) {
warn!(client_id, terminal_id, err = %err, "targeted client popup input failed");
}
foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before
}
ServerEvent::ClientShellEndpointRequestError {
client_id,
boot_id,
request_id,
code,
message,
} => {
let Some(client) = self.clients.get(&client_id) else {
return false;
};
if !matches!(client.mode, ClientConnectionMode::ClientShell) {
self.remove_client_and_resize_if_needed(client_id);
return true;
}
let message = crate::server::client_commands::error_message(
boot_id, request_id, code, message,
);
self.send_to_client(client_id, message);
false
}
ServerEvent::ClientShellEndpointRequest {
client_id,
boot_id,
request,
} => self.handle_client_shell_endpoint_request(client_id, boot_id, request),
ServerEvent::ClientShellEndpointResponseChunkReady {
client_id,
boot_id,
request_id,
final_chunk,
data,
} => {
let command_surface_revision = self.clients.get(&client_id).and_then(|client| {
(matches!(client.mode, ClientConnectionMode::ClientShell)
&& client.shell_endpoint_command_in_flight
&& boot_id == self.client_shell_boot_id)
.then_some(client.shell_endpoint_command_surface_revision)
.flatten()
});
let Some(command_surface_revision) = command_surface_revision else {
return false;
};
let completed_deferred_response =
self.clients.get_mut(&client_id).and_then(|client| {
let response = client.shell_deferred_navigation_response.as_mut()?;
response.extend_from_slice(&data);
final_chunk
.then(|| client.shell_deferred_navigation_response.take())
.flatten()
});
if final_chunk {
if let Some(client) = self.clients.get_mut(&client_id) {
client.shell_endpoint_command_in_flight = false;
client.shell_endpoint_command_surface_revision = None;
client.shell_deferred_navigation_request_id = None;
}
}
let deferred_tab_id = completed_deferred_response
.as_deref()
.and_then(Self::deferred_endpoint_navigation_tab_id);
let focus_before = self.shell_focus_target(client_id);
let focused_tabs_before = self.focused_shell_tabs();
let navigation_changed = self.clients.get(&client_id).is_some_and(|client| {
client.is_active_shell_client()
&& client.shell_projection_revision == command_surface_revision
}) && deferred_tab_id
.as_deref()
.is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id));
let geometry_changed =
navigation_changed && self.claim_shell_tab_geometry(client_id, false);
if navigation_changed {
self.reconcile_client_shell_locations();
let focus_after = self.shell_focus_target(client_id);
let focused_tabs_after = self.focused_shell_tabs();
self.app.accept_current_focus_without_events();
self.send_shell_navigation_focus_events(
focus_before.as_ref(),
focus_after.as_ref(),
&focused_tabs_before,
&focused_tabs_after,
);
}
self.send_to_client(
client_id,
ServerMessage::ClientShellEndpointResponseChunk {
boot_id,
request_id,
final_chunk,
data,
},
);
navigation_changed | geometry_changed
}
ServerEvent::ClientDetach { client_id } => {
info!(client_id, "client detached");
self.send_terminal_stream_detach_shutdown(client_id);
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;
};
client.take_deferred_render() != DeferredRender::None
}
ServerEvent::QuitSignal => {
// The quit check at the top of the loop handles this.
// No render needed — the next iteration will initiate shutdown.
false
}
}
}
fn handle_server_event_with_render_impact(&mut self, ev: ServerEvent) -> RenderImpact {
if self.handle_server_event(ev) {
RenderImpact::Full
} else {
RenderImpact::None
}
}
fn ignore_client_event_during_handoff(ev: &ServerEvent) -> bool {
!matches!(
ev,
ServerEvent::ClientConnected { .. }
| ServerEvent::ClientShellConnected { .. }
| ServerEvent::ClientShellEndpointResponseChunkReady { .. }
| ServerEvent::ClientDisconnected { .. }
| ServerEvent::ClientWriterDrained { .. }
| ServerEvent::QuitSignal
)
}
fn agent_read_not_idle_error(
&self,
request: &api::schema::Request,
) -> Option<api::schema::ErrorBody> {
use api::schema::{Method, ReadFormat, ReadSource};
let Method::AgentRead(params) = &request.method else {
return None;
};
let requested = params.lines?;
if params.format != ReadFormat::Text
|| !matches!(
params.source,
ReadSource::Recent | ReadSource::RecentUnwrapped
)
{
return None;
}
let target = self.app.resolve_agent_target(&params.target).ok()?;
let terminal = self
.app
.state
.terminals
.values()
.find(|terminal| terminal.id.as_str() == target.terminal_id)?;
if terminal.effective_known_agent().is_none()
|| terminal.state == crate::detect::AgentState::Idle
{
return None;
}
let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
let (screen, snapshot) = runtime.screen_text_snapshot()?;
if screen != crate::ghostty::ActiveScreen::Alternate
|| snapshot.rows.len() >= requested.min(1000) as usize
{
return None;
}
let status = crate::detect::manifest::agent_state_label(terminal.state);
Some(api::schema::ErrorBody {
code: "agent_not_idle".into(),
message: format!(
"cannot read {requested} lines while {} is {status}: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible",
params.target
),
})
}
fn alt_screen_read_spec(&self, request: &api::schema::Request) -> Option<AltScreenReadSpec> {
use api::schema::{Method, ReadFormat, ReadIntent, ReadSource};
let (target, source, lines, format) = match &request.method {
Method::AgentRead(params) => (
self.app.resolve_agent_target(&params.target).ok()?,
params.source,
params.lines,
params.format,
),
Method::PaneRead(params) if params.intent == ReadIntent::Interactive => (
self.app.resolve_terminal_target(&params.pane_id).ok()?,
params.source,
params.lines,
params.format,
),
_ => return None,
};
if format != ReadFormat::Text
|| !matches!(source, ReadSource::Recent | ReadSource::RecentUnwrapped)
{
return None;
}
let lines = lines.unwrap_or(80).min(1000) as usize;
if lines == 0
|| self
.terminal_attach_owners
.contains_key(target.terminal_id.as_str())
|| self
.pending_alt_screen_reads
.iter()
.any(|pending| pending.terminal_id.as_str() == target.terminal_id)
{
return None;
}
let terminal = self
.app
.state
.terminals
.values()
.find(|terminal| terminal.id.as_str() == target.terminal_id)?;
if terminal.effective_known_agent().is_none()
|| terminal.state != crate::detect::AgentState::Idle
{
return None;
}
let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) {
return None;
}
let (screen, initial, content_seq) = runtime.screen_text_snapshot_with_seq()?;
if screen != crate::ghostty::ActiveScreen::Alternate || initial.rows.len() >= lines {
return None;
}
Some(AltScreenReadSpec {
terminal_id: terminal.id.clone(),
lines,
unwrap: source == ReadSource::RecentUnwrapped,
initial,
content_seq,
})
}
fn poll_pending_alt_screen_reads(&mut self, now: Instant) {
let pending = std::mem::take(&mut self.pending_alt_screen_reads);
for read in pending {
let runtime = self.app.terminal_runtimes.get(&read.terminal_id);
let remains_idle = self
.app
.state
.terminals
.get(&read.terminal_id)
.is_some_and(|terminal| terminal.state == crate::detect::AgentState::Idle);
let attached = self
.terminal_attach_owners
.contains_key(read.terminal_id.as_str());
let outcome = if remains_idle && !attached {
read.poll(runtime, now)
} else {
read.abort(runtime, now)
};
if let Some(read) = outcome {
self.pending_alt_screen_reads.push(read);
}
}
}
fn alt_screen_read_conflict(&self, request: &api::schema::Request) -> AltScreenReadConflict {
let (target, source, lines, format) = match &request.method {
api::schema::Method::AgentRead(params) => (
self.app.resolve_agent_target(&params.target).ok(),
params.source,
params.lines,
params.format,
),
api::schema::Method::PaneRead(params) => (
self.app.resolve_terminal_target(&params.pane_id).ok(),
params.source,
params.lines,
params.format,
),
_ => return AltScreenReadConflict::None,
};
let Some(target) = target else {
return AltScreenReadConflict::None;
};
let Some(pending) = self
.pending_alt_screen_reads
.iter()
.find(|pending| pending.terminal_id.as_str() == target.terminal_id)
else {
return AltScreenReadConflict::None;
};
if format == api::schema::ReadFormat::Text {
AltScreenReadConflict::Frozen(pending.frozen_snapshot(source, lines))
} else {
AltScreenReadConflict::Defer
}
}
fn process_deferred_alt_screen_reads(&mut self) -> bool {
let deferred = std::mem::take(&mut self.deferred_alt_screen_reads);
let mut changed = false;
for msg in deferred {
match self.alt_screen_read_conflict(&msg.request) {
AltScreenReadConflict::None => {
changed |= self.handle_api_request_with_shutdown_check(msg);
}
AltScreenReadConflict::Frozen(_) | AltScreenReadConflict::Defer => {
self.deferred_alt_screen_reads.push(msg);
}
}
}
changed
}
/// 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;
for _ in 0..EXTERNAL_EVENT_DRAIN_LIMIT {
if self.should_quit.load(Ordering::Acquire) {
break;
}
let Ok(msg) = self.app.api_rx.try_recv() else {
break;
};
changed |= self.handle_api_request_with_shutdown_check(msg);
}
changed
}
fn reject_queued_api_requests_for_shutdown(&mut self) {
for _ in 0..self.app.api_rx.len() {
let Ok(msg) = self.app.api_rx.try_recv() else {
break;
};
self.handle_api_request_with_shutdown_check(msg);
}
}
fn reject_api_request_for_shutdown(msg: api::ApiRequestMessage) {
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);
}
fn handle_api_request_with_shutdown_check_inner(
&mut self,
msg: api::ApiRequestMessage,
skip_default_workspace_for_request: bool,
client_local: bool,
) -> bool {
if self.shutting_down || self.host_shutdown_requested() {
// During shutdown, respond with server_unavailable.
Self::reject_api_request_for_shutdown(msg);
return false;
}
let frozen_alt_screen_read = match self.alt_screen_read_conflict(&msg.request) {
AltScreenReadConflict::None => None,
AltScreenReadConflict::Frozen(snapshot) => Some(snapshot),
AltScreenReadConflict::Defer => {
self.deferred_alt_screen_reads.push(msg);
return false;
}
};
let metadata_expired = self.app.expire_due_metadata(Instant::now());
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
let handoff_result = self.perform_live_handoff(params.clone());
let handoff_succeeded = handoff_result.is_ok();
let response = match handoff_result {
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);
if handoff_succeeded {
wait_for_live_handoff_response_write(msg.response_write_complete);
self.finish_live_handoff_shutdown();
}
return true;
}
if let api::schema::Method::NotificationShow(params) = &msg.request.method {
let response =
self.handle_notification_show_api(msg.request.id.clone(), params.clone());
let _ = msg.respond_to.send(response);
return true;
}
match &msg.request.method {
api::schema::Method::ClientWindowTitleSet(params) => {
let response = self.handle_client_window_title_api(
msg.request.id.clone(),
Some(params.title.clone()),
);
let _ = msg.respond_to.send(response);
return true;
}
api::schema::Method::ClientWindowTitleClear(_) => {
let response = self.handle_client_window_title_api(msg.request.id.clone(), None);
let _ = msg.respond_to.send(response);
return true;
}
_ => {}
}
let mut changed = metadata_expired | api::request_changes_ui(&msg.request);
let skip_default_workspace = skip_default_workspace_for_request
|| matches!(
&msg.request.method,
api::schema::Method::ServerStop(_) | api::schema::Method::ServerLiveHandoff(_)
);
changed |= self.drain_all_internal_events_with_forwarding();
if self.host_shutdown_requested() {
Self::reject_api_request_for_shutdown(msg);
return 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,
Option<String>,
)> = {
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,
terminal.effective_agent_label().map(str::to_string),
)
})
})
})
})
.collect()
};
self.sync_foreground_client_state();
if let Some(error) = self.agent_read_not_idle_error(&msg.request) {
let response = serde_json::to_string(&api::schema::ErrorResponse {
id: msg.request.id.clone(),
error,
})
.unwrap_or_else(|_| "{}".to_owned());
let _ = msg.respond_to.send(response);
return changed;
}
let alt_screen_read_spec = self.alt_screen_read_spec(&msg.request);
if matches!(&msg.request.method, api::schema::Method::AgentPrompt(_)) {
let deferred_changed = self
.app
.handle_deferred_agent_api_request(msg.request, msg.respond_to);
return changed | deferred_changed;
}
if matches!(
&msg.request.method,
api::schema::Method::WorktreeCreate(_)
| api::schema::Method::WorktreeRemove(_)
| api::schema::Method::WorktreeList(_)
| api::schema::Method::WorktreeOpen(_)
) {
let read_only = matches!(&msg.request.method, api::schema::Method::WorktreeList(_));
let deferred_changed = self.app.handle_deferred_worktree_api_request(
msg.request,
msg.respond_to,
client_local,
);
return changed | (deferred_changed && !read_only);
}
if self.foreground_client_id.is_some_and(|client_id| {
self.clients
.get(&client_id)
.is_some_and(|client| matches!(client.mode, ClientConnectionMode::ClientShell))
}) {
self.app.state.view.terminal_area =
Rect::new(0, 0, self.effective_size.0, self.effective_size.1);
}
let mut 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)
};
if let Some(snapshot) = frozen_alt_screen_read {
if let Ok(mut success) = serde_json::from_str::<api::schema::SuccessResponse>(&response)
{
if let api::schema::ResponseResult::PaneRead { read } = &mut success.result {
read.text = snapshot.text;
read.truncated = snapshot.truncated;
if let Ok(serialized) = serde_json::to_string(&success) {
response = serialized;
}
}
}
}
if let Some(spec) = alt_screen_read_spec {
if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) {
if let api::schema::ResponseResult::PaneRead { read } = success.result {
let pending = crate::server::alt_screen_read::PendingAltScreenRead::start(
spec.terminal_id,
success.id,
msg.respond_to,
response,
read,
spec.lines,
spec.unwrap,
spec.initial,
spec.content_seq,
Instant::now(),
);
self.pending_alt_screen_reads.push(pending);
return changed;
}
}
}
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 {
debug!(title = %toast.title, body = %toast.context, "forwarding toast notification from API request");
self.send_notify_to_foreground_client(
toast_notify_kind(self.app.state.toast_config.delivery)
.expect("toast forwarding requires a client notification kind"),
&toast.title,
non_empty_body(&toast.context),
);
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, prev_agent_label) 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 terminal_id = pane_after.attached_terminal_id.clone();
let Some(terminal_after) = self.app.state.terminals.get(&terminal_id) else {
continue;
};
let new_state = terminal_after.state;
if new_state == *prev_state
|| (new_state == crate::detect::AgentState::Idle
&& terminal_after.last_agent_completion_seq.is_none())
{
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();
let agent_label = terminal_after.effective_agent_label().map(str::to_string);
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"
);
self.forward_semantic_agent_transition(
*ws_idx,
*pane_id,
*prev_state,
new_state,
prev_agent_label.as_deref(),
agent_label.as_deref(),
agent,
);
if !forwarded_toast_from_state
&& self.app.state.toast_config.delay_seconds == 0
&& 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(&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 workspace_label = self.app.state.workspaces[*ws_idx].display_name_from(
&self.app.state.terminals,
&self.app.terminal_runtimes,
);
let context = crate::app::actions::notification_context(
&self.app.state.workspaces[*ws_idx],
&workspace_label,
*ws_idx,
*pane_id,
);
self.send_notify_to_foreground_client(
toast_notify_kind(self.app.state.toast_config.delivery)
.expect("toast forwarding requires a client notification kind"),
format!("{agent_label} {event_text}"),
non_empty_body(&context),
);
}
}
}
// 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.toast_config.delay_seconds == 0 && 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,
) {
debug!(sound = ?sound, "forwarding sound notification from API request");
self.send_notify_to_foreground_client(
protocol::NotifyKind::Sound,
sound_notify_message(sound),
None,
);
}
}
}
if !skip_default_workspace && latest_shell_client(&self.clients).is_some() {
changed |= self.app.ensure_default_workspace();
}
changed
}
/// Handle scheduled tasks for the headless server.
///
/// Similar to the former App scheduler but without terminal resize polling.
fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool {
if self.host_shutdown_requested() {
return false;
}
let mut changed = false;
// 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
.state
.next_pending_agent_notification_deadline()
.is_some_and(|deadline| now >= deadline)
{
let previous_toast = self.app.state.toast.clone();
let mut deliveries = self.app.state.drain_due_agent_notifications(now);
if !deliveries.is_empty() {
self.app
.refresh_agent_notification_delivery_contexts(&mut deliveries);
self.app.sync_toast_deadline(previous_toast);
for delivery in &deliveries {
self.forward_agent_notification_delivery(delivery);
}
changed = true;
}
}
if self
.app
.restored_worktree_validation_retry_at
.is_some_and(|deadline| now >= deadline)
{
self.app.start_restored_worktree_validation(now);
}
if self.git_refresh_scheduled() {
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
.next_agent_manifest_update_check
.is_some_and(|deadline| now >= deadline)
{
self.app.run_agent_manifest_update_check();
}
if self
.app
.session_save_deadline
.is_some_and(|deadline| now >= deadline)
{
self.app.start_background_session_save();
}
if let Some(deadline) = self
.app
.agent_metadata_deadline
.filter(|deadline| now >= *deadline)
{
self.app.expire_metadata_at(deadline, now);
changed = true;
}
changed |= self.app.handle_tab_bar_status_tasks(now);
if geometry_dirty {
self.app.pending_agent_resume_deadline = None;
} else {
self.app.sync_pending_agent_resume_deadline(now);
changed |= self
.app
.start_pending_agent_resumes(now, self.app.pending_agent_resume_due(now));
}
changed
}
}
fn client_pane_input_releases_press(event: &protocol::ClientPaneInputEvent) -> bool {
matches!(
event,
protocol::ClientPaneInputEvent::Key {
kind: protocol::ClientKeyKind::Release,
..
} | protocol::ClientPaneInputEvent::Mouse {
kind: protocol::ClientMouseKind::Up(_),
..
}
)
}
fn client_pane_input_has_interaction(events: &[protocol::ClientPaneInputEvent]) -> bool {
events
.iter()
.any(|event| !client_pane_input_releases_press(event))
}
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
// ---------------------------------------------------------------------------
/// 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 sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> {
let mut sanitized = String::new();
let mut previous_space = false;
for ch in value.chars() {
let replacement = if ch == '\n' || ch == '\r' || ch == '\t' {
Some(' ')
} else if ch.is_control() {
None
} else {
Some(ch)
};
let Some(ch) = replacement else {
continue;
};
if ch.is_whitespace() {
if previous_space {
continue;
}
previous_space = true;
sanitized.push(' ');
} else {
previous_space = false;
sanitized.push(ch);
}
if sanitized.chars().count() >= max_chars {
break;
}
}
let sanitized = sanitized.trim().to_string();
(!sanitized.is_empty()).then_some(sanitized)
}
fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
(
config::config_diagnostic_summary(diagnostics),
config::config_diagnostic_summary_without_keybindings(diagnostics),
)
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests;