aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 00:07:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:44 +0300
commit3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch)
treea52280bccc824ca656f8bc986a756f30901974e4 /src/server
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src/server')
-rw-r--r--src/server/autodetect.rs155
-rw-r--r--src/server/client_accept.rs11
-rw-r--r--src/server/client_transport.rs211
-rw-r--r--src/server/clipboard_image.rs41
-rw-r--r--src/server/headless.rs405
-rw-r--r--src/server/mod.rs1
-rw-r--r--src/server/notifications.rs5
-rw-r--r--src/server/render_stream.rs3
-rw-r--r--src/server/socket_paths.rs2
9 files changed, 686 insertions, 148 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index a4ea721b..cdba5bd2 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -9,14 +9,14 @@
//! (escape hatch for users who want the traditional single-process behavior).
use std::io;
-use std::os::unix::net::UnixStream;
+#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
-use tracing::{info, warn};
+use tracing::info;
use super::socket_paths::client_socket_path;
@@ -30,6 +30,10 @@ const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Timeout for checking the stable JSON API before attaching to the binary protocol socket.
const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
+/// Private daemon-start hint used to seed a fresh headless server from the
+/// directory where the user ran `herdr`.
+pub(crate) const STARTUP_CWD_ENV_VAR: &str = "HERDR_STARTUP_CWD";
+
// ---------------------------------------------------------------------------
// Server detection
// ---------------------------------------------------------------------------
@@ -39,7 +43,7 @@ const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
/// This works by attempting to connect to the client socket. If the connection
/// succeeds, a server is running. If the socket file doesn't exist or the
/// connection is refused, no server is running. Stale sockets (from a crashed
-/// server) are detected because `UnixStream::connect` returns `ConnectionRefused`
+/// server) are detected because connect returns `ConnectionRefused`
/// when nobody is listening.
#[allow(dead_code)] // Public API for external use and testing
pub fn is_server_listening() -> bool {
@@ -48,42 +52,103 @@ pub fn is_server_listening() -> bool {
/// Checks whether a herdr server is listening at a specific socket path.
fn is_server_listening_at(socket_path: &Path) -> bool {
- if !socket_path.exists() {
- return false;
+ #[cfg(windows)]
+ {
+ let _ = socket_path;
+ return read_server_status().ok().flatten().is_some();
}
- match UnixStream::connect(socket_path) {
- Ok(_) => {
- // Server is listening. Close the test connection immediately.
- // The server's handshake handler will time out on this connection
- // since we don't send Hello, which is fine.
- true
+ #[cfg(not(windows))]
+ {
+ if !socket_path.exists() {
+ return false;
+ }
+
+ match crate::ipc::connect_local_stream(socket_path) {
+ Ok(_) => {
+ // Server is listening. Close the test connection immediately.
+ // The server's handshake handler will time out on this connection
+ // since we don't send Hello, which is fine.
+ true
+ }
+ Err(err)
+ if matches!(
+ err.kind(),
+ io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut
+ ) =>
+ {
+ // Socket file exists but nobody is listening — stale socket.
+ false
+ }
+ Err(err) if err.kind() == io::ErrorKind::NotFound => {
+ // Socket file disappeared between exists() and connect().
+ false
+ }
+ Err(err) => {
+ // Other errors (permission denied, etc.) — assume not listening.
+ tracing::warn!(err = %err, "unexpected error checking server socket");
+ false
+ }
}
+ }
+}
+
+fn read_server_status() -> io::Result<Option<crate::api::RuntimeStatus>> {
+ crate::api::read_runtime_status_at(&crate::api::socket_path(), STATUS_REQUEST_TIMEOUT)
+}
+
+#[cfg(windows)]
+fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> {
+ if !socket_path.exists() {
+ return Ok(false);
+ }
+
+ let mut stream = match crate::ipc::connect_local_stream(socket_path) {
+ Ok(stream) => stream,
Err(err)
if matches!(
err.kind(),
- io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut
+ io::ErrorKind::ConnectionRefused
+ | io::ErrorKind::NotFound
+ | io::ErrorKind::TimedOut
+ | io::ErrorKind::WouldBlock
) =>
{
- // Socket file exists but nobody is listening — stale socket.
- false
- }
- Err(err) if err.kind() == io::ErrorKind::NotFound => {
- // Socket file disappeared between exists() and connect().
- false
+ return Ok(false);
}
- Err(err) => {
- // Other errors (permission denied, etc.) — assume not listening.
- warn!(err = %err, "unexpected error checking server socket");
- false
+ Err(err) => return Err(err),
+ };
+
+ let hello = crate::protocol::ClientMessage::Hello {
+ version: crate::protocol::PROTOCOL_VERSION,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ requested_encoding: crate::protocol::RenderEncoding::SemanticFrame,
+ keybindings: crate::protocol::ClientKeybindings::Server,
+ launch_mode: crate::protocol::ClientLaunchMode::App,
+ };
+
+ match crate::protocol::write_message(&mut stream, &hello) {
+ Ok(()) => Ok(true),
+ Err(crate::protocol::FramingError::Io(err))
+ if matches!(
+ err.kind(),
+ io::ErrorKind::ConnectionRefused
+ | io::ErrorKind::NotFound
+ | io::ErrorKind::TimedOut
+ | io::ErrorKind::WouldBlock
+ | io::ErrorKind::BrokenPipe
+ | io::ErrorKind::ConnectionReset
+ ) =>
+ {
+ Ok(false)
}
+ Err(err) => Err(io::Error::other(err.to_string())),
}
}
-fn read_server_status() -> io::Result<Option<crate::api::RuntimeStatus>> {
- crate::api::read_runtime_status_at(&crate::api::socket_path(), STATUS_REQUEST_TIMEOUT)
-}
-
fn validate_running_server_compatibility() -> io::Result<()> {
let Some(status) = read_server_status()? else {
return Err(io::Error::other(format!(
@@ -149,13 +214,25 @@ fn build_server_daemon_command(exe: PathBuf) -> Command {
let mut command = Command::new(&exe);
command
.arg("server")
- // Create a new process group so the server survives the parent's exit
- // and doesn't receive SIGHUP when the client's terminal closes.
- .process_group(0)
// Redirect stdio to /dev/null
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
+ #[cfg(unix)]
+ {
+ // Create a new process group so the server survives the parent's exit
+ // and doesn't receive SIGHUP when the client's terminal closes.
+ command.process_group(0);
+ }
+
+ match std::env::current_dir() {
+ Ok(cwd) => {
+ command.env(STARTUP_CWD_ENV_VAR, cwd);
+ }
+ Err(_) => {
+ command.env_remove(STARTUP_CWD_ENV_VAR);
+ }
+ }
if crate::session::explicit_session_requested() {
command
@@ -179,6 +256,13 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
+ #[cfg(windows)]
+ if client_protocol_accepts_hello(socket_path)? {
+ info!(path = %socket_path.display(), "server client protocol ready");
+ return Ok(());
+ }
+
+ #[cfg(not(windows))]
if is_server_listening_at(socket_path) {
info!(path = %socket_path.display(), "server socket ready");
return Ok(());
@@ -232,7 +316,7 @@ pub fn auto_detect_launch() -> io::Result<()> {
// Tests
// ---------------------------------------------------------------------------
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::ffi::OsStr;
@@ -290,6 +374,17 @@ mod tests {
}
#[test]
+ fn server_daemon_command_passes_current_dir_as_startup_cwd() {
+ let expected = std::env::current_dir().unwrap();
+ let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test"));
+ let envs: Vec<_> = command.get_envs().collect();
+
+ assert!(envs.iter().any(|(key, value)| {
+ *key == OsStr::new(STARTUP_CWD_ENV_VAR) && value == &Some(expected.as_os_str())
+ }));
+ }
+
+ #[test]
fn is_server_listening_returns_true_for_live_socket() {
let dir = unique_test_dir("live");
std::fs::create_dir_all(&dir).unwrap();
diff --git a/src/server/client_accept.rs b/src/server/client_accept.rs
index afb2c398..ccfe7b1a 100644
--- a/src/server/client_accept.rs
+++ b/src/server/client_accept.rs
@@ -1,22 +1,23 @@
use std::io;
-use std::os::unix::net::UnixListener;
use std::sync::{atomic::AtomicBool, Arc};
+use interprocess::local_socket::traits::{Listener as _, Stream as _};
use tokio::sync::mpsc;
use tracing::{debug, error, warn};
+use crate::ipc::LocalListener;
use crate::server::client_transport::{self, ServerEvent};
/// Accepts pending thin-client connections and starts their handshake readers.
pub(crate) fn accept_pending_client_connections(
- listener: &UnixListener,
+ listener: &LocalListener,
next_client_id: &mut u64,
should_quit: &Arc<AtomicBool>,
server_event_tx: &mpsc::Sender<ServerEvent>,
) -> io::Result<()> {
loop {
match listener.accept() {
- Ok((stream, _addr)) => {
+ Ok(stream) => {
let client_id = *next_client_id;
*next_client_id = next_client_id.saturating_add(1);
@@ -53,10 +54,10 @@ pub(crate) fn accept_pending_client_connections(
///
/// During live handoff the old server must not let clients sit in the Unix
/// listener backlog waiting for a welcome frame that will never be sent.
-pub(crate) fn reject_pending_client_connections(listener: &UnixListener) -> io::Result<()> {
+pub(crate) fn reject_pending_client_connections(listener: &LocalListener) -> io::Result<()> {
loop {
match listener.accept() {
- Ok((_stream, _addr)) => {}
+ Ok(_stream) => {}
Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => break,
Err(err) => {
error!(err = %err, "client listener reject failed");
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index 931f1100..4f0f12bc 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -5,18 +5,20 @@
//! `HeadlessServer`.
use std::io::{self, Write};
-use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
+use interprocess::local_socket::traits::Stream as _;
+use interprocess::TryClone as _;
use tokio::sync::mpsc;
use tracing::{debug, warn};
+use crate::ipc::LocalStream;
use crate::protocol::{
- self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
- ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
- MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, AttachScrollDirection, AttachScrollSource, ClientInputEvent, ClientKeybindings,
+ ClientLaunchMode, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
/// Minimum accepted attached client size.
@@ -35,6 +37,8 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
/// Maximum input payload size (bytes) for a single `ClientMessage::Input`.
const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
+/// Maximum structured input events accepted in one client message.
+const MAX_INPUT_EVENT_BATCH: usize = 4096;
/// Channels owned by the server side of a client writer thread.
#[derive(Clone, Debug)]
@@ -62,6 +66,11 @@ pub(crate) enum ServerEvent {
},
/// A client sent an input message.
ClientInput { client_id: u64, data: Vec<u8> },
+ /// A client sent structured input events.
+ ClientInputEvents {
+ client_id: u64,
+ events: Vec<crate::protocol::ClientInputEvent>,
+ },
/// A client sent local clipboard image bytes to paste into a remote pane.
ClientClipboardImage {
client_id: u64,
@@ -126,12 +135,30 @@ fn parse_client_keybindings(
}
}
+fn input_events_within_limits(events: &[ClientInputEvent]) -> bool {
+ if events.len() > MAX_INPUT_EVENT_BATCH {
+ return false;
+ }
+
+ let mut paste_bytes = 0usize;
+ for event in events {
+ if let ClientInputEvent::Paste { text } = event {
+ paste_bytes = paste_bytes.saturating_add(text.len());
+ if paste_bytes > MAX_INPUT_PAYLOAD {
+ return false;
+ }
+ }
+ }
+
+ true
+}
+
/// Handles the client handshake on a blocking thread.
///
/// Reads the `Hello` message, validates the version, sends `Welcome`,
/// and then enters a read loop forwarding messages to the server event channel.
pub(crate) fn handle_client_handshake(
- mut stream: UnixStream,
+ mut stream: LocalStream,
client_id: u64,
server_event_tx: &mpsc::Sender<ServerEvent>,
should_quit: &Arc<AtomicBool>,
@@ -140,8 +167,9 @@ pub(crate) fn handle_client_handshake(
// the handshake thread needs blocking I/O for read_message/write_message.
stream.set_nonblocking(false)?;
- // Set a read timeout for the handshake.
- stream.set_read_timeout(Some(HANDSHAKE_TIMEOUT))?;
+ if let Err(err) = stream.set_recv_timeout(Some(HANDSHAKE_TIMEOUT)) {
+ debug!(client_id, err = %err, "client handshake read timeout unavailable");
+ }
// Read the Hello message.
let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
@@ -240,8 +268,9 @@ pub(crate) fn handle_client_handshake(
};
protocol::write_message(&mut stream, &welcome).map_err(|e| io::Error::other(e.to_string()))?;
- // Clear read timeout for normal operation.
- stream.set_read_timeout(None)?;
+ if let Err(err) = stream.set_recv_timeout(None) {
+ debug!(client_id, err = %err, "failed to clear client handshake read timeout");
+ }
// Create separate channels for reliable control messages and droppable renders.
let (control_tx, control_rx) = std::sync::mpsc::channel::<Vec<u8>>();
@@ -283,7 +312,7 @@ pub(crate) fn handle_client_handshake(
/// The client writer loop — prioritizes control messages over render frames.
fn client_writer_loop(
- mut stream: UnixStream,
+ mut stream: LocalStream,
client_id: u64,
control_rx: std::sync::mpsc::Receiver<Vec<u8>>,
render_rx: std::sync::mpsc::Receiver<Vec<u8>>,
@@ -349,7 +378,7 @@ fn client_writer_loop(
debug!("client writer thread exiting");
}
-fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool {
+fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool {
if let Err(err) = stream.write_all(data) {
debug!(err = %err, "client write failed, closing writer");
return false;
@@ -363,7 +392,7 @@ fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool {
/// The client read loop — reads messages from the client and forwards to the server event channel.
fn client_read_loop(
- mut stream: UnixStream,
+ mut stream: LocalStream,
client_id: u64,
server_event_tx: &mpsc::Sender<ServerEvent>,
should_quit: &Arc<AtomicBool>,
@@ -411,6 +440,20 @@ fn client_read_loop(
ServerEvent::ClientInput { client_id, data }
}
}
+ ClientMessage::InputEvents { events } => {
+ if !input_events_within_limits(&events) {
+ warn!(
+ client_id,
+ count = events.len(),
+ "oversized input events from client, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ } else {
+ ServerEvent::ClientInputEvents { client_id, events }
+ }
+ }
ClientMessage::ClipboardImage { extension, data } => {
if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
warn!(
@@ -487,6 +530,23 @@ fn client_read_loop(
#[cfg(test)]
mod tests {
use super::*;
+ use interprocess::local_socket::traits::Listener as _;
+
+ fn unique_test_path(name: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+ }
+
+ fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, std::path::PathBuf) {
+ let path = unique_test_path(name);
+ let listener = crate::ipc::bind_local_listener(&path).unwrap();
+ let client = crate::ipc::connect_local_stream(&path).unwrap();
+ let server = listener.accept().unwrap();
+ (client, server, path)
+ }
#[test]
fn clamp_terminal_size_zero_zero() {
@@ -569,7 +629,7 @@ new_tab = "ctrl+notakey"
#[test]
fn handshake_negotiates_terminal_ansi_encoding() {
- let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
+ let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let handshake_quit = should_quit.clone();
@@ -643,7 +703,8 @@ new_tab = "ctrl+notakey"
#[test]
fn handshake_marks_terminal_attach_launch_mode() {
- let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
+ let (mut client_stream, server_stream, _path) =
+ local_stream_pair("client-handshake-terminal-attach");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let handshake_quit = should_quit.clone();
@@ -706,7 +767,7 @@ new_tab = "ctrl+notakey"
#[test]
fn client_read_loop_rejects_oversized_input() {
- let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
+ let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-oversized");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let read_quit = should_quit.clone();
@@ -739,6 +800,126 @@ new_tab = "ctrl+notakey"
}
#[test]
+ fn client_read_loop_forwards_input_events() {
+ let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-events");
+ let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ let read_quit = should_quit.clone();
+ let handle = std::thread::spawn(move || {
+ client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
+ });
+ let events = vec![
+ ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ ClientInputEvent::FocusGained,
+ ];
+
+ protocol::write_message(
+ &mut client_stream,
+ &ClientMessage::InputEvents {
+ events: events.clone(),
+ },
+ )
+ .expect("write input events");
+
+ match server_event_rx
+ .blocking_recv()
+ .expect("client input events event")
+ {
+ ServerEvent::ClientInputEvents {
+ client_id,
+ events: actual,
+ } => {
+ assert_eq!(client_id, 7);
+ assert_eq!(actual, events);
+ }
+ other => panic!("expected ClientInputEvents, got {other:?}"),
+ }
+
+ drop(client_stream);
+ should_quit.store(true, Ordering::Release);
+ handle
+ .join()
+ .expect("read thread join")
+ .expect("read thread result");
+ }
+
+ #[test]
+ fn client_read_loop_rejects_oversized_input_event_batch() {
+ let (mut client_stream, server_stream, _path) =
+ local_stream_pair("client-read-oversized-events");
+ let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ let read_quit = should_quit.clone();
+ let handle = std::thread::spawn(move || {
+ client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
+ });
+
+ protocol::write_message(
+ &mut client_stream,
+ &ClientMessage::InputEvents {
+ events: vec![ClientInputEvent::FocusGained; MAX_INPUT_EVENT_BATCH + 1],
+ },
+ )
+ .expect("write oversized input events");
+
+ match server_event_rx
+ .blocking_recv()
+ .expect("client disconnected event")
+ {
+ ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7),
+ other => panic!("expected ClientDisconnected, got {other:?}"),
+ }
+
+ drop(client_stream);
+ should_quit.store(true, Ordering::Release);
+ handle
+ .join()
+ .expect("read thread join")
+ .expect("read thread result");
+ }
+
+ #[test]
+ fn client_read_loop_rejects_oversized_input_event_paste() {
+ let (mut client_stream, server_stream, _path) =
+ local_stream_pair("client-read-oversized-paste");
+ let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ let read_quit = should_quit.clone();
+ let handle = std::thread::spawn(move || {
+ client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
+ });
+
+ protocol::write_message(
+ &mut client_stream,
+ &ClientMessage::InputEvents {
+ events: vec![ClientInputEvent::Paste {
+ text: "x".repeat(MAX_INPUT_PAYLOAD + 1),
+ }],
+ },
+ )
+ .expect("write oversized paste event");
+
+ match server_event_rx
+ .blocking_recv()
+ .expect("client disconnected event")
+ {
+ ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7),
+ other => panic!("expected ClientDisconnected, got {other:?}"),
+ }
+
+ drop(client_stream);
+ should_quit.store(true, Ordering::Release);
+ handle
+ .join()
+ .expect("read thread join")
+ .expect("read thread result");
+ }
+
+ #[test]
fn handshake_timeout_is_within_five_second_deadline() {
// The handshake timeout must be short enough that
// the connection is guaranteed to close within 5 seconds even with
diff --git a/src/server/clipboard_image.rs b/src/server/clipboard_image.rs
index 299267d3..70072f2d 100644
--- a/src/server/clipboard_image.rs
+++ b/src/server/clipboard_image.rs
@@ -15,8 +15,6 @@ pub(crate) fn stage(
extension: &str,
data: &[u8],
) -> io::Result<StagedClipboardImage> {
- use std::os::unix::fs::OpenOptionsExt;
-
let extension = sanitize_extension(extension);
let dir = ensure_staging_dir()?;
cleanup_stale(&dir);
@@ -30,12 +28,10 @@ pub(crate) fn stage(
let path = dir.join(format!(
"client-{client_id}-clipboard-{unique}-{attempt}.{extension}"
));
- let mut file = match fs::OpenOptions::new()
- .write(true)
- .create_new(true)
- .mode(0o600)
- .open(&path)
- {
+ let mut options = fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ restrict_file_options(&mut options);
+ let mut file = match options.open(&path) {
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
@@ -76,13 +72,14 @@ fn sanitize_extension(extension: &str) -> &'static str {
}
fn staging_dir() -> PathBuf {
+ #[cfg(unix)]
let user_id = unsafe { libc::geteuid() };
+ #[cfg(windows)]
+ let user_id = std::process::id();
std::env::temp_dir().join(format!("herdr-clipboard-images-{user_id}"))
}
fn ensure_staging_dir() -> io::Result<PathBuf> {
- use std::os::unix::fs::PermissionsExt;
-
let dir = staging_dir();
fs::create_dir_all(&dir)?;
let metadata = fs::metadata(&dir)?;
@@ -92,10 +89,32 @@ fn ensure_staging_dir() -> io::Result<PathBuf> {
dir.display()
)));
}
- fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
+ restrict_dir_permissions(&dir)?;
Ok(dir)
}
+#[cfg(unix)]
+fn restrict_file_options(options: &mut fs::OpenOptions) {
+ use std::os::unix::fs::OpenOptionsExt;
+
+ options.mode(0o600);
+}
+
+#[cfg(windows)]
+fn restrict_file_options(_options: &mut fs::OpenOptions) {}
+
+#[cfg(unix)]
+fn restrict_dir_permissions(dir: &Path) -> io::Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+
+ fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
+}
+
+#[cfg(windows)]
+fn restrict_dir_permissions(_dir: &Path) -> io::Result<()> {
+ Ok(())
+}
+
fn cleanup_stale(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c78df8a1..f313161f 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -16,13 +16,17 @@
use std::collections::HashMap;
use std::io;
-use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossterm::event::{KeyModifiers, MouseEventKind};
+use 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;
use tracing::{debug, error, info, warn};
@@ -34,11 +38,15 @@ use crate::api;
use crate::app;
use crate::config;
use crate::events::AppEvent;
-use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity};
+use crate::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,
MAX_GRAPHICS_FRAME_SIZE,
};
+#[cfg(unix)]
use crate::server::client_accept::{
accept_pending_client_connections, reject_pending_client_connections,
};
@@ -167,7 +175,7 @@ const MIN_ROWS: u16 = 24;
#[allow(dead_code)]
const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
-/// How often the idle headless loop wakes to poll the std UnixListener for new
+/// 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
@@ -183,12 +191,16 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
/// 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>,
+ #[cfg(unix)]
api_server: Option<api::ServerHandle>,
- client_listener: UnixListener,
+ #[cfg(unix)]
+ client_listener: LocalListener,
client_socket_path: PathBuf,
client_socket_identity: SocketFileIdentity,
clients: HashMap<u64, ClientConnection>,
+ #[cfg(unix)]
next_client_id: u64,
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
foreground_client_id: Option<u64>,
@@ -210,6 +222,7 @@ pub struct HeadlessServer {
/// 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 Ctrl+C or `server stop` signal.
should_quit: Arc<AtomicBool>,
@@ -292,6 +305,51 @@ fn apply_terminal_attach_input(
.map_err(|err| format!("terminal attach input failed: {err}"))
}
+#[cfg(windows)]
+fn spawn_windows_client_accept_thread(
+ listener: LocalListener,
+ should_quit: Arc<AtomicBool>,
+ server_event_tx: mpsc::Sender<ServerEvent>,
+) {
+ std::thread::spawn(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();
+ std::thread::spawn(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");
+ }
+ });
+ }
+ });
+}
+
impl HeadlessServer {
/// Creates and starts the headless server.
///
@@ -308,30 +366,40 @@ impl HeadlessServer {
let client_path = client_socket_path();
prepare_socket_path(&client_path)?;
- let listener = UnixListener::bind(&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 the listener so we can poll it from the event loop.
- listener.set_nonblocking(true)?;
+ // Set non-blocking on Unix so we can poll it from the event loop.
+ #[cfg(unix)]
+ listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
let should_quit = Arc::new(AtomicBool::new(false));
// Channel for server events from client threads.
let (server_event_tx, server_event_rx) = mpsc::channel(64);
+ #[cfg(windows)]
+ spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
+
let server_keybindings = app_keybindings(&app);
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
server_config_diagnostic_summaries(config_diagnostics);
+ #[cfg(not(unix))]
+ let _ = (&api_tx, &api_server);
Ok(Self {
app,
+ #[cfg(unix)]
api_tx,
+ #[cfg(unix)]
api_server,
+ #[cfg(unix)]
client_listener: listener,
client_socket_path: client_path,
client_socket_identity,
clients: HashMap::new(),
+ #[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
server_keybindings,
@@ -342,6 +410,7 @@ impl HeadlessServer {
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
handoff_in_progress: false,
+ #[cfg(unix)]
pending_handoff_repaint_nudge: false,
should_quit,
server_event_rx,
@@ -891,7 +960,7 @@ impl HeadlessServer {
} else {
let _ = std::fs::remove_file(crate::api::socket_path());
}
- let _ = remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity);
+ let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity);
if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
match self.wait_then_restore_public_sockets_after_failed_handoff() {
@@ -972,10 +1041,10 @@ impl HeadlessServer {
let client_path = client_socket_path();
prepare_socket_path(&client_path)?;
- let listener = UnixListener::bind(&client_path)?;
+ let listener = bind_local_listener(&client_path)?;
restrict_socket_permissions(&client_path)?;
let client_socket_identity = socket_file_identity(&client_path)?;
- listener.set_nonblocking(true)?;
+ listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
self.api_server = Some(api_server);
self.client_listener = listener;
@@ -1188,6 +1257,7 @@ impl HeadlessServer {
}
/// 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);
@@ -1200,6 +1270,13 @@ impl HeadlessServer {
)
}
+ /// 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.
///
/// Returns true if any input was processed (requiring a re-render).
@@ -1863,6 +1940,7 @@ impl HeadlessServer {
}
}
+ #[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 {
@@ -1961,6 +2039,68 @@ impl HeadlessServer {
}
/// Handles a server event. Returns true if the event requires a re-render.
+ fn handle_client_input_events(
+ &mut self,
+ client_id: u64,
+ events: Vec<crate::raw_input::RawInputEvent>,
+ ) -> bool {
+ let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
+ &events,
+ self.app.state.redraw_on_focus_gained,
+ );
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ if host_surface_redraw {
+ client.request_full_redraw();
+ client.render_pending = true;
+ } else {
+ // Ensure semantic clients receive one post-input frame even if the
+ // semantic buffer compares equal. Terminal-ANSI clients must keep their
+ // server-side blit baseline; resetting it here forces a full redraw on
+ // every keypress and makes remote sessions feel extremely slow.
+ client.request_semantic_redraw_after_input();
+ }
+ }
+ self.update_client_outer_focus_from_events(client_id, &events);
+ let interaction = events_include_interaction(&events);
+ let foreground_changed = if interaction {
+ self.promote_client_to_foreground(client_id)
+ } else {
+ false
+ };
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size_before_input();
+ }
+ let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
+ self.app
+ .route_client_events(events, self.foreground_client_id == Some(client_id));
+ if self.app.take_config_reloaded_from_disk() {
+ self.reload_server_config(false);
+ } else {
+ self.sync_foreground_client_state();
+ }
+
+ if self.app.state.detach_requested {
+ self.app.state.detach_requested = false;
+ info!(client_id, "client detach requested via keybind");
+
+ self.send_client_graphics_cleanup(client_id);
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some("detached".to_owned()),
+ },
+ );
+
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.writer = None;
+ }
+
+ false
+ } else {
+ foreground_changed || theme_changed || interaction
+ }
+ }
+
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) {
return false;
@@ -2079,78 +2219,27 @@ impl HeadlessServer {
} else {
Vec::new()
};
- let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
- &events,
- self.app.state.redraw_on_focus_gained,
- );
- if let Some(client) = self.clients.get_mut(&client_id) {
- if host_surface_redraw {
- client.request_full_redraw();
- client.render_pending = true;
- } else {
- // Ensure semantic clients receive one post-input frame even if the
- // semantic buffer compares equal. Terminal-ANSI clients must keep their
- // server-side blit baseline; resetting it here forces a full redraw on
- // every keypress and makes remote sessions feel extremely slow.
- client.request_semantic_redraw_after_input();
- }
- }
- self.update_client_outer_focus_from_events(client_id, &events);
- let interaction = events_include_interaction(&events);
- let foreground_changed = if interaction {
- self.promote_client_to_foreground(client_id)
- } else {
- false
- };
- if foreground_changed {
- self.resize_shared_runtime_to_effective_size_before_input();
- }
- let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
- self.app
- .route_client_events(events, self.foreground_client_id == Some(client_id));
- if self.app.take_config_reloaded_from_disk() {
- self.reload_server_config(false);
- } else {
- self.sync_foreground_client_state();
- }
-
- // Check if the detach keybind was triggered during input processing.
- if self.app.state.detach_requested {
- self.app.state.detach_requested = false;
- info!(client_id, "client detach requested via keybind");
-
- // Clear client-local host graphics before sending ServerShutdown
- // so the outer terminal does not retain stale images.
- self.send_client_graphics_cleanup(client_id);
-
- // Send a ServerShutdown with "detached" reason to this client
- // so it exits cleanly (not with a connection-lost error).
- // The client will close its connection after receiving this,
- // which triggers a ClientDisconnected event that removes it.
- self.send_to_client(
+ self.handle_client_input_events(client_id, events)
+ }
+ ServerEvent::ClientInputEvents { client_id, events } => {
+ if self.handoff_in_progress {
+ debug!(
client_id,
- ServerMessage::ServerShutdown {
- reason: Some("detached".to_owned()),
- },
+ len = events.len(),
+ "ignored client input events during handoff"
);
-
- // Don't remove the client here — let the client disconnect
- // naturally after receiving the ServerShutdown. The client's
- // read loop will see EOF and the server will get a
- // ClientDisconnected event which handles cleanup.
- //
- // However, we do need to stop sending frames to this client
- // since it's detaching. Drop the writer channel so no more
- // frames are queued for this client.
- if let Some(client) = self.clients.get_mut(&client_id) {
- client.writer = None;
- }
-
- // No re-render needed for remaining clients.
- false
- } else {
- foreground_changed || theme_changed || interaction
+ return false;
}
+ debug!(
+ client_id,
+ len = events.len(),
+ "client input events received"
+ );
+ let events = events
+ .iter()
+ .map(crate::protocol::ClientInputEvent::to_raw_input_event)
+ .collect();
+ self.handle_client_input_events(client_id, events)
}
ServerEvent::ClientClipboardImage {
client_id,
@@ -3241,7 +3330,7 @@ impl HeadlessServer {
/// Removes socket files created by the server.
fn cleanup_sockets(&self) -> io::Result<()> {
if let Err(err) =
- remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity)
+ remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity)
{
if err.kind() != io::ErrorKind::NotFound {
warn!(
@@ -3389,6 +3478,7 @@ pub fn run_server() -> io::Result<()> {
api_rx,
event_hub,
);
+ seed_startup_workspace_if_empty(&mut app);
// The server runs headless — disable local notification side effects.
// Sound and terminal notifications are forwarded to connected clients
@@ -3427,6 +3517,36 @@ pub fn run_server() -> io::Result<()> {
result
}
+fn seed_startup_workspace_if_empty(app: &mut app::App) {
+ let Some(cwd) = take_startup_cwd() else {
+ return;
+ };
+
+ if !app.state.workspaces.is_empty() {
+ info!(
+ cwd = %cwd.display(),
+ "restored session already has workspaces; ignoring startup cwd"
+ );
+ return;
+ }
+
+ match app.create_workspace_with_options(cwd.clone(), true) {
+ Ok(_) => {
+ info!(cwd = %cwd.display(), "created startup workspace");
+ }
+ Err(err) => {
+ warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace");
+ app.state.mode = app::Mode::Navigate;
+ }
+ }
+}
+
+fn take_startup_cwd() -> Option<PathBuf> {
+ let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?;
+ std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR);
+ (!cwd.is_empty()).then(|| PathBuf::from(cwd))
+}
+
#[cfg(unix)]
fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> {
let loaded_config = config::Config::load();
@@ -3499,14 +3619,13 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
#[cfg(unix)]
fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> {
- use std::os::unix::net::UnixStream;
-
let deadline = Instant::now() + timeout;
let api_socket = api::socket_path();
let client_socket = client_socket_path();
while Instant::now() < deadline {
- let api_open = api_socket.exists() && UnixStream::connect(&api_socket).is_ok();
- let client_open = client_socket.exists() && UnixStream::connect(&client_socket).is_ok();
+ let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok();
+ let client_open =
+ client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok();
if !api_open && !client_open {
return Ok(());
}
@@ -3570,23 +3689,32 @@ mod tests {
let _ = fs::create_dir_all(&dir);
let socket_path = dir.join("client.sock");
let _ = fs::remove_file(&socket_path);
- let listener = UnixListener::bind(&socket_path).expect("bind test listener");
+ let listener = bind_local_listener(&socket_path).expect("bind test listener");
let client_socket_identity =
socket_file_identity(&socket_path).expect("test listener socket identity");
+ #[cfg(unix)]
listener
- .set_nonblocking(true)
+ .set_nonblocking(ListenerNonblockingMode::Accept)
.expect("set listener nonblocking");
let (server_event_tx, server_event_rx) = mpsc::channel(64);
+ #[cfg(windows)]
+ let should_quit = Arc::new(AtomicBool::new(false));
+ #[cfg(windows)]
+ spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
let server_keybindings = app_keybindings(&app);
HeadlessServer {
app,
+ #[cfg(unix)]
api_tx: None,
+ #[cfg(unix)]
api_server: None,
+ #[cfg(unix)]
client_listener: listener,
client_socket_path: socket_path,
client_socket_identity,
clients: HashMap::new(),
+ #[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
server_keybindings,
@@ -3597,8 +3725,12 @@ mod tests {
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
handoff_in_progress: false,
+ #[cfg(unix)]
pending_handoff_repaint_nudge: false,
+ #[cfg(unix)]
should_quit: Arc::new(AtomicBool::new(false)),
+ #[cfg(windows)]
+ should_quit,
server_event_rx,
server_event_tx,
}
@@ -5186,6 +5318,107 @@ next_tab = ""
assert_eq!(server.app.state.mode, crate::app::Mode::Terminal);
}
+ #[test]
+ fn semantic_client_input_events_route_through_app_input() {
+ let mut server = test_headless_server();
+ server.app.state.mode = crate::app::Mode::Onboarding;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ Some(true),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 1,
+ events: vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }],
+ }));
+
+ assert_eq!(server.app.state.mode, crate::app::Mode::Settings);
+ assert_eq!(
+ server.app.state.settings.section,
+ crate::app::state::SettingsSection::Integrations
+ );
+ }
+
+ #[test]
+ fn semantic_client_escape_closes_keybind_help() {
+ let mut server = test_headless_server();
+ server.app.state.mode = crate::app::Mode::KeybindHelp;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (100, 30),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ Some(true),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 1,
+ events: vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }],
+ }));
+
+ assert_eq!(server.app.state.mode, crate::app::Mode::Navigate);
+ }
+
+ #[test]
+ fn semantic_client_down_scrolls_keybind_help() {
+ let mut server = test_headless_server();
+ server.app.state.mode = crate::app::Mode::KeybindHelp;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (100, 30),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ Some(true),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ assert!(server.app.state.keybind_help_max_scroll() > 0);
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 1,
+ events: vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Down,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }],
+ }));
+
+ assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp);
+ assert_eq!(server.app.state.keybind_help.scroll, 1);
+ }
+
#[tokio::test]
async fn split_default_background_response_updates_theme_without_forwarding_tail() {
let mut server = test_headless_server();
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 3386d0d7..58b552e2 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -1,4 +1,5 @@
pub mod autodetect;
+#[cfg(unix)]
pub(crate) mod client_accept;
pub(crate) mod client_transport;
pub(crate) mod clients;
diff --git a/src/server/notifications.rs b/src/server/notifications.rs
index f0b2d746..711087f5 100644
--- a/src/server/notifications.rs
+++ b/src/server/notifications.rs
@@ -64,10 +64,14 @@ fn toast_event_text(kind: app::state::ToastKind) -> &'static str {
#[cfg(test)]
mod tests {
+ #[cfg(unix)]
use super::*;
+ #[cfg(unix)]
use crate::detect::Agent;
+ #[cfg(unix)]
use crate::terminal::TerminalState;
+ #[cfg(unix)]
fn init_repo(path: &std::path::Path) {
let status = std::process::Command::new("git")
.args(["init", "-q"])
@@ -77,6 +81,7 @@ mod tests {
assert!(status.success(), "git init failed for {}", path.display());
}
+ #[cfg(unix)]
#[tokio::test]
async fn toast_message_uses_live_root_runtime_cwd_label() {
let mut state = AppState::test_new();
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index 97baf38d..e158a6d4 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -351,6 +351,9 @@ pub(crate) fn focused_terminal_cursor(
.pane_infos
.iter()
.find(|info| info.is_focused)?;
+ if !app_state.pane_exposes_host_cursor(ws_idx, info.id) {
+ return None;
+ }
let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?;
let scrolled_back = crate::ui::pane_is_scrolled_back(rt);
diff --git a/src/server/socket_paths.rs b/src/server/socket_paths.rs
index 1630ae7c..8780f932 100644
--- a/src/server/socket_paths.rs
+++ b/src/server/socket_paths.rs
@@ -82,7 +82,7 @@ pub(crate) fn restrict_socket_permissions(path: &Path) -> io::Result<()> {
crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE)
}
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::fs;