aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs53
-rw-r--r--src/api/client.rs43
-rw-r--r--src/api/server.rs60
-rw-r--r--src/api/wait.rs4
-rw-r--r--src/app/actions.rs51
-rw-r--r--src/app/agent_resume.rs40
-rw-r--r--src/app/api.rs7
-rw-r--r--src/app/api/worktrees.rs20
-rw-r--r--src/app/input/mod.rs1
-rw-r--r--src/app/input/mouse.rs1
-rw-r--r--src/app/input/navigate.rs8
-rw-r--r--src/app/input/terminal.rs9
-rw-r--r--src/app/mod.rs16
-rw-r--r--src/app/state.rs35
-rw-r--r--src/client/input.rs310
-rw-r--r--src/client/mod.rs170
-rw-r--r--src/config/io.rs52
-rw-r--r--src/detect/mod.rs154
-rw-r--r--src/events.rs6
-rw-r--r--src/handoff_runtime.rs3
-rw-r--r--src/input/mod.rs8
-rw-r--r--src/input/model.rs6
-rw-r--r--src/integration/mod.rs503
-rw-r--r--src/ipc.rs128
-rw-r--r--src/main.rs40
-rw-r--r--src/pane.rs176
-rw-r--r--src/pane/kitty_keyboard.rs1
-rw-r--r--src/pane/osc.rs166
-rw-r--r--src/pane/terminal.rs17
-rw-r--r--src/persist/restore.rs129
-rw-r--r--src/persist/snapshot.rs13
-rw-r--r--src/platform/fallback.rs2
-rw-r--r--src/platform/mod.rs31
-rw-r--r--src/platform/windows.rs590
-rw-r--r--src/protocol/wire.rs307
-rw-r--r--src/pty/actor.rs1309
-rw-r--r--src/pty/actor/unix.rs1172
-rw-r--r--src/pty/backend.rs74
-rw-r--r--src/pty/backend/unix.rs94
-rw-r--r--src/pty/mod.rs2
-rw-r--r--src/remote.rs2390
-rw-r--r--src/remote/unix.rs2496
-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
-rw-r--r--src/session.rs42
-rw-r--r--src/terminal/runtime.rs1
-rw-r--r--src/terminal/state.rs30
-rw-r--r--src/ui/mobile.rs1
-rw-r--r--src/ui/panes.rs5
-rw-r--r--src/ui/sidebar.rs1
-rw-r--r--src/update.rs19
58 files changed, 7627 insertions, 4003 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 5cbf5741..5c852dfa 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -184,8 +184,17 @@ fn valid_session_path(value: &str) -> bool {
mod tests {
use super::*;
+ fn absolute_test_path(name: &str) -> String {
+ std::env::current_dir()
+ .unwrap()
+ .join(name)
+ .display()
+ .to_string()
+ }
+
#[test]
fn planner_allows_supported_agents() {
+ let pi_session = absolute_test_path("pi-session.jsonl");
assert_eq!(
plan(
"herdr:claude",
@@ -230,11 +239,11 @@ mod tests {
plan(
"herdr:pi",
"pi",
- &AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap()
+ &AgentSessionRef::path(&pi_session).unwrap()
)
.unwrap()
.argv,
- vec!["pi", "--session", "/tmp/pi-session.jsonl"]
+ vec!["pi", "--session", pi_session.as_str()]
);
assert_eq!(
plan(
@@ -260,6 +269,7 @@ mod tests {
#[test]
fn planner_rejects_custom_and_unsupported_path_refs() {
+ let claude_session = absolute_test_path("claude-session");
assert!(plan(
"custom:claude",
"claude",
@@ -269,22 +279,25 @@ mod tests {
assert!(plan(
"herdr:claude",
"claude",
- &AgentSessionRef::path("/tmp/claude-session").unwrap()
+ &AgentSessionRef::path(&claude_session).unwrap()
)
.is_none());
}
#[test]
fn report_ref_prefers_pi_path_and_validates_values() {
+ let pi_session = absolute_test_path("pi-session.jsonl");
+ let claude_session = absolute_test_path("claude-session");
+ let copilot_session = absolute_test_path("copilot-session");
let session_ref = session_ref_from_report(
"herdr:pi",
"pi",
Some("pi-id".into()),
- Some("/tmp/pi-session.jsonl".into()),
+ Some(pi_session.clone()),
)
.unwrap();
assert_eq!(session_ref.kind, AgentSessionRefKind::Path);
- assert_eq!(session_ref.value, "/tmp/pi-session.jsonl");
+ assert_eq!(session_ref.value, pi_session);
assert!(session_ref_from_report("herdr:pi", "pi", Some("bad\nid".into()), None).is_none());
assert!(
@@ -292,26 +305,19 @@ mod tests {
.is_none()
);
assert!(session_ref_from_report("custom:pi", "pi", Some("pi-id".into()), None).is_none());
- assert!(session_ref_from_report(
- "herdr:claude",
- "claude",
- None,
- Some("/tmp/claude-session".into())
- )
- .is_none());
+ assert!(
+ session_ref_from_report("herdr:claude", "claude", None, Some(claude_session)).is_none()
+ );
let session_ref =
session_ref_from_report("herdr:copilot", "copilot", Some("copilot-id".into()), None)
.unwrap();
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
assert_eq!(session_ref.value, "copilot-id");
- assert!(session_ref_from_report(
- "herdr:copilot",
- "copilot",
- None,
- Some("/tmp/copilot-session".into())
- )
- .is_none());
+ assert!(
+ session_ref_from_report("herdr:copilot", "copilot", None, Some(copilot_session))
+ .is_none()
+ );
let session_ref =
session_ref_from_report("herdr:droid", "droid", Some("droid-id".into()), None).unwrap();
@@ -343,22 +349,25 @@ mod tests {
#[test]
fn planner_rejects_path_refs_for_id_only_agents() {
+ let hermes_session = absolute_test_path("hermes-session");
+ let opencode_session = absolute_test_path("opencode-session");
+ let copilot_session = absolute_test_path("copilot-session");
assert!(plan(
"herdr:hermes",
"hermes",
- &AgentSessionRef::path("/tmp/hermes-session").unwrap()
+ &AgentSessionRef::path(&hermes_session).unwrap()
)
.is_none());
assert!(plan(
"herdr:opencode",
"opencode",
- &AgentSessionRef::path("/tmp/opencode-session").unwrap()
+ &AgentSessionRef::path(&opencode_session).unwrap()
)
.is_none());
assert!(plan(
"herdr:copilot",
"copilot",
- &AgentSessionRef::path("/tmp/copilot-session").unwrap()
+ &AgentSessionRef::path(&copilot_session).unwrap()
)
.is_none());
assert!(session_ref_from_snapshot(
diff --git a/src/api/client.rs b/src/api/client.rs
index 7d966965..38baf807 100644
--- a/src/api/client.rs
+++ b/src/api/client.rs
@@ -1,15 +1,16 @@
use std::fmt;
use std::io::{self, BufRead, BufReader, Write};
-use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Duration;
+use interprocess::local_socket::traits::Stream as _;
use serde::de::DeserializeOwned;
use crate::api::schema::{
ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult,
SubscriptionEventEnvelope, SuccessResponse,
};
+use crate::ipc::LocalStream;
/// API connection target resolved by clients at the process edge.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -66,8 +67,8 @@ impl ApiClient {
timeout: Duration,
) -> Result<serde_json::Value, ApiClientError> {
let mut stream = self.connect()?;
- stream.set_write_timeout(Some(timeout))?;
- stream.set_read_timeout(Some(timeout))?;
+ set_timeout_best_effort(&stream, TimeoutKind::Send, timeout)?;
+ set_timeout_best_effort(&stream, TimeoutKind::Recv, timeout)?;
write_request(&mut stream, request)?;
let mut reader = BufReader::new(stream);
@@ -97,7 +98,7 @@ impl ApiClient {
let mut stream = self.connect()?;
write_request(&mut stream, request)?;
if let Some(timeout) = read_timeout {
- stream.set_read_timeout(Some(timeout))?;
+ set_timeout_best_effort(&stream, TimeoutKind::Recv, timeout)?;
}
let mut reader = BufReader::new(stream);
@@ -124,13 +125,35 @@ impl ApiClient {
}
}
- fn connect(&self) -> io::Result<UnixStream> {
- UnixStream::connect(self.socket_path())
+ fn connect(&self) -> io::Result<LocalStream> {
+ crate::ipc::connect_local_stream(&self.socket_path())
+ }
+}
+
+enum TimeoutKind {
+ Send,
+ Recv,
+}
+
+fn set_timeout_best_effort(
+ stream: &LocalStream,
+ kind: TimeoutKind,
+ timeout: Duration,
+) -> io::Result<()> {
+ let result = match kind {
+ TimeoutKind::Send => stream.set_send_timeout(Some(timeout)),
+ TimeoutKind::Recv => stream.set_recv_timeout(Some(timeout)),
+ };
+ match result {
+ Ok(()) => Ok(()),
+ #[cfg(windows)]
+ Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(()),
+ Err(err) => Err(err),
}
}
pub struct EventStream {
- reader: BufReader<UnixStream>,
+ reader: BufReader<LocalStream>,
}
impl EventStream {
@@ -181,7 +204,7 @@ impl From<serde_json::Error> for ApiClientError {
}
}
-fn write_request(stream: &mut UnixStream, request: &Request) -> Result<(), ApiClientError> {
+fn write_request(stream: &mut LocalStream, request: &Request) -> Result<(), ApiClientError> {
stream.write_all(serde_json::to_string(request)?.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
@@ -189,7 +212,7 @@ fn write_request(stream: &mut UnixStream, request: &Request) -> Result<(), ApiCl
}
fn read_json_line<T: DeserializeOwned>(
- reader: &mut BufReader<UnixStream>,
+ reader: &mut BufReader<LocalStream>,
) -> Result<T, ApiClientError> {
let mut line = String::new();
let read = reader.read_line(&mut line)?;
@@ -200,7 +223,7 @@ fn read_json_line<T: DeserializeOwned>(
}
fn read_optional_json_line<T: DeserializeOwned>(
- reader: &mut BufReader<UnixStream>,
+ reader: &mut BufReader<LocalStream>,
) -> Result<Option<T>, ApiClientError> {
let mut line = String::new();
let read = reader.read_line(&mut line)?;
diff --git a/src/api/server.rs b/src/api/server.rs
index e2c4b4a1..94e8c725 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -1,13 +1,13 @@
use std::io::{self, Read, Write};
-use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
+use interprocess::local_socket::traits::{ListenerExt as _, Stream as _};
use tracing::{debug, error, info, warn};
-#[cfg(test)]
+#[cfg(all(test, unix))]
use std::fs;
use crate::api::schema::{
@@ -16,7 +16,10 @@ use crate::api::schema::{
use crate::api::subscriptions::ActiveSubscription;
use crate::api::wait::wait_for_output;
use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub};
-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, LocalStream,
+ SocketFileIdentity,
+};
const SOCKET_PERMISSION_MODE: u32 = 0o600;
pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -46,7 +49,7 @@ impl Drop for ServerHandle {
impl ServerHandle {
pub(crate) fn remove_socket_file_if_owned(&self) -> std::io::Result<()> {
- remove_socket_file_if_owned(&self.path, self.identity)
+ remove_socket_file_if_owned(&self.path, &self.identity)
}
}
@@ -57,7 +60,9 @@ pub fn start_server(
start_server_with_capabilities(
api_tx,
event_hub,
- Some(ServerCapabilities { live_handoff: true }),
+ Some(ServerCapabilities {
+ live_handoff: crate::platform::capabilities().live_handoff,
+ }),
)
}
@@ -69,7 +74,7 @@ pub fn start_server_with_capabilities(
let path = socket_path();
prepare_socket_path(&path)?;
- let listener = UnixListener::bind(&path)?;
+ let listener = bind_local_listener(&path)?;
restrict_socket_permissions(&path)?;
let identity = socket_file_identity(&path)?;
info!(path = %path.display(), "api server listening");
@@ -127,13 +132,13 @@ fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> {
}
fn handle_connection(
- mut stream: UnixStream,
+ mut stream: LocalStream,
api_tx: &ApiRequestSender,
event_hub: &EventHub,
running: &Arc<AtomicBool>,
capabilities: Option<ServerCapabilities>,
) -> std::io::Result<()> {
- if let Err(err) = stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)) {
+ if let Err(err) = stream.set_send_timeout(Some(STREAM_WRITE_TIMEOUT)) {
debug!(err = %err, "api connection write timeout unavailable");
}
@@ -340,7 +345,7 @@ fn api_response_outcome(response: &str) -> &'static str {
}
}
-fn read_initial_request_line(stream: &mut UnixStream) -> std::io::Result<Option<String>> {
+fn read_initial_request_line(stream: &mut LocalStream) -> std::io::Result<Option<String>> {
stream.set_nonblocking(true)?;
let deadline = Instant::now() + INITIAL_REQUEST_TIMEOUT;
let mut bytes = Vec::new();
@@ -387,7 +392,7 @@ fn read_initial_request_line(stream: &mut UnixStream) -> std::io::Result<Option<
}
fn stream_subscriptions(
- mut stream: UnixStream,
+ mut stream: LocalStream,
request_id: String,
params: crate::api::schema::EventsSubscribeParams,
api_tx: &ApiRequestSender,
@@ -444,27 +449,30 @@ fn stream_subscriptions(
}
}
-fn write_text_line(stream: &mut UnixStream, value: &str) -> std::io::Result<()> {
+fn write_text_line(stream: &mut LocalStream, value: &str) -> std::io::Result<()> {
stream.write_all(value.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()
}
-fn write_text_line_allow_disconnect(stream: &mut UnixStream, value: &str) -> std::io::Result<()> {
+fn write_text_line_allow_disconnect(stream: &mut LocalStream, value: &str) -> std::io::Result<()> {
match write_text_line(stream, value) {
Err(err) if is_connection_closed_error(&err) => Ok(()),
result => result,
}
}
-fn write_json_line<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> std::io::Result<()> {
+fn write_json_line<T: serde::Serialize>(
+ stream: &mut LocalStream,
+ value: &T,
+) -> std::io::Result<()> {
let encoded = serde_json::to_string(value)
.map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?;
write_text_line(stream, &encoded)
}
fn write_json_line_allow_disconnect<T: serde::Serialize>(
- stream: &mut UnixStream,
+ stream: &mut LocalStream,
value: &T,
) -> std::io::Result<()> {
let encoded = serde_json::to_string(value)
@@ -473,7 +481,7 @@ fn write_json_line_allow_disconnect<T: serde::Serialize>(
}
pub(super) fn should_stop_connection(
- stream: &mut UnixStream,
+ stream: &mut LocalStream,
running: &Arc<AtomicBool>,
) -> std::io::Result<bool> {
if !running.load(Ordering::Relaxed) {
@@ -483,7 +491,7 @@ pub(super) fn should_stop_connection(
probe_stream_closed(stream)
}
-fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result<bool> {
+fn probe_stream_closed(stream: &mut LocalStream) -> std::io::Result<bool> {
stream.set_nonblocking(true)?;
let mut probe = [0u8; 1];
let status = match stream.read(&mut probe) {
@@ -581,11 +589,13 @@ fn error_response_json(id: String, code: &str, message: String) -> String {
})
}
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
use super::*;
+ use interprocess::local_socket::traits::Listener as _;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
+ use std::os::unix::net::UnixListener;
use std::sync::{Mutex, OnceLock};
use tokio::sync::mpsc;
@@ -602,13 +612,21 @@ mod tests {
std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
}
- fn read_line(stream: &mut UnixStream) -> String {
+ fn read_line(stream: &mut LocalStream) -> String {
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
line
}
+ fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, 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 socket_path_prefers_explicit_env_override() {
let _guard = env_lock().lock().unwrap();
@@ -770,7 +788,7 @@ mod tests {
}
});
- let (mut client, server) = UnixStream::pair().unwrap();
+ let (mut client, server, _path) = local_stream_pair("api-wait-disconnect");
client
.write_all(br#"{"id":"req_wait","method":"pane.wait_for_output","params":{"pane_id":"pane_1","source":"recent","match":{"type":"substring","value":"never"}}}"#)
.unwrap();
@@ -800,7 +818,7 @@ mod tests {
#[test]
fn subscriptions_stop_when_client_disconnects() {
let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
- let (mut client, server) = UnixStream::pair().unwrap();
+ let (mut client, server, _path) = local_stream_pair("api-sub-disconnect");
client
.write_all(
br#"{"id":"sub_1","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#,
@@ -832,7 +850,7 @@ mod tests {
#[test]
fn subscriptions_stop_when_server_shuts_down() {
let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
- let (mut client, server) = UnixStream::pair().unwrap();
+ let (mut client, server, _path) = local_stream_pair("api-sub-shutdown");
client
.write_all(
br#"{"id":"sub_2","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#,
diff --git a/src/api/wait.rs b/src/api/wait.rs
index 4bce14f2..cbb39b58 100644
--- a/src/api/wait.rs
+++ b/src/api/wait.rs
@@ -1,4 +1,3 @@
-use std::os::unix::net::UnixStream;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
@@ -13,11 +12,12 @@ use crate::api::server::{
};
use crate::api::subscriptions::{match_output, output_match_read_source};
use crate::api::ApiRequestSender;
+use crate::ipc::LocalStream;
pub(super) fn wait_for_output(
request_id: String,
params: crate::api::schema::PaneWaitForOutputParams,
- stream: &mut UnixStream,
+ stream: &mut LocalStream,
api_tx: &ApiRequestSender,
running: &Arc<AtomicBool>,
) -> std::io::Result<Option<String>> {
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 06ffe51b..371457a8 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2334,6 +2334,25 @@ impl AppState {
// Intercepted in App::handle_internal_event before reaching this
// dispatch; never touches AppState.
AppEvent::ClipboardWrite { .. } => Vec::new(),
+ AppEvent::TerminalCwdReported { pane_id, cwd } => {
+ if !cwd.is_absolute() || !cwd.is_dir() {
+ return Vec::new();
+ }
+ let Some(terminal_id) = self.workspaces.iter().find_map(|ws| {
+ ws.pane_state(pane_id)
+ .map(|pane| pane.attached_terminal_id.clone())
+ }) else {
+ return Vec::new();
+ };
+ let Some(terminal) = self.terminals.get_mut(&terminal_id) else {
+ return Vec::new();
+ };
+ if terminal.cwd != cwd {
+ terminal.cwd = cwd;
+ self.mark_session_dirty();
+ }
+ Vec::new()
+ }
AppEvent::GitStatusRefreshed {
results,
cache_updates,
@@ -4282,6 +4301,9 @@ mod tests {
fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() {
let mut state = app_with_workspaces(&["active"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let test_dir = std::env::current_dir().unwrap();
+ let first_session = test_dir.join("one.jsonl").display().to_string();
+ let second_session = test_dir.join("two.jsonl").display().to_string();
let first_updates = state.handle_app_event(AppEvent::HookStateReported {
pane_id,
@@ -4291,7 +4313,7 @@ mod tests {
message: None,
custom_status: None,
seq: Some(20),
- session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/one.jsonl"),
+ session_ref: crate::agent_resume::AgentSessionRef::path(first_session),
});
assert_eq!(first_updates.len(), 1);
state.session_dirty = false;
@@ -4304,7 +4326,7 @@ mod tests {
message: None,
custom_status: None,
seq: Some(21),
- session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/two.jsonl"),
+ session_ref: crate::agent_resume::AgentSessionRef::path(second_session),
});
assert!(second_updates.is_empty());
@@ -4312,6 +4334,31 @@ mod tests {
}
#[test]
+ fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() {
+ let mut state = app_with_workspaces(&["active"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let terminal_id = state.workspaces[0]
+ .pane_state(pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ let cwd =
+ std::env::temp_dir().join(format!("herdr-cwd-report-test-{}", std::process::id()));
+ std::fs::create_dir_all(&cwd).unwrap();
+ state.session_dirty = false;
+
+ let updates = state.handle_app_event(AppEvent::TerminalCwdReported {
+ pane_id,
+ cwd: cwd.clone(),
+ });
+
+ assert!(updates.is_empty());
+ assert_eq!(state.terminals.get(&terminal_id).unwrap().cwd, cwd);
+ assert!(state.session_dirty);
+ let _ = std::fs::remove_dir_all(cwd);
+ }
+
+ #[test]
fn background_idle_sets_finished_toast() {
let mut state = app_with_workspaces(&["active", "background"]);
state.active = Some(0);
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs
index 8ff6cc4d..894b0b37 100644
--- a/src/app/agent_resume.rs
+++ b/src/app/agent_resume.rs
@@ -339,6 +339,7 @@ fn shell_quote(value: &str) -> String {
mod tests {
use super::*;
+ #[cfg(unix)]
fn test_app() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
App::new(
@@ -350,6 +351,21 @@ mod tests {
)
}
+ #[cfg(unix)]
+ fn long_running_test_argv() -> Vec<String> {
+ vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()]
+ }
+
+ #[cfg(unix)]
+ fn marker_resume_test_argv() -> Vec<String> {
+ vec![
+ "/bin/sh".into(),
+ "-c".into(),
+ "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(),
+ ]
+ }
+
+ #[cfg(unix)]
#[tokio::test]
async fn pending_agent_resume_waits_for_host_theme_before_launch() {
let mut app = test_app();
@@ -371,11 +387,7 @@ mod tests {
.expect("test terminal should exist");
terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec![
- "/bin/sh".into(),
- "-c".into(),
- "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(),
- ],
+ argv: marker_resume_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
@@ -432,6 +444,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[tokio::test]
async fn pending_agent_resume_can_launch_after_theme_wait_expires() {
let mut app = test_app();
@@ -451,7 +464,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
@@ -465,6 +478,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_launches_hidden_panes_with_current_terminal_area() {
let mut app = test_app();
@@ -500,7 +514,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"),
});
}
@@ -520,6 +534,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_launches_inactive_tab_panes_with_current_terminal_area() {
let mut app = test_app();
@@ -562,7 +577,7 @@ mod tests {
.expect("inactive tab terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0inactive-tab-session".into(),
});
@@ -583,6 +598,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_launches_zoom_hidden_active_tab_panes() {
let mut app = test_app();
@@ -620,7 +636,7 @@ mod tests {
.expect("hidden zoom pane terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0zoom-hidden-session".into(),
});
@@ -641,6 +657,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_uses_current_terminal_area_for_background_panes() {
let mut app = test_app();
@@ -676,7 +693,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
@@ -699,6 +716,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[tokio::test]
async fn pending_agent_resume_launches_with_inner_rect_size() {
let mut app = test_app();
@@ -734,7 +752,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
diff --git a/src/app/api.rs b/src/app/api.rs
index 4bb89a77..166ea789 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -117,8 +117,12 @@ impl App {
} else {
None
};
+ let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
+ if terminal_cwd_reported {
+ self.mark_git_status_refresh_due(Instant::now());
+ }
for update in &pane_updates {
self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
self.emit_pane_state_update(update);
@@ -811,6 +815,7 @@ mod tests {
use super::*;
use crate::detect::{Agent, AgentState};
+ #[cfg(unix)]
fn init_repo(path: &std::path::Path) {
let status = std::process::Command::new("git")
.args(["init", "-q"])
@@ -820,6 +825,7 @@ mod tests {
assert!(status.success(), "git init failed for {}", path.display());
}
+ #[cfg(unix)]
#[tokio::test]
async fn herdr_toast_context_uses_live_root_runtime_cwd_label() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -912,6 +918,7 @@ mod tests {
let _ = std::fs::remove_dir_all(temp_root);
}
+ #[cfg(unix)]
#[tokio::test]
async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
index eb45a6f2..d6a269ca 100644
--- a/src/app/api/worktrees.rs
+++ b/src/app/api/worktrees.rs
@@ -898,8 +898,19 @@ mod tests {
App::new(&Config::default(), true, None, api_rx, event_hub)
}
+ #[cfg(windows)]
+ fn test_shell() -> &'static str {
+ "C:\\Windows\\System32\\whoami.exe"
+ }
+
+ #[cfg(not(windows))]
+ fn test_shell() -> &'static str {
+ "/usr/bin/true"
+ }
+
fn app_with_parent(repo: &Path) -> App {
let mut app = test_app();
+ app.state.default_shell = test_shell().into();
let mut parent = Workspace::test_new("main");
parent.identity_cwd = repo.to_path_buf();
app.state.workspaces = vec![parent];
@@ -954,6 +965,9 @@ mod tests {
);
assert!(workspace.worktree.unwrap().is_linked_worktree);
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
let remove =
crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
crate::worktree::run_worktree_command(&remove).unwrap();
@@ -968,6 +982,7 @@ mod tests {
let event_hub = crate::api::EventHub::default();
let mut app = test_app_with_event_hub(event_hub.clone());
app.state.worktree_directory = worktree_root.clone();
+ app.state.default_shell = test_shell().into();
let response = app.handle_api_request(Request {
id: "req".into(),
@@ -1000,6 +1015,9 @@ mod tests {
"auto-created parent workspace event should include parent worktree membership"
);
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
let remove =
crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
crate::worktree::run_worktree_command(&remove).unwrap();
@@ -1230,7 +1248,7 @@ mod tests {
let repo = create_committed_repo("api-worktree-open-source-repo");
let event_hub = crate::api::EventHub::default();
let mut app = test_app_with_event_hub(event_hub.clone());
- app.state.default_shell = "/usr/bin/true".into();
+ app.state.default_shell = test_shell().into();
let response = app.handle_api_request(Request {
id: "req".into(),
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index e8761a35..59fa1ccd 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -526,6 +526,7 @@ fn unique_temp_path(name: &str) -> std::path::PathBuf {
}
#[cfg(test)]
+#[cfg(unix)]
fn wait_for_file(path: &std::path::Path) -> String {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline {
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index ae10c0fa..e69bb2c1 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -2519,6 +2519,7 @@ mod tests {
assert_eq!(app.state.workspaces[0].display_name(), "a");
}
+ #[cfg(unix)]
#[tokio::test]
async fn keyboard_context_menu_split_keeps_new_runtime() {
let mut app = app_for_mouse_test();
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 8dacc5c9..9d861f66 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -988,12 +988,15 @@ fn shell_quote(value: &str) -> String {
#[cfg(test)]
mod tests {
+ #[cfg(unix)]
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Direction;
- use super::super::{state_with_workspaces, unique_temp_path, wait_for_file};
+ #[cfg(unix)]
+ use super::super::wait_for_file;
+ use super::super::{state_with_workspaces, unique_temp_path};
use super::*;
use crate::{
app::App, config::Config, input::TerminalKey, terminal::TerminalState, workspace::Workspace,
@@ -1897,6 +1900,7 @@ last_pane = "prefix+tab"
assert_eq!(state.workspaces.len(), 2);
}
+ #[cfg(unix)]
#[tokio::test]
async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -1946,6 +1950,7 @@ last_pane = "prefix+tab"
let _ = std::fs::remove_file(output_path);
}
+ #[cfg(unix)]
#[tokio::test]
async fn pane_overlay_command_opens_and_closes_after_exit() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -2033,6 +2038,7 @@ last_pane = "prefix+tab"
}
}
+ #[cfg(unix)]
#[tokio::test]
async fn edit_scrollback_key_opens_focused_runtime_scrollback_in_editor_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index c60a9f48..60f2d4cc 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -197,9 +197,9 @@ mod tests {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
- use super::super::{
- app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file,
- };
+ use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
+ #[cfg(unix)]
+ use super::super::{unique_temp_path, wait_for_file};
use super::*;
use crate::{config::Config, events::AppEvent, workspace::Workspace};
@@ -738,6 +738,7 @@ mod tests {
assert_eq!(app.state.mode, Mode::Terminal);
}
+ #[cfg(unix)]
#[tokio::test]
async fn terminal_direct_edit_scrollback_opens_editor_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -791,6 +792,7 @@ mod tests {
let _ = std::fs::remove_file(output_path);
}
+ #[cfg(unix)]
#[tokio::test]
async fn direct_custom_command_runs_before_forwarding_to_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -827,6 +829,7 @@ mod tests {
let _ = std::fs::remove_file(output_path);
}
+ #[cfg(unix)]
#[tokio::test]
async fn direct_custom_pane_command_opens_overlay_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 6a387c9a..3a038dee 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1511,6 +1511,16 @@ mod tests {
)
}
+ #[cfg(windows)]
+ fn exiting_test_command() -> &'static str {
+ "C:\\Windows\\System32\\whoami.exe"
+ }
+
+ #[cfg(not(windows))]
+ fn exiting_test_command() -> &'static str {
+ "/usr/bin/true"
+ }
+
#[derive(Clone, Default)]
struct FakePrefixInputSource {
switch_calls: Rc<Cell<usize>>,
@@ -3112,7 +3122,7 @@ mod tests {
async fn pane_split_request_targets_pane_in_background_tab() {
let _guard = config_env_lock().lock().unwrap();
let original_shell = std::env::var_os("SHELL");
- std::env::set_var("SHELL", "/usr/bin/true");
+ std::env::set_var("SHELL", exiting_test_command());
let mut app = test_app();
let mut workspace = Workspace::test_new("api-pane-split-background-tab");
@@ -3209,7 +3219,7 @@ mod tests {
async fn pane_split_request_focuses_new_pane_when_requested() {
let _guard = config_env_lock().lock().unwrap();
let original_shell = std::env::var_os("SHELL");
- std::env::set_var("SHELL", "/usr/bin/true");
+ std::env::set_var("SHELL", exiting_test_command());
let mut app = test_app();
let mut workspace = Workspace::test_new("api-pane-split-focus-background-tab");
@@ -3363,7 +3373,7 @@ mod tests {
tab_id: None,
split: Some(crate::api::schema::SplitDirection::Right),
focus: true,
- argv: vec!["/usr/bin/true".into()],
+ argv: vec![exiting_test_command().into()],
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
diff --git a/src/app/state.rs b/src/app/state.rs
index 494bee0f..32cf087d 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1419,6 +1419,22 @@ impl AppState {
self.switch_ascii_input_source_in_prefix
}
+ pub(crate) fn pane_exposes_host_cursor(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> bool {
+ let has_effective_agent_label = self
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.terminal_id(pane_id))
+ .and_then(|terminal_id| self.terminals.get(terminal_id))
+ .and_then(crate::terminal::TerminalState::effective_agent_label)
+ .is_some();
+
+ pane_exposes_host_cursor_for_target(has_effective_agent_label, cfg!(windows))
+ }
+
pub(crate) fn integration_updates_available(&self) -> bool {
self.integration_recommendations
.iter()
@@ -1734,12 +1750,31 @@ impl AppState {
}
}
+fn pane_exposes_host_cursor_for_target(
+ has_effective_agent_label: bool,
+ target_is_windows: bool,
+) -> bool {
+ !target_is_windows || !has_effective_agent_label
+}
+
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
#[test]
+ fn windows_agent_panes_do_not_expose_host_cursor() {
+ assert!(!pane_exposes_host_cursor_for_target(true, true));
+ assert!(pane_exposes_host_cursor_for_target(false, true));
+ }
+
+ #[test]
+ fn non_windows_agent_panes_keep_existing_host_cursor_behavior() {
+ assert!(pane_exposes_host_cursor_for_target(true, false));
+ assert!(pane_exposes_host_cursor_for_target(false, false));
+ }
+
+ #[test]
fn built_in_theme_names_resolve() {
for name in THEME_NAMES {
assert!(
diff --git a/src/client/input.rs b/src/client/input.rs
index e9592db8..bbc37a58 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -1,22 +1,24 @@
//! Stdin input reading for the thin client.
//!
-//! Reads stdin bytes and forwards framed input to the main event loop.
-//! Unlike the monolithic herdr, the thin client does NOT parse input into
-//! key/mouse/paste events. It keeps enough byte-framing state to avoid splitting
-//! terminal control strings, then sends bytes to the server as `ClientMessage::Input`.
-//! The server handles semantic parsing.
+//! On Unix, reads stdin bytes and forwards framed input to the main event loop.
+//! The server handles semantic parsing. On Windows, crossterm may surface
+//! terminal control strings as character key events, so the reader re-frames
+//! those control bytes before forwarding semantic client input events.
//!
//! This is simpler and more reliable because:
//! - The server has the same input parsing code
//! - We avoid duplicating parsing logic in the client
//! - Host terminal control replies can be buffered or discarded before they leak
-use std::io::{self, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(unix)]
+use std::io::{self, Read};
+#[cfg(unix)]
use std::os::fd::AsRawFd;
+#[cfg(windows)]
+use std::time::Duration;
use tokio::sync::mpsc;
use super::ClientLoopEvent;
@@ -31,6 +33,15 @@ use super::ClientLoopEvent;
/// The main loop receives the raw bytes and forwards them as
/// `ClientMessage::Input` to the server.
pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
+ #[cfg(windows)]
+ return windows_stdin_reader_loop(event_tx, should_quit);
+
+ #[cfg(unix)]
+ unix_stdin_reader_loop(event_tx, should_quit);
+}
+
+#[cfg(unix)]
+fn unix_stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
let stdin = io::stdin();
let mut reader = stdin.lock();
let mut scratch = [0u8; 4096];
@@ -70,16 +81,179 @@ pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &
}
}
+#[cfg(windows)]
+fn windows_stdin_reader_loop(
+ event_tx: mpsc::Sender<ClientLoopEvent>,
+ should_quit: &Arc<AtomicBool>,
+) {
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ let mut raw_sequence_pending = false;
+
+ while !should_quit.load(Ordering::Acquire) {
+ match crossterm::event::poll(Duration::from_millis(10)) {
+ Ok(true) => {}
+ Ok(false) => {
+ if raw_sequence_pending {
+ tracing::debug!("windows input raw sequence timed out; flushing");
+ if !send_windows_raw_events(framer.flush_timeout(), &event_tx) {
+ return;
+ }
+ raw_sequence_pending = false;
+ }
+ continue;
+ }
+ Err(_) => break,
+ }
+
+ let event = match crossterm::event::read() {
+ Ok(event) => event,
+ Err(_) => break,
+ };
+
+ if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) {
+ tracing::debug!(
+ bytes = ?bytes,
+ pending_before = raw_sequence_pending,
+ "windows input routed through raw framer"
+ );
+ let events = framer.push(&bytes);
+ raw_sequence_pending = events.is_empty();
+ if !send_windows_raw_events(events, &event_tx) {
+ return;
+ }
+ continue;
+ }
+
+ if raw_sequence_pending {
+ tracing::debug!("windows input raw sequence interrupted by semantic event; flushing");
+ if !send_windows_raw_events(framer.flush_timeout(), &event_tx) {
+ return;
+ }
+ raw_sequence_pending = false;
+ }
+
+ if windows_event_is_control_key(&event) {
+ tracing::debug!(event = ?event, "windows control key forwarded as semantic input");
+ }
+
+ let Some(event) = crate::protocol::ClientInputEvent::from_crossterm(event) else {
+ continue;
+ };
+ if event_tx
+ .blocking_send(ClientLoopEvent::StdinEvents(vec![event]))
+ .is_err()
+ {
+ return;
+ }
+ }
+
+ if raw_sequence_pending {
+ let _ = send_windows_raw_events(framer.flush_timeout(), &event_tx);
+ }
+}
+
+#[cfg(windows)]
+fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool {
+ use crossterm::event::{Event, KeyModifiers};
+
+ matches!(
+ event,
+ Event::Key(key)
+ if key.modifiers.contains(KeyModifiers::CONTROL)
+ || matches!(key.code, crossterm::event::KeyCode::Char(ch) if ch.is_control())
+ )
+}
+
+#[cfg(windows)]
+fn windows_key_raw_bytes(
+ event: &crossterm::event::Event,
+ raw_sequence_pending: bool,
+) -> Option<Vec<u8>> {
+ use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
+
+ let Event::Key(key) = event else {
+ return None;
+ };
+ if key.kind == KeyEventKind::Release {
+ return None;
+ }
+
+ match key.code {
+ KeyCode::Esc if key.modifiers.is_empty() => Some(vec![0x1b]),
+ KeyCode::Char(ch) if raw_sequence_pending || ch.is_control() => {
+ let mut bytes = Vec::new();
+ if key.modifiers.contains(KeyModifiers::ALT) {
+ bytes.push(0x1b);
+ }
+ let mut buf = [0; 4];
+ bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
+ Some(bytes)
+ }
+ _ => None,
+ }
+}
+
+#[cfg(windows)]
+fn send_windows_raw_events(
+ events: Vec<crate::raw_input::RawInputEvent>,
+ event_tx: &mpsc::Sender<ClientLoopEvent>,
+) -> bool {
+ let raw_event_count = events.len();
+ let events = events
+ .into_iter()
+ .filter_map(windows_client_input_event_from_raw)
+ .collect::<Vec<_>>();
+ if events.is_empty() {
+ return true;
+ }
+
+ tracing::debug!(
+ raw_event_count,
+ forwarded_event_count = events.len(),
+ "windows raw-framed input events forwarded"
+ );
+ event_tx
+ .blocking_send(ClientLoopEvent::StdinEvents(events))
+ .is_ok()
+}
+
+#[cfg(windows)]
+fn windows_client_input_event_from_raw(
+ event: crate::raw_input::RawInputEvent,
+) -> Option<crate::protocol::ClientInputEvent> {
+ match event {
+ crate::raw_input::RawInputEvent::Key(key) => Some(crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::from_crossterm(key.code)?,
+ modifiers: key.modifiers.bits(),
+ kind: crate::protocol::ClientKeyKind::from_crossterm(key.kind),
+ }),
+ crate::raw_input::RawInputEvent::Mouse(mouse) => {
+ Some(crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?,
+ column: mouse.column,
+ row: mouse.row,
+ modifiers: mouse.modifiers.bits(),
+ })
+ }
+ crate::raw_input::RawInputEvent::Paste(text) => {
+ Some(crate::protocol::ClientInputEvent::Paste { text })
+ }
+ crate::raw_input::RawInputEvent::OuterFocusGained => {
+ Some(crate::protocol::ClientInputEvent::FocusGained)
+ }
+ crate::raw_input::RawInputEvent::OuterFocusLost => {
+ Some(crate::protocol::ClientInputEvent::FocusLost)
+ }
+ crate::raw_input::RawInputEvent::HostDefaultColor { .. }
+ | crate::raw_input::RawInputEvent::Unsupported => None,
+ }
+}
+
#[cfg(unix)]
fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> {
poll_read_ready(reader.as_raw_fd(), timeout_ms)
}
-#[cfg(not(unix))]
-fn stdin_read_ready<R>(_reader: &R, _timeout_ms: i32) -> Option<bool> {
- None
-}
-
#[cfg(unix)]
fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
#[repr(C)]
@@ -113,7 +287,7 @@ fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
// Tests
// ---------------------------------------------------------------------------
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
// The stdin reader thread is hard to unit test since it reads from actual stdin.
// Integration tests will verify the full clientโ†’server input flow.
@@ -121,6 +295,7 @@ mod tests {
use super::*;
+ #[cfg(unix)]
#[test]
fn stdin_input_event_carries_raw_bytes() {
let data = vec![0x1b, b'[', b'A']; // Up arrow escape sequence
@@ -131,3 +306,114 @@ mod tests {
}
}
}
+
+#[cfg(all(test, windows))]
+mod windows_tests {
+ use super::*;
+ use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
+
+ #[test]
+ fn windows_control_chars_are_reframed_as_raw_bytes() {
+ let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
+ assert_eq!(
+ windows_key_raw_bytes(&escape, false).as_deref(),
+ Some(b"\x1b".as_slice())
+ );
+
+ let enter = Event::Key(KeyEvent::new(KeyCode::Char('\r'), KeyModifiers::empty()));
+ assert_eq!(
+ windows_key_raw_bytes(&enter, false).as_deref(),
+ Some(b"\r".as_slice())
+ );
+
+ let printable = Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()));
+ assert_eq!(windows_key_raw_bytes(&printable, false), None);
+
+ let pending_arrow_tail =
+ Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::empty()));
+ assert_eq!(
+ windows_key_raw_bytes(&pending_arrow_tail, true).as_deref(),
+ Some(b"[".as_slice())
+ );
+ }
+
+ #[test]
+ fn windows_ctrl_d_semantic_event_encodes_to_eot() {
+ let event = Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
+ assert_eq!(windows_key_raw_bytes(&event, false), None);
+
+ let event =
+ crate::protocol::ClientInputEvent::from_crossterm(event).expect("ctrl-d converts");
+ let raw = event.to_raw_input_event();
+ let crate::raw_input::RawInputEvent::Key(key) = raw else {
+ panic!("expected key");
+ };
+ assert_eq!(key.code, KeyCode::Char('d'));
+ assert_eq!(key.modifiers, KeyModifiers::CONTROL);
+ assert_eq!(
+ crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
+ b"\x04"
+ );
+ }
+
+ #[test]
+ fn windows_eot_control_char_normalizes_to_ctrl_d() {
+ let event = Event::Key(KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::empty()));
+ let bytes = windows_key_raw_bytes(&event, false).expect("eot routes through raw framer");
+ assert_eq!(bytes, b"\x04");
+
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ let events = framer.push(&bytes);
+ assert_eq!(events.len(), 1);
+
+ let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
+ .expect("raw eot converts");
+ assert_eq!(
+ event,
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('d'),
+ modifiers: KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }
+ );
+ }
+
+ #[test]
+ fn windows_pending_escape_sequence_converts_to_semantic_arrow() {
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ assert!(framer.push(b"\x1b").is_empty());
+ assert!(framer.push(b"[").is_empty());
+ let events = framer.push(b"A");
+ assert_eq!(events.len(), 1);
+
+ let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
+ .expect("raw arrow converts");
+ assert_eq!(
+ event,
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Up,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }
+ );
+ }
+
+ #[test]
+ fn windows_bare_escape_flushes_to_semantic_escape() {
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ assert!(framer.push(b"\x1b").is_empty());
+ let events = framer.flush_timeout();
+ assert_eq!(events.len(), 1);
+
+ let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
+ .expect("raw escape converts");
+ assert_eq!(
+ event,
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }
+ );
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 564b5e27..a895dae0 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -16,25 +16,31 @@ mod input;
use std::collections::HashSet;
use std::io::{self, Write as _};
-use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
- EnableFocusChange, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind,
- PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+ EnableFocusChange, EnableMouseCapture,
};
+#[cfg(unix)]
+use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
+#[cfg(not(windows))]
+use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
use crossterm::execute;
+use interprocess::local_socket::traits::Stream as _;
+use interprocess::TryClone as _;
use tracing::{debug, info, warn};
+use crate::ipc::LocalStream;
use crate::protocol::render_ansi;
use crate::protocol::{
- self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
- ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
- MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientKeybindings, ClientLaunchMode, ClientMessage, NotifyKind, RenderEncoding,
+ ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
+#[cfg(unix)]
+use crate::protocol::{AttachScrollDirection, AttachScrollSource, MAX_CLIPBOARD_IMAGE_PAYLOAD};
use crate::server::socket_paths::client_socket_path;
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
@@ -58,17 +64,24 @@ struct ClientState {
/// Direct attach prefix escape state. None for full-app clients.
attach_escape: Option<AttachEscapeState>,
/// Rows scrolled for one direct-attach wheel notch.
+ #[cfg(unix)]
mouse_scroll_lines: usize,
/// Whether outer focus gain should force a full host-terminal redraw.
redraw_on_focus_gained: bool,
}
#[derive(Debug, Default)]
+#[cfg(windows)]
+struct AttachEscapeState;
+
+#[derive(Debug, Default)]
+#[cfg(unix)]
struct AttachEscapeState {
pending_prefix: bool,
}
#[derive(Debug)]
+#[cfg(unix)]
enum AttachInputAction {
Forward(Vec<u8>),
Scroll {
@@ -84,6 +97,7 @@ enum AttachInputAction {
}
impl AttachEscapeState {
+ #[cfg(unix)]
fn filter_input(
&mut self,
data: Vec<u8>,
@@ -126,6 +140,7 @@ impl AttachEscapeState {
}
}
+#[cfg(unix)]
fn attach_scroll_action(
data: &[u8],
viewport_rows: u16,
@@ -304,20 +319,16 @@ fn setup_terminal_with_capabilities(
if enable_client_protocols {
if mouse_capture {
- execute!(io::stdout(), EnableMouseCapture)?;
+ set_mouse_capture(true)?;
} else {
- execute!(io::stdout(), DisableMouseCapture)?;
+ set_mouse_capture(false)?;
}
- execute!(
- io::stdout(),
- EnableBracketedPaste,
- EnableFocusChange,
- PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
- )?;
+ execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
+ push_keyboard_enhancement_flags()?;
} else if mouse_capture {
- execute!(io::stdout(), EnableMouseCapture)?;
+ set_mouse_capture(true)?;
} else {
- execute!(io::stdout(), DisableMouseCapture)?;
+ set_mouse_capture(false)?;
}
let modify_other_keys_mode = enable_client_protocols
@@ -354,7 +365,12 @@ fn set_mouse_capture(enabled: bool) -> io::Result<()> {
if enabled {
execute!(io::stdout(), EnableMouseCapture)
} else {
- execute!(io::stdout(), DisableMouseCapture)
+ match execute!(io::stdout(), DisableMouseCapture) {
+ Ok(()) => Ok(()),
+ #[cfg(windows)]
+ Err(err) if err.to_string() == "Initial console modes not set" => Ok(()),
+ Err(err) => Err(err),
+ }
}
}
@@ -367,9 +383,9 @@ fn restore_terminal_state(reset_modify_other_keys: bool) {
let _ = io::stdout().flush();
}
+ let _ = pop_keyboard_enhancement_flags();
let _ = execute!(
io::stdout(),
- PopKeyboardEnhancementFlags,
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
@@ -378,6 +394,29 @@ fn restore_terminal_state(reset_modify_other_keys: bool) {
let _ = write_terminal_restore_postlude(&mut io::stdout());
}
+#[cfg(not(windows))]
+fn push_keyboard_enhancement_flags() -> io::Result<()> {
+ execute!(
+ io::stdout(),
+ PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
+ )
+}
+
+#[cfg(windows)]
+fn push_keyboard_enhancement_flags() -> io::Result<()> {
+ Ok(())
+}
+
+#[cfg(not(windows))]
+fn pop_keyboard_enhancement_flags() -> io::Result<()> {
+ execute!(io::stdout(), PopKeyboardEnhancementFlags)
+}
+
+#[cfg(windows)]
+fn pop_keyboard_enhancement_flags() -> io::Result<()> {
+ Ok(())
+}
+
impl Drop for TerminalGuard {
fn drop(&mut self) {
restore_terminal_state(self.reset_modify_other_keys);
@@ -414,7 +453,7 @@ fn requested_keybindings() -> ClientKeybindings {
/// Sends Hello with the terminal size and protocol version, reads the Welcome
/// response. Returns Ok(()) on success, or an error if the server rejects us.
fn do_handshake(
- stream: &mut UnixStream,
+ stream: &mut LocalStream,
cols: u16,
rows: u16,
cell_width_px: u32,
@@ -445,13 +484,13 @@ fn do_handshake(
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
// Read Welcome.
- stream
- .set_read_timeout(Some(Duration::from_secs(5)))
- .map_err(ClientError::ConnectionFailed)?;
+ if let Err(err) = stream.set_recv_timeout(Some(Duration::from_secs(5))) {
+ debug!(err = %err, "client handshake read timeout unavailable");
+ }
let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
- stream
- .set_read_timeout(None)
- .map_err(ClientError::ConnectionFailed)?;
+ if let Err(err) = stream.set_recv_timeout(None) {
+ debug!(err = %err, "failed to clear client handshake read timeout");
+ }
match welcome {
ServerMessage::Welcome {
@@ -478,7 +517,11 @@ fn do_handshake(
/// Internal events for the client event loop.
enum ClientLoopEvent {
/// Raw input bytes from stdin.
+ #[cfg(unix)]
StdinInput(Vec<u8>),
+ /// Structured input events from platforms without Unix-style stdin bytes.
+ #[cfg(windows)]
+ StdinEvents(Vec<crate::protocol::ClientInputEvent>),
/// Terminal resize detected.
Resize(u16, u16, u32, u32),
/// Server message received.
@@ -503,6 +546,7 @@ pub fn run_client() -> io::Result<()> {
}
/// Runs a direct terminal attach client.
+#[cfg(unix)]
pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> {
run_client_with_mode(
RenderEncoding::TerminalAnsi,
@@ -512,6 +556,16 @@ pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()
)
}
+/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path.
+#[cfg(windows)]
+pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> {
+ debug_assert!(!crate::platform::capabilities().direct_terminal_attach);
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "direct terminal attach is not supported on Windows yet",
+ ))
+}
+
fn run_client_with_mode(
requested_encoding: RenderEncoding,
attach_request: Option<(String, bool)>,
@@ -521,6 +575,7 @@ fn run_client_with_mode(
init_logging();
let loaded_config = crate::config::Config::load();
+ let mouse_capture = loaded_config.config.ui.mouse_capture;
let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines();
let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
let sound_config = loaded_config.config.ui.sound;
@@ -533,7 +588,7 @@ fn run_client_with_mode(
info!(path = %socket_path.display(), "{log_message}");
// Try to connect to the server.
- let mut stream = match UnixStream::connect(&socket_path) {
+ let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
Ok(s) => s,
Err(err) => {
// Server unreachable โ€” show clear error and exit.
@@ -578,10 +633,10 @@ fn run_client_with_mode(
// Now set up the terminal. This must happen AFTER the handshake succeeds,
// so we don't leave the terminal in raw mode if the server rejects us.
let direct_attach = attach_escape.is_some();
- let _guard = if direct_attach {
+ let terminal_guard = if direct_attach {
setup_direct_attach_terminal()
} else {
- setup_terminal(false)
+ setup_terminal(mouse_capture)
}
.map_err(|err| {
eprintln!("herdr: failed to set up terminal: {err}");
@@ -589,10 +644,10 @@ fn run_client_with_mode(
})?;
// Install a panic hook to restore the terminal on panic (same as monolithic).
- let in_tmux = std::env::var("TMUX").is_ok();
+ let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
- restore_terminal_state(in_tmux);
+ restore_terminal_state(panic_resets_modify_other_keys);
original_hook(info);
}));
@@ -620,7 +675,7 @@ fn run_client_with_mode(
mouse_scroll_lines,
redraw_on_focus_gained,
kitty_graphics_enabled,
- false,
+ mouse_capture,
negotiated_encoding,
attach_escape,
)
@@ -628,7 +683,7 @@ fn run_client_with_mode(
});
// Restore the terminal before printing any final status message.
- drop(_guard);
+ drop(terminal_guard);
if let Err(err) = result {
eprintln!("herdr: {err}");
@@ -660,7 +715,7 @@ fn run_client_with_mode(
/// - server reader thread โ†’ reads ServerMessages and sends to main loop
/// - main loop: coordinates input, output, and server communication
async fn run_client_loop(
- stream: UnixStream,
+ stream: LocalStream,
cols: u16,
rows: u16,
should_quit: Arc<AtomicBool>,
@@ -672,6 +727,9 @@ async fn run_client_loop(
negotiated_encoding: RenderEncoding,
attach_escape: Option<AttachEscapeState>,
) -> Result<(), ClientError> {
+ #[cfg(windows)]
+ let _ = mouse_scroll_lines;
+
let mut state = ClientState {
blit_encoder: render_ansi::BlitEncoder::new(),
mouse_capture_active,
@@ -679,6 +737,7 @@ async fn run_client_loop(
sound_config,
kitty_graphics_enabled,
attach_escape,
+ #[cfg(unix)]
mouse_scroll_lines,
redraw_on_focus_gained,
};
@@ -694,7 +753,7 @@ async fn run_client_loop(
input::stdin_reader_loop(stdin_tx, &stdin_quit);
});
- if state.attach_escape.is_none() {
+ if state.attach_escape.is_none() && should_query_host_terminal_theme() {
query_host_terminal_theme();
}
@@ -739,6 +798,7 @@ async fn run_client_loop(
};
match event {
+ #[cfg(unix)]
ClientLoopEvent::StdinInput(data) => {
let data = if let Some(attach_escape) = &mut state.attach_escape {
match attach_escape.filter_input(
@@ -817,6 +877,26 @@ async fn run_client_loop(
return Err(ClientError::ConnectionLost(e));
}
}
+ #[cfg(windows)]
+ ClientLoopEvent::StdinEvents(events) => {
+ if state.attach_escape.is_some() {
+ continue;
+ }
+ let raw_events = events
+ .iter()
+ .map(crate::protocol::ClientInputEvent::to_raw_input_event)
+ .collect::<Vec<_>>();
+ if crate::raw_input::events_require_host_surface_redraw(
+ &raw_events,
+ state.redraw_on_focus_gained,
+ ) {
+ state.request_full_redraw();
+ }
+ let msg = ClientMessage::InputEvents { events };
+ if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ return Err(ClientError::ConnectionLost(e));
+ }
+ }
ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => {
state.reported_size = (new_cols, new_rows);
let msg = ClientMessage::Resize {
@@ -917,7 +997,7 @@ async fn run_client_loop(
/// Blocking thread that reads ServerMessages from the server and sends them
/// to the main event loop.
fn server_reader_thread(
- mut stream: UnixStream,
+ mut stream: LocalStream,
event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
max_frame_size: usize,
@@ -970,7 +1050,7 @@ fn server_reader_thread(
// ---------------------------------------------------------------------------
/// Writes a message to the server stream (blocking).
-fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<()> {
+fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> {
protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string()))
}
@@ -1063,6 +1143,7 @@ fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
}
}
+#[cfg(unix)]
fn should_bridge_clipboard_image_paste(data: &[u8]) -> bool {
if data == b"\x1b[200~\x1b[201~" {
return true;
@@ -1254,6 +1335,10 @@ fn query_host_terminal_theme() {
let _ = write_host_terminal_theme_query(io::stdout());
}
+fn should_query_host_terminal_theme() -> bool {
+ !cfg!(windows)
+}
+
fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
writer.write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes())?;
writer.flush()
@@ -1330,6 +1415,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() {
assert!(should_bridge_clipboard_image_paste(&[0x16]));
@@ -1400,12 +1486,18 @@ mod tests {
}
#[test]
+ fn host_terminal_theme_query_is_disabled_on_windows() {
+ assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
+ }
+
+ #[test]
fn terminal_restore_postlude_restores_visible_default_cursor() {
let mut output = Vec::new();
write_terminal_restore_postlude(&mut output).unwrap();
assert_eq!(output, b"\x1b[?25h\x1b[0 q");
}
+ #[cfg(unix)]
#[test]
fn attach_escape_detaches_on_prefix_q() {
let mut escape = AttachEscapeState::default();
@@ -1419,6 +1511,7 @@ mod tests {
));
}
+ #[cfg(unix)]
#[test]
fn attach_escape_sends_literal_prefix_on_double_prefix() {
let mut escape = AttachEscapeState::default();
@@ -1432,6 +1525,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_forwards_prefix_before_non_escape_key() {
let mut escape = AttachEscapeState::default();
@@ -1445,6 +1539,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_turns_wheel_into_scroll_action() {
let mut escape = AttachEscapeState::default();
@@ -1467,6 +1562,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_swallows_non_wheel_mouse_reports() {
let mut escape = AttachEscapeState::default();
@@ -1476,6 +1572,7 @@ mod tests {
));
}
+ #[cfg(unix)]
#[test]
fn attach_escape_turns_plain_page_keys_into_scroll_actions() {
let mut escape = AttachEscapeState::default();
@@ -1518,6 +1615,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_forwards_modified_page_key() {
let mut escape = AttachEscapeState::default();
diff --git a/src/config/io.rs b/src/config/io.rs
index 1ea928a3..1bfabf96 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -15,23 +15,71 @@ pub fn app_dir_name() -> &'static str {
pub fn config_dir() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(dir).join(app_dir_name())
+ } else if cfg!(windows) {
+ windows_config_dir()
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(format!(".config/{}", app_dir_name()))
} else {
- PathBuf::from(format!("/tmp/{}", app_dir_name()))
+ std::env::temp_dir().join(app_dir_name())
}
}
pub fn state_dir() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_STATE_HOME") {
PathBuf::from(dir).join(app_dir_name())
+ } else if cfg!(windows) {
+ windows_state_dir()
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(format!(".local/state/{}", app_dir_name()))
} else {
- PathBuf::from(format!("/tmp/{}-state", app_dir_name()))
+ std::env::temp_dir().join(format!("{}-state", app_dir_name()))
}
}
+#[cfg(windows)]
+fn windows_config_dir() -> PathBuf {
+ if let Ok(dir) = std::env::var("APPDATA") {
+ return PathBuf::from(dir).join(app_dir_name());
+ }
+ if let Ok(profile) = std::env::var("USERPROFILE") {
+ return PathBuf::from(profile)
+ .join("AppData")
+ .join("Roaming")
+ .join(app_dir_name());
+ }
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home).join(format!(".config/{}", app_dir_name()));
+ }
+ std::env::temp_dir().join(app_dir_name())
+}
+
+#[cfg(not(windows))]
+fn windows_config_dir() -> PathBuf {
+ unreachable!("windows_config_dir is only called on Windows")
+}
+
+#[cfg(windows)]
+fn windows_state_dir() -> PathBuf {
+ if let Ok(dir) = std::env::var("LOCALAPPDATA") {
+ return PathBuf::from(dir).join(app_dir_name());
+ }
+ if let Ok(profile) = std::env::var("USERPROFILE") {
+ return PathBuf::from(profile)
+ .join("AppData")
+ .join("Local")
+ .join(app_dir_name());
+ }
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home).join(format!(".local/state/{}", app_dir_name()));
+ }
+ std::env::temp_dir().join(format!("{}-state", app_dir_name()))
+}
+
+#[cfg(not(windows))]
+fn windows_state_dir() -> PathBuf {
+ unreachable!("windows_state_dir is only called on Windows")
+}
+
impl Config {
pub fn load() -> LoadedConfig {
let path = config_path();
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 06536336..902ae999 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -375,17 +375,96 @@ fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> Stri
fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>) -> Option<String> {
let argv = argv?;
- let runtime = path_basename(runtime).to_lowercase();
+ let runtime = normalized_agent_lookup_name(path_basename(runtime));
match runtime.as_str() {
"node" | "bun" => script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]),
"python" | "python3" => script_arg_agent_name(argv, &["-c"], &["-m"]),
"sh" | "bash" | "zsh" | "fish" => script_arg_agent_name(argv, &["-c"], &[]),
+ "cmd" => windows_cmd_arg_agent_name(argv),
+ "powershell" | "pwsh" => powershell_arg_agent_name(argv),
"tmux" => None,
_ => None,
}
}
+fn windows_cmd_arg_agent_name(argv: &[String]) -> Option<String> {
+ let mut args = argv.iter().skip(1);
+ while let Some(arg) = args.next() {
+ let flag = arg.trim_matches('"').to_lowercase();
+ match flag.as_str() {
+ "/c" | "/k" => {
+ return args
+ .next()
+ .and_then(|command| command_text_agent_name(command))
+ }
+ "/d" | "/s" | "/q" | "/a" | "/u" | "/e:on" | "/e:off" | "/f:on" | "/f:off"
+ | "/v:on" | "/v:off" => continue,
+ _ => {}
+ }
+ }
+ None
+}
+
+fn powershell_arg_agent_name(argv: &[String]) -> Option<String> {
+ let mut args = argv.iter().skip(1);
+ while let Some(arg) = args.next() {
+ let flag = arg.trim_matches('"').to_lowercase();
+ match flag.as_str() {
+ "-file" | "-f" | "/file" => {
+ return args
+ .next()
+ .and_then(|path| agent_name_from_path_token(path));
+ }
+ "-command" | "-c" | "/command" | "/c" => {
+ return args
+ .next()
+ .and_then(|command| command_text_agent_name(command));
+ }
+ "-encodedcommand" | "-enc" | "/encodedcommand" | "/enc" => return None,
+ "-configurationname" | "-executionpolicy" | "-outputformat" | "-psconsolefile"
+ | "-version" | "-windowstyle" | "-workingdirectory" => {
+ let _ = args.next();
+ }
+ _ if flag.starts_with('-') || flag.starts_with('/') => {}
+ _ => return agent_name_from_path_token(arg),
+ }
+ }
+ None
+}
+
+fn command_text_agent_name(command: &str) -> Option<String> {
+ let mut rest = command;
+ while let Some((token, next)) = command_text_token(rest) {
+ let token = token.trim();
+ if token.eq_ignore_ascii_case("&")
+ || token.eq_ignore_ascii_case(".")
+ || token.eq_ignore_ascii_case("call")
+ {
+ rest = next;
+ continue;
+ }
+ return agent_name_from_path_token(token);
+ }
+ None
+}
+
+fn command_text_token(input: &str) -> Option<(&str, &str)> {
+ let input = input.trim_start();
+ let first = input.chars().next()?;
+ if first == '"' || first == '\'' {
+ let start = first.len_utf8();
+ if let Some(end) = input[start..].find(first) {
+ let end = start + end;
+ return Some((&input[start..end], &input[end + first.len_utf8()..]));
+ }
+ return Some((&input[start..], ""));
+ }
+
+ let end = input.find(char::is_whitespace).unwrap_or(input.len());
+ Some((&input[..end], &input[end..]))
+}
+
fn script_arg_agent_name(
argv: &[String],
eval_flags: &[&str],
@@ -488,16 +567,18 @@ fn agent_name_from_basename(basename: &str) -> Option<String> {
fn normalized_agent_lookup_name(name: &str) -> String {
let mut name = name.trim().to_lowercase();
- if name.ends_with(".exe") {
- name.truncate(name.len() - ".exe".len());
+ for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] {
+ if name.ends_with(suffix) {
+ name.truncate(name.len() - suffix.len());
+ break;
+ }
}
name
}
fn path_basename(path: &str) -> &str {
- std::path::Path::new(path)
- .file_name()
- .and_then(|name| name.to_str())
+ path.rsplit(['/', '\\'])
+ .find(|component| !component.is_empty())
.unwrap_or(path)
}
@@ -513,9 +594,20 @@ fn process_priority(process: &crate::platform::ForegroundProcess, normalized_nam
}
fn is_generic_runtime_or_shell(name: &str) -> bool {
+ let name = normalized_agent_lookup_name(path_basename(name));
matches!(
- name,
- "sh" | "bash" | "zsh" | "fish" | "tmux" | "node" | "bun" | "python" | "python3"
+ name.as_str(),
+ "sh" | "bash"
+ | "zsh"
+ | "fish"
+ | "tmux"
+ | "node"
+ | "bun"
+ | "python"
+ | "python3"
+ | "cmd"
+ | "powershell"
+ | "pwsh"
)
}
@@ -541,6 +633,7 @@ mod tests {
}
}
+ #[cfg(unix)]
fn temp_detection_path(name: &str) -> std::path::PathBuf {
let unique = format!(
"herdr-detect-tests-{}-{}-{}",
@@ -787,6 +880,51 @@ mod tests {
}
#[test]
+ fn identify_agent_in_job_detects_windows_cmd_wrapped_codex() {
+ let job = crate::platform::ForegroundJob {
+ process_group_id: 123,
+ processes: vec![foreground_process(
+ 1,
+ "cmd.exe",
+ &[
+ "cmd.exe",
+ "/D",
+ "/S",
+ "/C",
+ "C:\\Users\\herdr\\AppData\\Roaming\\npm\\codex.cmd --model gpt-5",
+ ],
+ )],
+ };
+
+ assert_eq!(
+ identify_agent_in_job(&job),
+ Some((Agent::Codex, "codex".to_string()))
+ );
+ }
+
+ #[test]
+ fn identify_agent_in_job_detects_powershell_file_wrapped_claude() {
+ let job = crate::platform::ForegroundJob {
+ process_group_id: 123,
+ processes: vec![foreground_process(
+ 1,
+ "powershell.exe",
+ &[
+ "powershell.exe",
+ "-NoProfile",
+ "-File",
+ "C:\\Users\\herdr\\Documents\\PowerShell\\Scripts\\claude.ps1",
+ ],
+ )],
+ };
+
+ assert_eq!(
+ identify_agent_in_job(&job),
+ Some((Agent::Claude, "claude".to_string()))
+ );
+ }
+
+ #[test]
fn identify_agent_in_job_detects_opencode_exe_from_pnpm_package() {
let job = crate::platform::ForegroundJob {
process_group_id: 123,
diff --git a/src/events.rs b/src/events.rs
index 2b33bebb..b4d49d10 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -96,6 +96,12 @@ pub enum AppEvent {
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
/// re-emits it through herdr's own clipboard writer.
ClipboardWrite { content: Vec<u8> },
+ /// A pane child reported its shell current directory through terminal
+ /// metadata such as OSC 7.
+ TerminalCwdReported {
+ pane_id: PaneId,
+ cwd: std::path::PathBuf,
+ },
/// Background git status refresh completed for workspaces.
GitStatusRefreshed {
results: Vec<WorkspaceGitStatus>,
diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs
index 331cff07..6d7b82fe 100644
--- a/src/handoff_runtime.rs
+++ b/src/handoff_runtime.rs
@@ -28,9 +28,10 @@ impl HandoffRuntimeState {
}
}
-#[cfg(unix)]
#[derive(Debug)]
pub(crate) struct ImportedHandoffRuntime {
+ #[cfg(unix)]
pub master_fd: std::os::fd::RawFd,
+ #[cfg(unix)]
pub state: HandoffRuntimeState,
}
diff --git a/src/input/mod.rs b/src/input/mod.rs
index ab7192c7..3882b035 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -4,10 +4,12 @@ mod parse;
#[allow(unused_imports)]
pub use encode::{
- encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
+ encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
};
+#[cfg(not(windows))]
+pub use model::ime_compatible_keyboard_enhancement_flags;
pub use model::{
- host_modify_other_keys_mode, ime_compatible_keyboard_enhancement_flags, KeyboardProtocol,
- MouseProtocolEncoding, MouseProtocolMode, TerminalKey,
+ host_modify_other_keys_mode, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode,
+ TerminalKey,
};
pub use parse::parse_terminal_key_sequence;
diff --git a/src/input/model.rs b/src/input/model.rs
index 777c6a3b..a439217f 100644
--- a/src/input/model.rs
+++ b/src/input/model.rs
@@ -1,4 +1,6 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, KeyboardEnhancementFlags};
+#[cfg(not(windows))]
+use crossterm::event::KeyboardEnhancementFlags;
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -41,6 +43,7 @@ impl From<KeyEvent> for TerminalKey {
}
}
+#[cfg(not(windows))]
pub fn ime_compatible_keyboard_enhancement_flags() -> KeyboardEnhancementFlags {
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
@@ -138,6 +141,7 @@ mod tests {
);
}
+ #[cfg(not(windows))]
#[test]
fn keyboard_enhancement_flags_stay_ime_compatible() {
let flags = ime_compatible_keyboard_enhancement_flags();
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index b668998d..2989ba90 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -656,29 +656,84 @@ pub(crate) fn integration_target_label(
}
fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> &'static str {
+ integration_target_command_names(target)[0]
+}
+
+fn integration_target_command_names(
+ target: crate::api::schema::IntegrationTarget,
+) -> &'static [&'static str] {
match target {
- crate::api::schema::IntegrationTarget::Pi => "pi",
- crate::api::schema::IntegrationTarget::Omp => "omp",
- crate::api::schema::IntegrationTarget::Claude => "claude",
- crate::api::schema::IntegrationTarget::Codex => "codex",
- crate::api::schema::IntegrationTarget::Copilot => "copilot",
- crate::api::schema::IntegrationTarget::Droid => "droid",
- crate::api::schema::IntegrationTarget::Kimi => "kimi",
- crate::api::schema::IntegrationTarget::Opencode => "opencode",
- crate::api::schema::IntegrationTarget::Hermes => "hermes",
- crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
+ crate::api::schema::IntegrationTarget::Pi => &["pi"],
+ crate::api::schema::IntegrationTarget::Omp => &["omp"],
+ crate::api::schema::IntegrationTarget::Claude => &["claude"],
+ crate::api::schema::IntegrationTarget::Codex => &["codex"],
+ crate::api::schema::IntegrationTarget::Copilot => &["copilot"],
+ crate::api::schema::IntegrationTarget::Droid => &["droid"],
+ crate::api::schema::IntegrationTarget::Kimi => &["kimi"],
+ crate::api::schema::IntegrationTarget::Opencode => &["opencode"],
+ crate::api::schema::IntegrationTarget::Hermes => &["hermes"],
+ crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(),
}
}
fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool {
- command_available(integration_target_command(target))
+ integration_target_command_names(target)
+ .iter()
+ .any(|command| command_available(command))
+ || integration_target_install_layout_available(target)
+}
+
+#[cfg(windows)]
+fn qodercli_command_names() -> &'static [&'static str] {
+ &["qodercli", "qoder", "qoderclicn", "qodercn"]
+}
+
+#[cfg(not(windows))]
+fn qodercli_command_names() -> &'static [&'static str] {
+ &["qodercli"]
+}
+
+fn integration_target_install_layout_available(
+ target: crate::api::schema::IntegrationTarget,
+) -> bool {
+ match target {
+ crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(),
+ crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(),
+ _ => false,
+ }
}
fn command_available(command: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
- std::env::split_paths(&paths).any(|dir| executable_file_exists(&dir.join(command)))
+ std::env::split_paths(&paths).any(|dir| {
+ command_path_candidates(&dir, command)
+ .into_iter()
+ .any(|path| executable_file_exists(&path))
+ })
+}
+
+fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> {
+ let base = dir.join(command);
+
+ #[cfg(not(windows))]
+ {
+ vec![base]
+ }
+
+ #[cfg(windows)]
+ {
+ if Path::new(command).extension().is_some() {
+ return vec![base];
+ }
+
+ let mut candidates = vec![base];
+ for extension in [".exe", ".cmd", ".bat", ".ps1"] {
+ candidates.push(dir.join(format!("{command}{extension}")));
+ }
+ candidates
+ }
}
fn executable_file_exists(path: &Path) -> bool {
@@ -701,6 +756,53 @@ fn executable_file_exists(path: &Path) -> bool {
}
}
+fn codex_standalone_binary_available() -> bool {
+ let Ok(releases_dir) =
+ codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases"))
+ else {
+ return false;
+ };
+ let Ok(entries) = fs::read_dir(releases_dir) else {
+ return false;
+ };
+
+ entries.filter_map(Result::ok).any(|entry| {
+ executable_file_exists(&entry.path().join("bin").join(codex_executable_name()))
+ })
+}
+
+fn codex_executable_name() -> &'static str {
+ if cfg!(windows) {
+ "codex.exe"
+ } else {
+ "codex"
+ }
+}
+
+fn hermes_install_layout_available() -> bool {
+ #[cfg(windows)]
+ {
+ let Some(local_app_data) =
+ std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
+ else {
+ return false;
+ };
+ let dir = PathBuf::from(local_app_data).join("hermes");
+ [
+ dir.join("hermes.exe"),
+ dir.join("bin").join("hermes.exe"),
+ dir.join("Scripts").join("hermes.exe"),
+ ]
+ .into_iter()
+ .any(|path| executable_file_exists(&path))
+ }
+
+ #[cfg(not(windows))]
+ {
+ false
+ }
+}
+
pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> {
integration_specs()
.into_iter()
@@ -897,6 +999,17 @@ pub(crate) fn install_pi() -> io::Result<PathBuf> {
pub(crate) fn install_omp() -> io::Result<OmpInstallPaths> {
let dir = omp_extension_dir()?;
if !dir.is_dir() {
+ if dir.parent().is_some_and(|parent| parent.is_dir()) {
+ fs::create_dir_all(&dir)?;
+ } else {
+ return Err(io::Error::other(format!(
+ "omp extension directory not found at {}. install omp and create the extensions directory first",
+ dir.display()
+ )));
+ }
+ }
+
+ if !dir.is_dir() {
return Err(io::Error::other(format!(
"omp extension directory not found at {}. install omp and create the extensions directory first",
dir.display()
@@ -2537,14 +2650,14 @@ fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
-fn make_executable(path: &Path) -> io::Result<()> {
+fn make_executable(_path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
- let mut perms = fs::metadata(path)?.permissions();
+ let mut perms = fs::metadata(_path)?.permissions();
perms.set_mode(0o755);
- fs::set_permissions(path, perms)?;
+ fs::set_permissions(_path, perms)?;
}
Ok(())
@@ -2638,9 +2751,28 @@ fn qodercli_dir() -> io::Result<PathBuf> {
}
fn home_dir() -> io::Result<PathBuf> {
- std::env::var("HOME")
- .map(PathBuf::from)
- .map_err(|_| io::Error::other("HOME is not set; cannot locate home directory"))
+ if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
+ return Ok(PathBuf::from(home));
+ }
+
+ #[cfg(windows)]
+ {
+ if let Some(profile) = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()) {
+ return Ok(PathBuf::from(profile));
+ }
+ if let (Some(drive), Some(path)) = (
+ std::env::var_os("HOMEDRIVE").filter(|value| !value.is_empty()),
+ std::env::var_os("HOMEPATH").filter(|value| !value.is_empty()),
+ ) {
+ let mut home = PathBuf::from(drive);
+ home.push(path);
+ return Ok(home);
+ }
+ }
+
+ Err(io::Error::other(
+ "home directory is not set; cannot locate home directory",
+ ))
}
#[cfg(test)]
@@ -2703,6 +2835,28 @@ mod tests {
))
}
+ #[cfg(windows)]
+ #[test]
+ fn home_dir_uses_userprofile_when_home_is_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let previous_home = std::env::var_os("HOME");
+ let previous_userprofile = std::env::var_os("USERPROFILE");
+ std::env::remove_var("HOME");
+ std::env::set_var("USERPROFILE", &base);
+
+ assert_eq!(home_dir().unwrap(), base);
+
+ if let Some(home) = previous_home {
+ std::env::set_var("HOME", home);
+ }
+ if let Some(userprofile) = previous_userprofile {
+ std::env::set_var("USERPROFILE", userprofile);
+ } else {
+ std::env::remove_var("USERPROFILE");
+ }
+ }
+
#[test]
#[cfg(unix)]
fn command_available_requires_executable_file_on_path() {
@@ -2732,6 +2886,162 @@ mod tests {
}
#[test]
+ #[cfg(windows)]
+ fn command_available_finds_windows_command_shims_on_path() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("PATH", &bin);
+
+ fs::write(bin.join("claude.cmd"), "@echo off\r\n").unwrap();
+ assert!(command_available("claude"));
+
+ fs::write(bin.join("codex.exe"), "").unwrap();
+ assert!(command_available("codex"));
+
+ assert!(!command_available("missing-agent"));
+
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ #[cfg(windows)]
+ fn qodercli_availability_checks_windows_aliases() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("PATH", &bin);
+
+ fs::write(bin.join("qoder.cmd"), "@echo off\r\n").unwrap();
+
+ assert!(integration_target_available(
+ crate::api::schema::IntegrationTarget::Qodercli
+ ));
+
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ #[cfg(windows)]
+ fn hermes_availability_checks_windows_install_layout() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let local_app_data = base.join("local-app-data");
+ let hermes_bin = local_app_data.join("hermes").join("bin");
+ fs::create_dir_all(&hermes_bin).unwrap();
+ fs::write(hermes_bin.join("hermes.exe"), "").unwrap();
+ let original_local_app_data = std::env::var_os("LOCALAPPDATA");
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("LOCALAPPDATA", &local_app_data);
+ std::env::set_var("PATH", "");
+
+ assert!(integration_target_available(
+ crate::api::schema::IntegrationTarget::Hermes
+ ));
+
+ if let Some(local_app_data) = original_local_app_data {
+ std::env::set_var("LOCALAPPDATA", local_app_data);
+ } else {
+ std::env::remove_var("LOCALAPPDATA");
+ }
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn codex_availability_finds_standalone_binary_under_codex_home() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let bin = home
+ .join(".codex/packages/standalone/releases/0.137.0-test")
+ .join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let binary = bin.join(codex_executable_name());
+ fs::write(&binary, "").unwrap();
+ make_executable(&binary).unwrap();
+ let original_home = std::env::var_os("HOME");
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("HOME", &home);
+ std::env::set_var("PATH", "");
+
+ assert!(integration_target_available(
+ crate::api::schema::IntegrationTarget::Codex
+ ));
+
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn integration_recommendations_mark_standalone_codex_available() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let bin = home
+ .join(".codex/packages/standalone/releases/0.137.0-test")
+ .join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let binary = bin.join(codex_executable_name());
+ fs::write(&binary, "").unwrap();
+ make_executable(&binary).unwrap();
+ let original_home = std::env::var_os("HOME");
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("HOME", &home);
+ std::env::set_var("PATH", "");
+
+ let codex = integration_recommendations()
+ .into_iter()
+ .find(|recommendation| {
+ recommendation.target == crate::api::schema::IntegrationTarget::Codex
+ })
+ .expect("codex recommendation should be present");
+
+ assert!(codex.available);
+ assert_eq!(codex.state, IntegrationStatusKind::NotInstalled);
+ assert!(codex.needs_install());
+
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
fn integration_recommendation_installs_available_or_outdated_targets() {
let mut recommendation = IntegrationRecommendation {
target: crate::api::schema::IntegrationTarget::Claude,
@@ -2906,6 +3216,29 @@ mod tests {
}
#[test]
+ fn install_omp_creates_extensions_dir_when_agent_dir_exists() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let agent_dir = home.join(".omp/agent");
+ let ext_dir = agent_dir.join("extensions");
+ fs::create_dir_all(&agent_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_omp().unwrap();
+
+ assert_eq!(
+ installed.extension_path,
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
+ );
+ assert!(ext_dir.is_dir());
+ assert!(!installed.removed_legacy_pi_extension);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
fn uninstall_omp_removes_embedded_extension_when_present() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -3133,15 +3466,41 @@ mod tests {
let hooks_dir = claude_dir.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ let settings = serde_json::json!({
+ "hooks": {
+ "PostToolUse": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-post", "timeout": 10}
+ ]
+ }],
+ "PostToolUseFailure": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-failure", "timeout": 10}
+ ]
+ }],
+ "SubagentStop": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-subagent", "timeout": 10}
+ ]
+ }],
+ "SessionEnd": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-session-end", "timeout": 10}
+ ]
+ }]
+ }
+ });
fs::write(
claude_dir.join("settings.json"),
- format!(
- r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-subagent","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}},{{"type":"command","command":"echo keep-session-end","timeout":10}}]}}]}}}}"#,
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- ),
+ serde_json::to_string(&settings).unwrap(),
)
.unwrap();
std::env::set_var("HOME", &home);
@@ -3245,19 +3604,48 @@ mod tests {
fs::create_dir_all(&hooks_dir).unwrap();
let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
fs::write(&hook_path, CLAUDE_HOOK_ASSET).unwrap();
+ let settings = serde_json::json!({
+ "hooks": {
+ "SessionStart": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
+ }],
+ "UserPromptSubmit": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep", "timeout": 10}
+ ]
+ }],
+ "PermissionRequest": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]
+ }],
+ "PostToolUse": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
+ }],
+ "PostToolUseFailure": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
+ }],
+ "SubagentStop": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
+ }],
+ "Stop": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
+ }],
+ "SessionEnd": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10}]
+ }]
+ }
+ });
fs::write(
claude_dir.join("settings.json"),
- format!(
- r#"{{"hooks":{{"SessionStart":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- ),
+ serde_json::to_string(&settings).unwrap(),
)
.unwrap();
std::env::set_var("HOME", &home);
@@ -3464,16 +3852,21 @@ mod tests {
fs::create_dir_all(&codex_dir).unwrap();
let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
fs::write(&hook_path, CODEX_HOOK_ASSET).unwrap();
+ let hooks = serde_json::json!({
+ "hooks": {
+ "SessionStart": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}],
+ "UserPromptSubmit": [{"hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep", "timeout": 10}
+ ]}],
+ "PreToolUse": [{"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]}],
+ "PermissionRequest": [{"hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]}],
+ "Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}]
+ }
+ });
fs::write(
codex_dir.join("hooks.json"),
- format!(
- r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PreToolUse":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PermissionRequest":[{{"hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"Stop":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}]}}}}"#,
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- hook_path.display(),
- ),
+ serde_json::to_string(&hooks).unwrap(),
)
.unwrap();
fs::write(
@@ -3808,14 +4201,24 @@ mod tests {
"bash {}",
shell_single_quote(&hook_path.display().to_string())
);
+ let settings = serde_json::json!({
+ "hooks": {
+ "PreToolUse": [
+ {"type": "command", "command": command, "timeoutSec": 10},
+ {"type": "command", "command": "echo keep", "timeoutSec": 10}
+ ],
+ "PostToolUse": [{"type": "command", "command": command, "timeoutSec": 10}],
+ "notification": [{
+ "type": "command",
+ "matcher": "permission_prompt|elicitation_dialog|agent_idle",
+ "command": command,
+ "timeoutSec": 10
+ }]
+ }
+ });
fs::write(
copilot_dir.join("settings.json"),
- format!(
- r#"{{"hooks":{{"PreToolUse":[{{"type":"command","command":"{}","timeoutSec":10}},{{"type":"command","command":"echo keep","timeoutSec":10}}],"PostToolUse":[{{"type":"command","command":"{}","timeoutSec":10}}],"notification":[{{"type":"command","matcher":"permission_prompt|elicitation_dialog|agent_idle","command":"{}","timeoutSec":10}}]}}}}"#,
- command,
- command,
- command,
- ),
+ serde_json::to_string(&settings).unwrap(),
)
.unwrap();
std::env::set_var("HOME", &home);
diff --git a/src/ipc.rs b/src/ipc.rs
index 5f973ccf..5c1fe1b7 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -1,14 +1,66 @@
use std::fs;
use std::io;
-use std::os::unix::fs::MetadataExt;
-use std::os::unix::fs::PermissionsExt;
-use std::os::unix::net::UnixStream;
+#[cfg(unix)]
+use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::Path;
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) type LocalListener = interprocess::local_socket::Listener;
+pub(crate) type LocalStream = interprocess::local_socket::Stream;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SocketFileIdentity {
+ #[cfg(unix)]
dev: u64,
+ #[cfg(unix)]
ino: u64,
+ #[cfg(windows)]
+ marker: Vec<u8>,
+}
+
+pub(crate) fn connect_local_stream(path: &Path) -> io::Result<LocalStream> {
+ #[cfg(unix)]
+ {
+ use interprocess::local_socket::{prelude::*, GenericFilePath};
+
+ let name = path.to_fs_name::<GenericFilePath>()?;
+ LocalStream::connect(name)
+ }
+
+ #[cfg(windows)]
+ {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced};
+
+ let name = path.to_string_lossy().to_string();
+ let name = name.to_ns_name::<GenericNamespaced>()?;
+ LocalStream::connect(name)
+ }
+}
+
+pub(crate) fn bind_local_listener(path: &Path) -> io::Result<LocalListener> {
+ #[cfg(unix)]
+ {
+ use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions};
+
+ let name = path.to_fs_name::<GenericFilePath>()?;
+ ListenerOptions::new()
+ .name(name)
+ .reclaim_name(false)
+ .create_sync()
+ }
+
+ #[cfg(windows)]
+ {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions};
+
+ let name = path.to_string_lossy().to_string();
+ let name = name.to_ns_name::<GenericNamespaced>()?;
+ let listener = ListenerOptions::new()
+ .name(name)
+ .reclaim_name(false)
+ .create_sync()?;
+ fs::write(path, windows_socket_marker())?;
+ Ok(listener)
+ }
}
pub(crate) fn prepare_socket_path(
@@ -23,7 +75,7 @@ pub(crate) fn prepare_socket_path(
return Ok(());
}
- match UnixStream::connect(path) {
+ match connect_local_stream(path) {
Ok(_) => {
return Err(io::Error::new(io::ErrorKind::AddrInUse, busy_message(path)));
}
@@ -33,6 +85,7 @@ pub(crate) fn prepare_socket_path(
io::ErrorKind::ConnectionRefused
| io::ErrorKind::NotFound
| io::ErrorKind::TimedOut
+ | io::ErrorKind::WouldBlock
) => {}
Err(err) => return Err(err),
}
@@ -47,16 +100,26 @@ pub(crate) fn prepare_socket_path(
}
pub(crate) fn socket_file_identity(path: &Path) -> io::Result<SocketFileIdentity> {
- let metadata = fs::metadata(path)?;
- Ok(SocketFileIdentity {
- dev: metadata.dev(),
- ino: metadata.ino(),
- })
+ #[cfg(windows)]
+ {
+ Ok(SocketFileIdentity {
+ marker: fs::read(path)?,
+ })
+ }
+
+ #[cfg(unix)]
+ {
+ let metadata = fs::metadata(path)?;
+ Ok(SocketFileIdentity {
+ dev: metadata.dev(),
+ ino: metadata.ino(),
+ })
+ }
}
pub(crate) fn remove_socket_file_if_owned(
path: &Path,
- identity: SocketFileIdentity,
+ identity: &SocketFileIdentity,
) -> io::Result<()> {
let current = match socket_file_identity(path) {
Ok(current) => current,
@@ -64,7 +127,7 @@ pub(crate) fn remove_socket_file_if_owned(
Err(err) => return Err(err),
};
- if current != identity {
+ if current != *identity {
return Ok(());
}
@@ -75,8 +138,49 @@ pub(crate) fn remove_socket_file_if_owned(
}
}
+#[cfg(windows)]
+fn windows_socket_marker() -> String {
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0);
+ format!("{}:{now}", std::process::id())
+}
+
+#[cfg(unix)]
pub(crate) fn restrict_socket_permissions(path: &Path, mode: u32) -> io::Result<()> {
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(mode);
fs::set_permissions(path, permissions)
}
+
+#[cfg(windows)]
+pub(crate) fn restrict_socket_permissions(_path: &Path, _mode: u32) -> io::Result<()> {
+ Ok(())
+}
+
+#[cfg(all(test, windows))]
+mod tests {
+ use super::*;
+ use std::path::PathBuf;
+
+ #[test]
+ fn remove_socket_file_if_owned_compares_windows_marker_contents() {
+ let path = temp_socket_marker_path("same-len-marker");
+ let _ = fs::remove_file(&path);
+
+ fs::write(&path, b"marker-aa").expect("write first marker");
+ let identity = socket_file_identity(&path).expect("read first identity");
+ fs::write(&path, b"marker-bb").expect("replace with same-length marker");
+
+ remove_socket_file_if_owned(&path, &identity).expect("remove owned marker");
+
+ assert!(path.exists(), "same-length replacement marker must survive");
+
+ let _ = fs::remove_file(&path);
+ }
+
+ fn temp_socket_marker_path(name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!("herdr-{name}-{}.sock", std::process::id()))
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 053ecf26..ffd11140 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,9 +2,10 @@ use std::io;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
- EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags,
- PushKeyboardEnhancementFlags,
+ EnableFocusChange, EnableMouseCapture,
};
+#[cfg(not(windows))]
+use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
use crossterm::execute;
pub(crate) const HERDR_ENV_VAR: &str = "HERDR_ENV";
@@ -18,6 +19,29 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
"recursion detected. base case not found. aborting.",
];
+#[cfg(not(windows))]
+fn push_keyboard_enhancement_flags() -> io::Result<()> {
+ execute!(
+ io::stdout(),
+ PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
+ )
+}
+
+#[cfg(windows)]
+fn push_keyboard_enhancement_flags() -> io::Result<()> {
+ Ok(())
+}
+
+#[cfg(not(windows))]
+fn pop_keyboard_enhancement_flags() -> io::Result<()> {
+ execute!(io::stdout(), PopKeyboardEnhancementFlags)
+}
+
+#[cfg(windows)]
+fn pop_keyboard_enhancement_flags() -> io::Result<()> {
+ Ok(())
+}
+
mod agent_resume;
mod api;
mod app;
@@ -632,11 +656,11 @@ fn main() -> io::Result<()> {
}
let _ = execute!(
io::stdout(),
- PopKeyboardEnhancementFlags,
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
);
+ let _ = pop_keyboard_enhancement_flags();
ratatui::restore();
original_hook(info);
}));
@@ -661,12 +685,8 @@ fn main() -> io::Result<()> {
} else {
execute!(io::stdout(), DisableMouseCapture)?;
}
- execute!(
- io::stdout(),
- EnableBracketedPaste,
- EnableFocusChange,
- PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
- )?;
+ execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
+ push_keyboard_enhancement_flags()?;
// Some hosts do not honor Kitty keyboard enhancement pushes for
// Shift+Enter. Enable xterm modifyOtherKeys only on hosts where we
@@ -696,9 +716,9 @@ fn main() -> io::Result<()> {
if crate::kitty_graphics::is_enabled() {
crate::kitty_graphics::clear_all_host_graphics()?;
}
+ pop_keyboard_enhancement_flags()?;
execute!(
io::stdout(),
- PopKeyboardEnhancementFlags,
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
diff --git a/src/pane.rs b/src/pane.rs
index 66517ab4..3861a9a0 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -8,7 +8,7 @@ use std::sync::{
use bytes::Bytes;
use portable_pty::CommandBuilder;
-#[cfg(test)]
+#[cfg(all(test, unix))]
use portable_pty::{native_pty_system, PtySize};
use ratatui::{layout::Rect, Frame};
#[cfg(test)]
@@ -19,7 +19,6 @@ use tracing::{debug, error, info, warn};
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::PaneId;
-#[cfg(unix)]
use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadResult};
mod input;
@@ -136,10 +135,12 @@ fn should_clear_agent_for_foreground_shell(
previous_agent.is_some() && new_agent.is_none() && foreground_is_pane_shell
}
+#[cfg(unix)]
fn usable_process_cwd(pid: u32) -> Option<std::path::PathBuf> {
crate::platform::process_cwd(pid).filter(|cwd| cwd.is_absolute() && cwd.is_dir())
}
+#[cfg(unix)]
fn foreground_member_cwd_different_from_shell(
shell_pid: u32,
shell_cwd: Option<&std::path::PathBuf>,
@@ -391,6 +392,7 @@ fn detection_update_for_publish(
(!detection.skip_state_update).then_some(detection)
}
+#[cfg(unix)]
fn spawn_basic_detection_task(
pane_id: PaneId,
child_pid: Arc<AtomicU32>,
@@ -652,6 +654,7 @@ pub struct PaneRuntime {
io: PaneRuntimeIo,
current_size: Cell<(u16, u16, u32, u32)>,
child_pid: Arc<AtomicU32>,
+ reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>,
child_wait_completed: Option<Arc<AtomicBool>>,
kitty_keyboard_flags: Arc<AtomicU16>,
detect_reset_notify: Arc<Notify>,
@@ -662,7 +665,6 @@ pub struct PaneRuntime {
}
enum PaneRuntimeIo {
- #[cfg(unix)]
Actor(PtyIoActorHandle),
#[cfg(test)]
TestChannel {
@@ -674,7 +676,6 @@ enum PaneRuntimeIo {
impl PaneRuntimeIo {
fn shutdown(&self) {
match self {
- #[cfg(unix)]
PaneRuntimeIo::Actor(actor) => actor.shutdown(),
#[cfg(test)]
PaneRuntimeIo::TestChannel { .. } => {}
@@ -743,7 +744,6 @@ impl PaneRuntimeIo {
terminal_responses: Vec<Bytes>,
) {
match self {
- #[cfg(unix)]
PaneRuntimeIo::Actor(actor) => {
actor.resize(
rows,
@@ -760,6 +760,7 @@ impl PaneRuntimeIo {
}
}
+ #[cfg(unix)]
fn nudge_child_redraw_after_handoff(
&self,
rows: u16,
@@ -768,7 +769,6 @@ impl PaneRuntimeIo {
cell_height_px: u32,
) {
match self {
- #[cfg(unix)]
PaneRuntimeIo::Actor(actor) => {
actor.nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px);
}
@@ -779,7 +779,6 @@ impl PaneRuntimeIo {
async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
match self {
- #[cfg(unix)]
PaneRuntimeIo::Actor(actor) => actor.write_user_input(bytes).await,
#[cfg(test)]
PaneRuntimeIo::TestChannel { sender, .. } => sender.send(bytes).await,
@@ -788,7 +787,6 @@ impl PaneRuntimeIo {
fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
match self {
- #[cfg(unix)]
PaneRuntimeIo::Actor(actor) => actor.try_write_user_input(bytes),
#[cfg(test)]
PaneRuntimeIo::TestChannel { sender, .. } => sender.try_send(bytes),
@@ -934,10 +932,27 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String
return configured_shell.to_string();
}
+ #[cfg(windows)]
+ {
+ let _ = env_shell;
+ return default_pane_shell();
+ }
+
+ #[cfg(not(windows))]
env_shell
.map(|shell| shell.trim().to_string())
.filter(|shell| !shell.is_empty())
- .unwrap_or_else(|| "/bin/sh".into())
+ .unwrap_or_else(default_pane_shell)
+}
+
+#[cfg(windows)]
+fn default_pane_shell() -> String {
+ "powershell.exe".into()
+}
+
+#[cfg(not(windows))]
+fn default_pane_shell() -> String {
+ "/bin/sh".into()
}
#[derive(Clone, Copy)]
@@ -1022,7 +1037,9 @@ fn pane_shell_command_builder_for_target(
cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?);
Ok(cmd)
} else {
- Ok(CommandBuilder::new(&shell))
+ let mut cmd = CommandBuilder::new(&shell);
+ apply_windows_powershell_cwd_reporting(&mut cmd, &shell);
+ Ok(cmd)
}
}
@@ -1030,6 +1047,67 @@ fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result<C
pane_shell_command_builder_for_target(shell_config, cfg!(target_os = "macos"))
}
+fn apply_windows_powershell_cwd_reporting(cmd: &mut CommandBuilder, shell: &str) {
+ if !is_windows_powershell_shell(shell) {
+ return;
+ }
+ cmd.arg("-NoExit");
+ cmd.arg("-Command");
+ cmd.arg(windows_powershell_cwd_prompt_wrapper());
+}
+
+fn is_windows_powershell_shell(shell: &str) -> bool {
+ #[cfg(windows)]
+ {
+ let name = Path::new(shell)
+ .file_name()
+ .and_then(std::ffi::OsStr::to_str)
+ .unwrap_or(shell)
+ .to_ascii_lowercase();
+ matches!(
+ name.as_str(),
+ "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
+ )
+ }
+ #[cfg(not(windows))]
+ {
+ let _ = shell;
+ false
+ }
+}
+
+fn windows_powershell_cwd_prompt_wrapper() -> &'static str {
+ r#"$global:__HERDR_ORIGINAL_PROMPT = if (Test-Path Function:\prompt) { (Get-Command prompt -CommandType Function).ScriptBlock } else { { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " } }; function global:prompt { try { if ($PWD.Provider.Name -eq 'FileSystem') { $uri = ([System.Uri]$PWD.ProviderPath).AbsoluteUri; [Console]::Write("$([char]27)]7;$uri$([char]7)") } } catch {}; & $global:__HERDR_ORIGINAL_PROMPT }"#
+}
+
+fn usable_reported_cwd(cwd: std::path::PathBuf) -> Option<std::path::PathBuf> {
+ (cwd.is_absolute() && cwd.is_dir()).then_some(cwd)
+}
+
+fn publish_reported_cwd(
+ pane_id: PaneId,
+ cwd: std::path::PathBuf,
+ reported_cwd: &Arc<Mutex<Option<std::path::PathBuf>>>,
+ events: &mpsc::Sender<AppEvent>,
+) {
+ let Some(cwd) = usable_reported_cwd(cwd) else {
+ return;
+ };
+ if let Ok(mut current) = reported_cwd.lock() {
+ if current.as_ref() == Some(&cwd) {
+ return;
+ }
+ *current = Some(cwd.clone());
+ }
+ if let Err(err) = events.try_send(AppEvent::TerminalCwdReported { pane_id, cwd }) {
+ warn!(
+ pane = pane_id.raw(),
+ err = %err,
+ "failed to send terminal cwd report"
+ );
+ }
+}
+
impl PaneRuntime {
pub fn shutdown(mut self) {
self.detect_handle.abort();
@@ -1319,6 +1397,7 @@ impl PaneRuntime {
}
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
let child_pid = Arc::new(AtomicU32::new(child_pid));
+ let reported_cwd = Arc::new(Mutex::new(None));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
let io = {
@@ -1328,6 +1407,7 @@ impl PaneRuntime {
let render_dirty = render_dirty.clone();
let child_pid = child_pid.clone();
let read_events = events.clone();
+ let reported_cwd = reported_cwd.clone();
let rt = tokio::runtime::Handle::current();
let delay_rt = rt.clone();
let on_read = Box::new(move |bytes: &[u8]| {
@@ -1347,6 +1427,9 @@ impl PaneRuntime {
}
});
}
+ if let Some(cwd) = result.reported_cwd.clone() {
+ publish_reported_cwd(pane_id, cwd, &reported_cwd, &read_events);
+ }
for content in result.clipboard_writes {
if let Err(err) = read_events.try_send(AppEvent::ClipboardWrite { content }) {
warn!(
@@ -1383,6 +1466,7 @@ impl PaneRuntime {
io,
current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)),
child_pid,
+ reported_cwd,
child_wait_completed: None,
kitty_keyboard_flags,
detect_reset_notify,
@@ -1431,6 +1515,7 @@ impl PaneRuntime {
// --- Child watcher task ---
let child_pid = Arc::new(AtomicU32::new(0));
+ let reported_cwd = Arc::new(Mutex::new(None));
let child_wait_completed = Arc::new(AtomicBool::new(false));
{
let child_pid = child_pid.clone();
@@ -1465,6 +1550,7 @@ impl PaneRuntime {
let render_dirty = render_dirty.clone();
let child_pid = child_pid.clone();
let events = events.clone();
+ let reported_cwd = reported_cwd.clone();
let rt = tokio::runtime::Handle::current();
let on_read = Box::new(move |bytes: &[u8]| {
let shell_pid = child_pid.load(Ordering::Acquire);
@@ -1483,6 +1569,9 @@ impl PaneRuntime {
}
});
}
+ if let Some(cwd) = result.reported_cwd.clone() {
+ publish_reported_cwd(pane_id, cwd, &reported_cwd, &events);
+ }
for content in result.clipboard_writes {
if let Err(err) = events.try_send(AppEvent::ClipboardWrite { content }) {
warn!(
@@ -1498,7 +1587,10 @@ impl PaneRuntime {
});
PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
pane_id: pane_id.raw(),
+ #[cfg(unix)]
master_fd: spawned.master_fd,
+ #[cfg(windows)]
+ master: spawned.master,
initially_quiesced: false,
on_read,
on_reader_exit: None,
@@ -1815,6 +1907,7 @@ impl PaneRuntime {
io,
current_size: Cell::new((rows, cols, 0, 0)),
child_pid,
+ reported_cwd,
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
detect_reset_notify,
@@ -1860,6 +1953,7 @@ impl PaneRuntime {
);
}
+ #[cfg(unix)]
pub fn nudge_child_redraw_after_handoff(&self) {
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
self.io
@@ -2092,6 +2186,15 @@ impl PaneRuntime {
/// Get the current working directory of the child shell process.
pub fn cwd(&self) -> Option<std::path::PathBuf> {
+ if let Some(cwd) = self
+ .reported_cwd
+ .lock()
+ .ok()
+ .and_then(|reported_cwd| reported_cwd.clone())
+ .and_then(usable_reported_cwd)
+ {
+ return Some(cwd);
+ }
let pid = self.child_pid.load(Ordering::Relaxed);
crate::platform::process_cwd(pid)
}
@@ -2180,6 +2283,7 @@ impl PaneRuntime {
},
current_size: Cell::new((rows, cols, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
+ reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
@@ -2216,6 +2320,7 @@ mod tests {
assert!(!process_alive_for_shutdown(43, 42, false, |_| false));
}
+ #[cfg(unix)]
fn capture_shell_output(command: &str, extra_env: &[(&str, &str)]) -> String {
let pair = native_pty_system()
.openpty(PtySize {
@@ -2261,6 +2366,7 @@ mod tests {
);
}
+ #[cfg(not(windows))]
#[test]
fn pane_shell_falls_back_to_shell_env() {
assert_eq!(
@@ -2269,10 +2375,22 @@ mod tests {
);
}
+ #[cfg(windows)]
+ #[test]
+ fn pane_shell_ignores_shell_env_on_windows() {
+ assert_eq!(
+ pane_shell_from("", Some("c:\\windows\\system32\\cmd.exe".to_string())),
+ default_pane_shell()
+ );
+ }
+
#[test]
fn pane_shell_ignores_empty_values() {
- assert_eq!(pane_shell_from(" ", Some(" ".to_string())), "/bin/sh");
- assert_eq!(pane_shell_from("", None), "/bin/sh");
+ assert_eq!(
+ pane_shell_from(" ", Some(" ".to_string())),
+ default_pane_shell()
+ );
+ assert_eq!(pane_shell_from("", None), default_pane_shell());
}
#[test]
@@ -2295,6 +2413,7 @@ mod tests {
));
}
+ #[cfg(unix)]
#[test]
fn login_shell_builder_uses_default_prog_with_resolved_shell_env() {
let cmd = pane_shell_command_builder_for_target(
@@ -2309,6 +2428,7 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn auto_shell_builder_uses_login_shell_on_macos_target() {
let cmd = pane_shell_command_builder_for_target(
@@ -2334,6 +2454,27 @@ mod tests {
assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]);
}
+ #[cfg(windows)]
+ #[test]
+ fn windows_powershell_shell_builder_wraps_cwd_reporting_prompt() {
+ let cmd = pane_shell_command_builder_for_target(
+ PaneShellConfig::new("powershell.exe", crate::config::ShellModeConfig::NonLogin),
+ false,
+ )
+ .unwrap();
+ let argv: Vec<_> = cmd
+ .get_argv()
+ .iter()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect();
+
+ assert_eq!(argv[0], "powershell.exe");
+ assert!(argv.iter().any(|arg| arg == "-NoExit"));
+ assert!(argv
+ .iter()
+ .any(|arg| arg.contains("]7;") && arg.contains("Function:\\prompt")));
+ }
+
#[test]
fn login_shell_builder_rejects_missing_shell_instead_of_falling_back() {
let err = pane_shell_command_builder_for_target(
@@ -2347,6 +2488,7 @@ mod tests {
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
+ #[cfg(unix)]
#[test]
fn login_shell_builder_resolves_bare_shell_names_from_path() {
let _lock = crate::integration::integration_env_lock();
@@ -2388,6 +2530,7 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
+ #[cfg(unix)]
#[test]
fn login_shell_resolution_preserves_shell_paths() {
assert_eq!(resolve_shell_for_login_mode("/bin/sh").unwrap(), "/bin/sh");
@@ -2404,12 +2547,14 @@ mod tests {
assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]);
}
+ #[cfg(unix)]
#[test]
fn pane_terminal_identity_overrides_outer_terminal_env() {
let output = capture_shell_output("printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"", &[]);
assert_eq!(output, "xterm-256color\ntruecolor\n");
}
+ #[cfg(unix)]
#[test]
fn pane_terminal_identity_allows_explicit_override() {
let output = capture_shell_output(
@@ -2419,6 +2564,7 @@ mod tests {
assert_eq!(output, "vt100\n24bit\n");
}
+ #[cfg(unix)]
#[tokio::test]
async fn handoff_history_ansi_captures_primary_screen() {
let runtime =
@@ -2429,6 +2575,7 @@ mod tests {
assert!(history.contains("handoff-primary-history"));
}
+ #[cfg(unix)]
#[tokio::test]
async fn handoff_history_ansi_skips_alternate_screen() {
let runtime = PaneRuntime::test_with_scrollback_bytes(
@@ -2441,6 +2588,7 @@ mod tests {
assert!(runtime.handoff_history_ansi().is_none());
}
+ #[cfg(unix)]
#[tokio::test]
async fn handoff_runtime_state_captures_terminal_input_state() {
let runtime = PaneRuntime::test_with_screen_bytes(
@@ -2467,6 +2615,7 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn truncate_handoff_history_keeps_recent_utf8_boundary() {
let history = format!("old\n{}\nrecent\n", "รฉ".repeat(8));
@@ -2477,6 +2626,7 @@ mod tests {
assert!(truncated.is_char_boundary(0));
}
+ #[cfg(unix)]
#[test]
fn truncate_handoff_history_drops_partial_long_line() {
let history = format!("old\n{}", "x".repeat(64));
@@ -2505,6 +2655,7 @@ mod tests {
},
current_size: Cell::new((80, 24, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
+ reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
@@ -2533,6 +2684,7 @@ mod tests {
},
current_size: Cell::new((80, 24, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
+ reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs
index 1b7d3274..89824ee3 100644
--- a/src/pane/kitty_keyboard.rs
+++ b/src/pane/kitty_keyboard.rs
@@ -81,6 +81,7 @@ impl KittyKeyboardTracker {
}
}
+ #[cfg(unix)]
pub(crate) fn replay_ansi(&self) -> Option<String> {
if self.stack.is_empty() {
return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags));
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 37407ade..1d37f1c0 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -1,4 +1,5 @@
use std::borrow::Cow;
+use std::path::PathBuf;
use tracing::info;
@@ -359,6 +360,145 @@ impl Osc52Forwarder {
}
}
+/// Reconstructs cwd-reporting OSC sequences from child output. Shell
+/// integrations commonly use OSC 7 (`file://...`), while Windows Terminal
+/// documents OSC 9;9 for the same practical purpose.
+#[derive(Debug, Default)]
+pub(super) struct CwdOscTracker {
+ state: Osc52ForwarderState,
+ body: Vec<u8>,
+ pending: Vec<PathBuf>,
+}
+
+impl CwdOscTracker {
+ pub(super) fn observe(&mut self, bytes: &[u8]) {
+ for &byte in bytes {
+ match self.state {
+ Osc52ForwarderState::Ground => {
+ if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ }
+ }
+ Osc52ForwarderState::Escape => {
+ if byte == b']' {
+ self.body.clear();
+ self.state = Osc52ForwarderState::OscBody;
+ } else if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ } else {
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ Osc52ForwarderState::OscBody => match byte {
+ 0x07 => {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ 0x1b => self.state = Osc52ForwarderState::OscEscape,
+ _ => self.body.push(byte),
+ },
+ Osc52ForwarderState::OscEscape => {
+ if byte == b'\\' {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ } else {
+ self.body.push(0x1b);
+ self.body.push(byte);
+ self.state = Osc52ForwarderState::OscBody;
+ }
+ }
+ }
+
+ if self.body.len() > 4096 {
+ self.body.clear();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ }
+
+ fn finalize(&mut self) {
+ if let Some(cwd) = parse_cwd_osc(&self.body) {
+ self.pending.push(cwd);
+ }
+ self.body.clear();
+ }
+
+ pub(super) fn drain_latest(&mut self) -> Option<PathBuf> {
+ self.pending.drain(..).next_back()
+ }
+}
+
+fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> {
+ let body = std::str::from_utf8(body).ok()?;
+ if let Some(uri) = body.strip_prefix("7;") {
+ return parse_file_uri_cwd(uri);
+ }
+ if let Some(path) = body.strip_prefix("9;9;") {
+ let path = path.trim().trim_matches('"');
+ return (!path.is_empty()).then(|| PathBuf::from(path));
+ }
+ None
+}
+
+fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> {
+ let rest = uri.strip_prefix("file://")?;
+ let path = if rest.starts_with('/') {
+ rest
+ } else if let Some(slash) = rest.find('/') {
+ let host = &rest[..slash];
+ if !(host.is_empty() || host.eq_ignore_ascii_case("localhost")) {
+ return None;
+ }
+ &rest[slash..]
+ } else {
+ rest
+ };
+ let path = percent_decode_utf8(path)?;
+
+ #[cfg(windows)]
+ {
+ let mut path = path;
+ if path.len() >= 3
+ && path.as_bytes()[0] == b'/'
+ && path.as_bytes()[2] == b':'
+ && path.as_bytes()[1].is_ascii_alphabetic()
+ {
+ path.remove(0);
+ }
+ return Some(PathBuf::from(path.replace('/', "\\")));
+ }
+
+ #[cfg(not(windows))]
+ Some(PathBuf::from(path))
+}
+
+fn percent_decode_utf8(input: &str) -> Option<String> {
+ let bytes = input.as_bytes();
+ let mut output = Vec::with_capacity(bytes.len());
+ let mut idx = 0;
+ while idx < bytes.len() {
+ if bytes[idx] == b'%' {
+ let hi = *bytes.get(idx + 1)?;
+ let lo = *bytes.get(idx + 2)?;
+ output.push(hex_value(hi)? * 16 + hex_value(lo)?);
+ idx += 3;
+ } else {
+ output.push(bytes[idx]);
+ idx += 1;
+ }
+ }
+ String::from_utf8(output).ok()
+}
+
+fn hex_value(byte: u8) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
+
/// Accepts `52;c;<base64>` and `52;;<base64>`.
/// Queries (`?`) are rejected because herdr has no reply path.
/// The payload must decode as base64 before it is forwarded.
@@ -601,6 +741,32 @@ mod tests {
}
#[test]
+ fn cwd_osc_tracker_detects_split_osc7_sequence() {
+ let mut tracker = CwdOscTracker::default();
+
+ tracker.observe(b"\x1b]7;file:///tmp/herdr%20repo");
+ assert_eq!(tracker.drain_latest(), None);
+ tracker.observe(b"\x07");
+
+ assert_eq!(
+ tracker.drain_latest(),
+ Some(std::path::PathBuf::from("/tmp/herdr repo"))
+ );
+ }
+
+ #[test]
+ fn cwd_osc_tracker_detects_windows_terminal_cwd_sequence() {
+ let mut tracker = CwdOscTracker::default();
+
+ tracker.observe(b"\x1b]9;9;C:\\Users\\herdr\\src\\herdr\x1b\\");
+
+ assert_eq!(
+ tracker.drain_latest(),
+ Some(std::path::PathBuf::from("C:\\Users\\herdr\\src\\herdr"))
+ );
+ }
+
+ #[test]
fn default_color_event_tracker_detects_queries_sets_and_resets() {
let mut tracker = DefaultColorEventTracker::default();
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index cf8506ba..9bfcf57f 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -23,7 +23,7 @@ use super::{
osc::{
contains_scrollback_clear_sequence, current_transient_default_color_owner,
maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed,
- write_host_terminal_theme, DefaultColorEvent, DefaultColorEventTracker,
+ write_host_terminal_theme, CwdOscTracker, DefaultColorEvent, DefaultColorEventTracker,
DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder,
},
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
@@ -101,6 +101,7 @@ pub(crate) struct ProcessBytesResult {
pub request_render: bool,
pub render_delay: Option<Duration>,
pub clipboard_writes: Vec<Vec<u8>>,
+ pub reported_cwd: Option<std::path::PathBuf>,
pub terminal_responses: Vec<Bytes>,
}
@@ -123,6 +124,7 @@ pub(crate) struct GhosttyPaneCore {
pub child_default_foreground_changed: bool,
pub child_default_background_changed: bool,
pub osc52_forwarder: Osc52Forwarder,
+ pub cwd_osc_tracker: CwdOscTracker,
pub xtgettcap_query_tracker: XtgettcapQueryTracker,
}
@@ -264,6 +266,7 @@ impl PaneTerminal {
self.ghostty.keyboard_protocol().unwrap_or(fallback)
}
+ #[cfg(unix)]
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
self.ghostty
.kitty_keyboard_state_ansi()
@@ -352,6 +355,7 @@ impl GhosttyPaneTerminal {
child_default_foreground_changed: false,
child_default_background_changed: false,
osc52_forwarder: Osc52Forwarder::default(),
+ cwd_osc_tracker: CwdOscTracker::default(),
xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
}),
key_encoder: Mutex::new(key_encoder),
@@ -419,6 +423,7 @@ impl GhosttyPaneTerminal {
request_render: false,
render_delay: None,
clipboard_writes: Vec::new(),
+ reported_cwd: None,
terminal_responses: Vec::new(),
};
};
@@ -436,6 +441,8 @@ impl GhosttyPaneTerminal {
core.osc52_forwarder.observe(bytes);
let clipboard_writes = core.osc52_forwarder.drain_pending();
+ core.cwd_osc_tracker.observe(bytes);
+ let reported_cwd = core.cwd_osc_tracker.drain_latest();
let alternate_screen = core
.terminal
@@ -507,6 +514,7 @@ impl GhosttyPaneTerminal {
request_render,
render_delay,
clipboard_writes,
+ reported_cwd,
terminal_responses,
}
}
@@ -576,6 +584,7 @@ impl GhosttyPaneTerminal {
}
}
+ #[cfg(unix)]
pub fn seed_handoff_input_state(&self, input_state: InputState) {
let Ok(mut core) = self.core.lock() else {
return;
@@ -647,6 +656,7 @@ impl GhosttyPaneTerminal {
}
}
+ #[cfg(unix)]
pub fn seed_keyboard_protocol_flags(&self, flags: u16) {
if flags == 0 {
return;
@@ -654,6 +664,7 @@ impl GhosttyPaneTerminal {
self.seed_keyboard_protocol_ansi(&format!("\x1b[>{flags}u"));
}
+ #[cfg(unix)]
pub fn seed_keyboard_protocol_ansi(&self, ansi: &str) {
if ansi.is_empty() {
return;
@@ -785,6 +796,7 @@ impl GhosttyPaneTerminal {
))
}
+ #[cfg(unix)]
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
let core = self.core.lock().ok()?;
core.kitty_keyboard.replay_ansi()
@@ -2144,6 +2156,7 @@ mod tests {
assert_eq!(encoded, b"\x1bOA");
}
+ #[cfg(unix)]
#[test]
fn ghostty_seed_handoff_input_state_restores_input_modes() {
let (tx, _rx) = mpsc::channel(4);
@@ -2254,6 +2267,7 @@ mod tests {
assert_eq!(encoded, b"\x1b[13;2u");
}
+ #[cfg(unix)]
#[test]
fn ghostty_seed_keyboard_protocol_flags_restores_shift_enter_encoding() {
let (tx, _rx) = mpsc::channel(4);
@@ -2271,6 +2285,7 @@ mod tests {
assert_eq!(encoded, b"\x1b[13;2u");
}
+ #[cfg(unix)]
#[test]
fn ghostty_keyboard_protocol_state_replays_nested_stack() {
let (tx, _rx) = mpsc::channel(4);
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 93041017..d5661b30 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -455,32 +455,58 @@ fn restore_tab(
continue;
}
- let runtime_result = if let Some(imported) = imported_runtime {
- TerminalRuntime::from_handoff_fd(
- crate::handoff_runtime::ImportedHandoffRuntime {
- master_fd: imported.master_fd,
- state: imported.state.with_pane_id(*id),
- },
- runtime_context.scrollback_limit_bytes,
- crate::terminal_theme::TerminalTheme::default(),
- runtime_context.events.clone(),
- runtime_context.render_notify.clone(),
- runtime_context.render_dirty.clone(),
- )
- } else {
- TerminalRuntime::spawn_with_initial_history(
- *id,
- rows,
- cols,
- cwd.clone(),
- runtime_context.scrollback_limit_bytes,
- crate::terminal_theme::TerminalTheme::default(),
- runtime_context.shell_config,
- startup.initial_history_ansi,
- runtime_context.events.clone(),
- runtime_context.render_notify.clone(),
- runtime_context.render_dirty.clone(),
- )
+ #[cfg(not(unix))]
+ if imported_runtime.is_some() {
+ failed_imports += 1;
+ continue;
+ }
+
+ let runtime_result = {
+ #[cfg(unix)]
+ if let Some(imported) = imported_runtime {
+ TerminalRuntime::from_handoff_fd(
+ crate::handoff_runtime::ImportedHandoffRuntime {
+ master_fd: imported.master_fd,
+ state: imported.state.with_pane_id(*id),
+ },
+ runtime_context.scrollback_limit_bytes,
+ crate::terminal_theme::TerminalTheme::default(),
+ runtime_context.events.clone(),
+ runtime_context.render_notify.clone(),
+ runtime_context.render_dirty.clone(),
+ )
+ } else {
+ TerminalRuntime::spawn_with_initial_history(
+ *id,
+ rows,
+ cols,
+ cwd.clone(),
+ runtime_context.scrollback_limit_bytes,
+ crate::terminal_theme::TerminalTheme::default(),
+ runtime_context.shell_config,
+ startup.initial_history_ansi,
+ runtime_context.events.clone(),
+ runtime_context.render_notify.clone(),
+ runtime_context.render_dirty.clone(),
+ )
+ }
+
+ #[cfg(not(unix))]
+ {
+ TerminalRuntime::spawn_with_initial_history(
+ *id,
+ rows,
+ cols,
+ cwd.clone(),
+ runtime_context.scrollback_limit_bytes,
+ crate::terminal_theme::TerminalTheme::default(),
+ runtime_context.shell_config,
+ startup.initial_history_ansi,
+ runtime_context.events.clone(),
+ runtime_context.render_notify.clone(),
+ runtime_context.render_dirty.clone(),
+ )
+ }
};
match runtime_result {
@@ -771,6 +797,24 @@ fn collect_ids_inner(node: &Node, ids: &mut Vec<PaneId>) {
mod tests {
use super::*;
+ fn test_session_path(name: &str) -> String {
+ std::env::current_dir()
+ .unwrap()
+ .join(name)
+ .display()
+ .to_string()
+ }
+
+ #[cfg(windows)]
+ fn test_restore_shell() -> &'static str {
+ "C:\\Windows\\System32\\whoami.exe"
+ }
+
+ #[cfg(not(windows))]
+ fn test_restore_shell() -> &'static str {
+ "/bin/sh"
+ }
+
#[test]
fn capture_and_restore_node_round_trip() {
let node = Node::Split {
@@ -847,35 +891,37 @@ mod tests {
#[test]
fn restore_plan_respects_opt_in_and_allowlist() {
+ let pi_session_path = test_session_path("pi-session.jsonl");
let session = super::super::snapshot::PaneAgentSessionSnapshot {
source: "herdr:pi".into(),
agent: "pi".into(),
kind: crate::agent_resume::AgentSessionRefKind::Path,
- value: "/tmp/pi-session.jsonl".into(),
+ value: pi_session_path.clone(),
};
assert!(restore_plan_for_snapshot(&session, false).is_none());
assert_eq!(
restore_plan_for_snapshot(&session, true).unwrap().argv,
- vec!["pi", "--session", "/tmp/pi-session.jsonl"]
+ vec!["pi", "--session", pi_session_path.as_str()]
);
let unsupported_path = super::super::snapshot::PaneAgentSessionSnapshot {
source: "herdr:claude".into(),
agent: "claude".into(),
kind: crate::agent_resume::AgentSessionRefKind::Path,
- value: "/tmp/claude-session".into(),
+ value: test_session_path("claude-session"),
};
assert!(restore_plan_for_snapshot(&unsupported_path, true).is_none());
}
#[test]
fn restore_plan_selection_suppresses_duplicates() {
+ let pi_session_path = test_session_path("pi-session.jsonl");
let session = super::super::snapshot::PaneAgentSessionSnapshot {
source: "herdr:pi".into(),
agent: "pi".into(),
kind: crate::agent_resume::AgentSessionRefKind::Path,
- value: "/tmp/pi-session.jsonl".into(),
+ value: pi_session_path.clone(),
};
let mut resumed = HashSet::new();
@@ -884,7 +930,10 @@ mod tests {
let first = take_restore_plan_for_snapshot(&session, true, &mut resumed)
.expect("first restore should get a plan");
- assert_eq!(first.argv, vec!["pi", "--session", "/tmp/pi-session.jsonl"]);
+ assert_eq!(
+ first.argv,
+ vec!["pi", "--session", pi_session_path.as_str()]
+ );
assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_none());
}
@@ -894,7 +943,7 @@ mod tests {
source: "herdr:pi".into(),
agent: "pi".into(),
kind: crate::agent_resume::AgentSessionRefKind::Path,
- value: "/tmp/pi-session.jsonl".into(),
+ value: test_session_path("pi-session.jsonl"),
};
let history = super::super::snapshot::PaneHistorySnapshot {
ansi: "RESTORED_HISTORY\r\n".into(),
@@ -919,7 +968,7 @@ mod tests {
source: "herdr:pi".into(),
agent: "pi".into(),
kind: crate::agent_resume::AgentSessionRefKind::Path,
- value: "/tmp/pi-session.jsonl".into(),
+ value: test_session_path("pi-session.jsonl"),
};
let history = super::super::snapshot::PaneHistorySnapshot {
ansi: "RESTORED_HISTORY\r\n".into(),
@@ -947,7 +996,7 @@ mod tests {
source: "herdr:pi".into(),
agent: "pi".into(),
kind: crate::agent_resume::AgentSessionRefKind::Path,
- value: "/tmp/pi-session.jsonl".into(),
+ value: test_session_path("pi-session.jsonl"),
};
let history = super::super::snapshot::PaneHistorySnapshot {
ansi: "RESTORED_HISTORY\r\n".into(),
@@ -989,7 +1038,7 @@ mod tests {
source: "herdr:pi".into(),
agent: "pi".into(),
kind: crate::agent_resume::AgentSessionRefKind::Path,
- value: "/tmp/pi-session.jsonl".into(),
+ value: test_session_path("pi-session.jsonl"),
};
let mut resumed = HashSet::new();
assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_some());
@@ -1047,7 +1096,7 @@ mod tests {
24,
80,
0,
- "/usr/bin/true",
+ test_restore_shell(),
crate::config::ShellModeConfig::NonLogin,
false,
events,
@@ -1122,7 +1171,7 @@ mod tests {
24,
80,
0,
- "/bin/sh",
+ test_restore_shell(),
crate::config::ShellModeConfig::NonLogin,
true,
events,
@@ -1150,7 +1199,7 @@ mod tests {
let (_handoff_workspaces, handoff_terminals, handoff_runtimes) = restore_handoff(
&snapshot,
0,
- "/bin/sh",
+ test_restore_shell(),
crate::config::ShellModeConfig::NonLogin,
&mut imports,
mpsc::channel(4).0,
@@ -1185,7 +1234,7 @@ mod tests {
5,
40,
4096,
- "/bin/sh",
+ test_restore_shell(),
crate::config::ShellModeConfig::NonLogin,
false,
events,
@@ -1224,7 +1273,7 @@ mod tests {
5,
40,
4096,
- "/bin/sh",
+ test_restore_shell(),
crate::config::ShellModeConfig::NonLogin,
false,
events,
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index bc24a322..c04c280f 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -488,6 +488,14 @@ mod tests {
}
}
+ fn test_session_path(name: &str) -> String {
+ std::env::current_dir()
+ .unwrap()
+ .join(name)
+ .display()
+ .to_string()
+ }
+
fn state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect();
@@ -1024,6 +1032,7 @@ mod tests {
#[test]
fn capture_contract_tracks_hook_authority_agent_session() {
let mut state = state_with_workspaces(&["one"]);
+ let session_path = test_session_path("pi-session.jsonl");
let root = state.workspaces[0].tabs[0].root_pane;
state.ensure_test_terminals();
let terminal_id = state.workspaces[0].tabs[0].panes[&root]
@@ -1039,7 +1048,7 @@ mod tests {
crate::detect::AgentState::Working,
None,
None,
- crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
);
@@ -1055,7 +1064,7 @@ mod tests {
agent_session.kind,
crate::agent_resume::AgentSessionRefKind::Path
);
- assert_eq!(agent_session.value, "/tmp/pi-session.jsonl");
+ assert_eq!(agent_session.value, session_path);
}
#[test]
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs
index 42abfcb2..0f48d32d 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -52,6 +52,8 @@ pub fn open_url(_url: &str) -> std::io::Result<()> {
}
/// Unsupported platform stub.
+// Windows does not wire clipboard-image bridging into semantic input yet.
+#[cfg_attr(windows, allow(dead_code))]
pub fn read_clipboard_image() -> Option<ClipboardImage> {
None
}
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 12c275f0..ec9a497f 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -25,6 +25,22 @@ pub enum Signal {
Kill,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct PlatformCapabilities {
+ pub(crate) live_handoff: bool,
+ pub(crate) remote_attach: bool,
+ pub(crate) direct_terminal_attach: bool,
+}
+
+pub(crate) const fn capabilities() -> PlatformCapabilities {
+ PlatformCapabilities {
+ live_handoff: cfg!(unix),
+ remote_attach: cfg!(unix),
+ direct_terminal_attach: cfg!(unix),
+ }
+}
+
+#[cfg(unix)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardCommand {
pub program: &'static str,
@@ -32,11 +48,14 @@ pub struct ClipboardCommand {
}
#[derive(Debug, Clone, PartialEq, Eq)]
+// Windows does not wire clipboard-image bridging into semantic input yet.
+#[cfg_attr(windows, allow(dead_code))]
pub struct ClipboardImage {
pub bytes: Vec<u8>,
pub extension: &'static str,
}
+#[cfg(unix)]
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum LimitedRead {
Empty,
@@ -44,6 +63,7 @@ pub(crate) enum LimitedRead {
Oversized,
}
+#[cfg(unix)]
pub(crate) fn read_limited_reader(
mut reader: impl std::io::Read,
max_bytes: usize,
@@ -91,9 +111,14 @@ mod macos;
#[cfg(target_os = "macos")]
pub use macos::*;
-#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+#[cfg(target_os = "windows")]
+mod windows;
+#[cfg(target_os = "windows")]
+pub use windows::*;
+
+#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
mod fallback;
-#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub use fallback::*;
#[cfg(not(target_os = "macos"))]
@@ -139,7 +164,7 @@ impl PrefixInputSource for RealPrefixInputSource {
}
}
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
use super::*;
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
new file mode 100644
index 00000000..a8ad2082
--- /dev/null
+++ b/src/platform/windows.rs
@@ -0,0 +1,590 @@
+use std::{
+ collections::{HashMap, VecDeque},
+ ffi::c_void,
+ mem::{size_of, MaybeUninit},
+ path::PathBuf,
+ ptr::null_mut,
+};
+
+use windows_sys::{
+ Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation},
+ Win32::{
+ Foundation::{
+ CloseHandle, LocalFree, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, STATUS_SUCCESS,
+ UNICODE_STRING,
+ },
+ System::{
+ Diagnostics::{
+ Debug::ReadProcessMemory,
+ ToolHelp::{
+ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
+ TH32CS_SNAPPROCESS,
+ },
+ },
+ Threading::{
+ GetExitCodeProcess, OpenProcess, TerminateProcess, PROCESS_BASIC_INFORMATION,
+ PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
+ },
+ },
+ UI::Shell::{CommandLineToArgvW, ShellExecuteW},
+ },
+};
+
+use super::{ClipboardImage, ForegroundJob, Signal};
+
+const STILL_ACTIVE: u32 = 259;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct WindowsProcessEntry {
+ pid: u32,
+ parent_pid: u32,
+ name: String,
+ argv0: Option<String>,
+ argv: Option<Vec<String>>,
+ cmdline: Option<String>,
+}
+
+pub fn raise_server_nofile_limit() {}
+
+pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
+ let entries = snapshot_processes();
+ select_pane_foreground_job(child_pid, &entries)
+}
+
+pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> {
+ let entries = snapshot_processes();
+ let entry = entries.iter().find(|entry| entry.pid == process_group_id)?;
+ Some(ForegroundJob {
+ process_group_id,
+ processes: vec![foreground_process_from_entry(entry)],
+ })
+}
+
+pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
+ foreground_job(child_pid).map(|job| job.process_group_id)
+}
+
+pub fn process_cwd(pid: u32) -> Option<PathBuf> {
+ let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?;
+ let process_parameters = read_process_parameters(process.0)?;
+ read_unicode_string(process.0, process_parameters.current_directory.dos_path)
+ .map(PathBuf::from)
+ .filter(|path| path.is_absolute())
+}
+
+fn select_pane_foreground_job(
+ shell_pid: u32,
+ entries: &[WindowsProcessEntry],
+) -> Option<ForegroundJob> {
+ let shell = entries.iter().find(|entry| entry.pid == shell_pid)?;
+ let shell_job = || ForegroundJob {
+ process_group_id: shell_pid,
+ processes: vec![foreground_process_from_entry(shell)],
+ };
+
+ let descendants = descendant_entries(shell_pid, entries);
+ let mut candidates = Vec::new();
+ for entry in descendants {
+ let process = foreground_process_from_entry(entry);
+ let job = ForegroundJob {
+ process_group_id: entry.pid,
+ processes: vec![process],
+ };
+ if crate::detect::identify_agent_in_job(&job).is_some() {
+ candidates.push(job);
+ }
+ }
+
+ match candidates.len() {
+ 1 => candidates.pop(),
+ _ => Some(shell_job()),
+ }
+}
+
+fn descendant_entries(root_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<&WindowsProcessEntry> {
+ let mut children: HashMap<u32, Vec<&WindowsProcessEntry>> = HashMap::new();
+ for entry in entries {
+ children.entry(entry.parent_pid).or_default().push(entry);
+ }
+
+ let mut output = Vec::new();
+ let mut queue = VecDeque::new();
+ if let Some(root_children) = children.get(&root_pid) {
+ queue.extend(root_children.iter().copied());
+ }
+ while let Some(entry) = queue.pop_front() {
+ output.push(entry);
+ if let Some(next) = children.get(&entry.pid) {
+ queue.extend(next.iter().copied());
+ }
+ }
+ output
+}
+
+fn foreground_process_from_entry(entry: &WindowsProcessEntry) -> super::ForegroundProcess {
+ super::ForegroundProcess {
+ pid: entry.pid,
+ name: entry.name.clone(),
+ argv0: entry.argv0.clone(),
+ argv: entry.argv.clone(),
+ cmdline: entry.cmdline.clone(),
+ }
+}
+
+fn snapshot_processes() -> Vec<WindowsProcessEntry> {
+ let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
+ if snapshot == INVALID_HANDLE_VALUE {
+ return Vec::new();
+ }
+ let _snapshot = ProcessHandle(snapshot);
+
+ let mut entry = PROCESSENTRY32W {
+ dwSize: size_of::<PROCESSENTRY32W>() as u32,
+ ..Default::default()
+ };
+ let mut output = Vec::new();
+ let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
+ while ok {
+ let pid = entry.th32ProcessID;
+ let name = nul_terminated_utf16_to_string(&entry.szExeFile);
+ let cmdline = process_command_line(pid);
+ let argv = cmdline.as_deref().and_then(command_line_to_argv);
+ let argv0 = argv
+ .as_ref()
+ .and_then(|argv| argv.first().cloned())
+ .or_else(|| (!name.is_empty()).then(|| name.clone()));
+ output.push(WindowsProcessEntry {
+ pid,
+ parent_pid: entry.th32ParentProcessID,
+ name,
+ argv0,
+ argv,
+ cmdline,
+ });
+ ok = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
+ }
+ output
+}
+
+fn process_command_line(pid: u32) -> Option<String> {
+ let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?;
+ let parameters = read_process_parameters(process.0)?;
+ read_unicode_string(process.0, parameters.command_line)
+}
+
+fn read_process_parameters(process: HANDLE) -> Option<RtlUserProcessParameters> {
+ let mut basic_info = MaybeUninit::<PROCESS_BASIC_INFORMATION>::uninit();
+ let status = unsafe {
+ NtQueryInformationProcess(
+ process,
+ ProcessBasicInformation,
+ basic_info.as_mut_ptr().cast::<c_void>(),
+ size_of::<PROCESS_BASIC_INFORMATION>() as u32,
+ null_mut(),
+ )
+ };
+ if status != STATUS_SUCCESS as NTSTATUS {
+ return None;
+ }
+
+ let basic_info = unsafe { basic_info.assume_init() };
+ if basic_info.PebBaseAddress.is_null() {
+ return None;
+ }
+
+ let peb = read_process_value::<Peb>(process, basic_info.PebBaseAddress.cast::<c_void>())?;
+ if peb.process_parameters.is_null() {
+ return None;
+ }
+
+ read_process_value::<RtlUserProcessParameters>(process, peb.process_parameters.cast())
+}
+
+fn command_line_to_argv(command_line: &str) -> Option<Vec<String>> {
+ let wide: Vec<u16> = command_line
+ .encode_utf16()
+ .chain(std::iter::once(0))
+ .collect();
+ let mut argc = 0;
+ let argv_ptr = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut argc) };
+ if argv_ptr.is_null() || argc <= 0 {
+ return None;
+ }
+
+ let argv_slice = unsafe { std::slice::from_raw_parts(argv_ptr, argc as usize) };
+ let mut argv = Vec::with_capacity(argc as usize);
+ for &arg in argv_slice {
+ if arg.is_null() {
+ continue;
+ }
+ let mut len = 0;
+ unsafe {
+ while *arg.add(len) != 0 {
+ len += 1;
+ }
+ argv.push(String::from_utf16_lossy(std::slice::from_raw_parts(
+ arg, len,
+ )));
+ }
+ }
+ unsafe {
+ LocalFree(argv_ptr.cast());
+ }
+ Some(argv)
+}
+
+fn nul_terminated_utf16_to_string(buffer: &[u16]) -> String {
+ let len = buffer
+ .iter()
+ .position(|&value| value == 0)
+ .unwrap_or(buffer.len());
+ String::from_utf16_lossy(&buffer[..len])
+}
+
+pub fn session_processes(child_pid: u32) -> Vec<u32> {
+ if child_pid == 0 {
+ return Vec::new();
+ }
+
+ let entries = snapshot_processes();
+ session_processes_from_entries(child_pid, &entries)
+}
+
+fn session_processes_from_entries(child_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<u32> {
+ if !entries.iter().any(|entry| entry.pid == child_pid) {
+ return Vec::new();
+ }
+
+ let mut pids = vec![child_pid];
+ pids.extend(
+ descendant_entries(child_pid, &entries)
+ .into_iter()
+ .map(|entry| entry.pid),
+ );
+ pids
+}
+
+pub fn signal_processes(pids: &[u32], signal: Signal) {
+ if signal == Signal::Hangup {
+ return;
+ }
+
+ for &pid in pids {
+ let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
+ continue;
+ };
+ unsafe {
+ TerminateProcess(process.0, 1);
+ }
+ }
+}
+
+pub fn process_exists(pid: u32) -> bool {
+ let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
+ return false;
+ };
+
+ let mut exit_code = 0;
+ let ok = unsafe { GetExitCodeProcess(process.0, &mut exit_code) } != 0;
+ ok && exit_code == STILL_ACTIVE
+}
+
+pub fn write_clipboard(_bytes: &[u8]) -> bool {
+ false
+}
+
+pub fn open_url(url: &str) -> std::io::Result<()> {
+ let operation = wide_null("open");
+ let url = wide_null(url);
+ let result = unsafe {
+ ShellExecuteW(
+ std::ptr::null_mut(),
+ operation.as_ptr(),
+ url.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null(),
+ 1,
+ )
+ };
+ if result as isize > 32 {
+ Ok(())
+ } else {
+ Err(std::io::Error::other(format!(
+ "failed to open URL with ShellExecuteW: code {}",
+ result as isize
+ )))
+ }
+}
+
+// Windows does not wire clipboard-image bridging into semantic input yet.
+#[cfg_attr(windows, allow(dead_code))]
+pub fn read_clipboard_image() -> Option<ClipboardImage> {
+ None
+}
+
+pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result<bool> {
+ Ok(false)
+}
+
+fn wide_null(value: &str) -> Vec<u16> {
+ value.encode_utf16().chain(std::iter::once(0)).collect()
+}
+
+struct ProcessHandle(HANDLE);
+
+impl ProcessHandle {
+ fn open(pid: u32, access: u32) -> Option<Self> {
+ if pid == 0 {
+ return None;
+ }
+ let handle = unsafe { OpenProcess(access, 0, pid) };
+ (!handle.is_null()).then_some(Self(handle))
+ }
+}
+
+impl Drop for ProcessHandle {
+ fn drop(&mut self) {
+ unsafe {
+ CloseHandle(self.0);
+ }
+ }
+}
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct Peb {
+ reserved1: [u8; 2],
+ being_debugged: u8,
+ reserved2: [u8; 1],
+ reserved3: [*mut c_void; 2],
+ ldr: *mut c_void,
+ process_parameters: *mut RtlUserProcessParameters,
+}
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct CurDir {
+ dos_path: UNICODE_STRING,
+ handle: HANDLE,
+}
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct RtlUserProcessParameters {
+ maximum_length: u32,
+ length: u32,
+ flags: u32,
+ debug_flags: u32,
+ console_handle: HANDLE,
+ console_flags: u32,
+ standard_input: HANDLE,
+ standard_output: HANDLE,
+ standard_error: HANDLE,
+ current_directory: CurDir,
+ dll_path: UNICODE_STRING,
+ image_path_name: UNICODE_STRING,
+ command_line: UNICODE_STRING,
+}
+
+fn read_process_value<T: Copy>(process: HANDLE, address: *const c_void) -> Option<T> {
+ if address.is_null() {
+ return None;
+ }
+
+ let mut value = MaybeUninit::<T>::uninit();
+ let mut bytes_read = 0;
+ let ok = unsafe {
+ ReadProcessMemory(
+ process,
+ address,
+ value.as_mut_ptr().cast::<c_void>(),
+ size_of::<T>(),
+ &mut bytes_read,
+ )
+ } != 0;
+
+ (ok && bytes_read == size_of::<T>()).then(|| unsafe { value.assume_init() })
+}
+
+fn read_unicode_string(process: HANDLE, unicode: UNICODE_STRING) -> Option<String> {
+ if unicode.Buffer.is_null() || unicode.Length == 0 || unicode.Length % 2 != 0 {
+ return None;
+ }
+
+ let char_len = usize::from(unicode.Length / 2);
+ let mut buffer = vec![0_u16; char_len];
+ let mut bytes_read = 0;
+ let ok = unsafe {
+ ReadProcessMemory(
+ process,
+ unicode.Buffer.cast::<c_void>(),
+ buffer.as_mut_ptr().cast::<c_void>(),
+ usize::from(unicode.Length),
+ &mut bytes_read,
+ )
+ } != 0;
+
+ if !ok || bytes_read != usize::from(unicode.Length) {
+ return None;
+ }
+
+ String::from_utf16(&buffer).ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ fs,
+ process::{Command, Stdio},
+ thread,
+ time::{Duration, Instant},
+ };
+
+ #[test]
+ fn windows_process_cwd_reads_child_launch_directory() {
+ let cwd = std::env::temp_dir().join(format!("herdr-cwd-test-{}", std::process::id()));
+ fs::create_dir_all(&cwd).expect("create cwd fixture");
+
+ let mut child = Command::new("powershell.exe")
+ .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 10"])
+ .current_dir(&cwd)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .expect("spawn powershell");
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut observed = None;
+ while Instant::now() < deadline {
+ observed = super::process_cwd(child.id());
+ if observed.as_deref() == Some(cwd.as_path()) {
+ break;
+ }
+ thread::sleep(Duration::from_millis(100));
+ }
+
+ let _ = child.kill();
+ let _ = child.wait();
+ let _ = fs::remove_dir_all(&cwd);
+
+ assert_eq!(observed.as_deref(), Some(cwd.as_path()));
+ }
+
+ #[test]
+ fn windows_process_tree_selects_direct_agent_descendant() {
+ let entries = vec![
+ test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
+ test_entry(20, 10, "codex.exe", &["codex.exe"]),
+ ];
+
+ let job = super::select_pane_foreground_job(10, &entries).unwrap();
+
+ assert_eq!(job.process_group_id, 20);
+ assert_eq!(job.processes.len(), 1);
+ assert_eq!(job.processes[0].name, "codex.exe");
+ }
+
+ #[test]
+ fn windows_process_tree_selects_wrapped_agent_descendant() {
+ let entries = vec![
+ test_entry(10, 1, "cmd.exe", &["cmd.exe"]),
+ test_entry(
+ 20,
+ 10,
+ "node.exe",
+ &[
+ "node.exe",
+ "C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\codex\\bin\\codex.js",
+ ],
+ ),
+ ];
+
+ let job = super::select_pane_foreground_job(10, &entries).unwrap();
+
+ assert_eq!(job.process_group_id, 20);
+ assert_eq!(job.processes[0].name, "node.exe");
+ }
+
+ #[test]
+ fn windows_process_tree_selects_cmd_wrapped_agent_descendant() {
+ let entries = vec![
+ test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
+ test_entry(
+ 20,
+ 10,
+ "cmd.exe",
+ &[
+ "cmd.exe",
+ "/D",
+ "/S",
+ "/C",
+ "C:\\Users\\herdr\\AppData\\Roaming\\npm\\codex.cmd --model gpt-5",
+ ],
+ ),
+ ];
+
+ let job = super::select_pane_foreground_job(10, &entries).unwrap();
+
+ assert_eq!(job.process_group_id, 20);
+ assert_eq!(job.processes[0].name, "cmd.exe");
+ }
+
+ #[test]
+ fn windows_process_tree_returns_shell_for_plain_descendant() {
+ let entries = vec![
+ test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
+ test_entry(20, 10, "git.exe", &["git.exe", "status"]),
+ ];
+
+ let job = super::select_pane_foreground_job(10, &entries).unwrap();
+
+ assert_eq!(job.process_group_id, 10);
+ assert_eq!(job.processes[0].name, "powershell.exe");
+ }
+
+ #[test]
+ fn windows_process_tree_returns_shell_for_multiple_agent_descendants() {
+ let entries = vec![
+ test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
+ test_entry(20, 10, "codex.exe", &["codex.exe"]),
+ test_entry(30, 10, "claude.exe", &["claude.exe"]),
+ ];
+
+ let job = super::select_pane_foreground_job(10, &entries).unwrap();
+
+ assert_eq!(job.process_group_id, 10);
+ assert_eq!(job.processes[0].name, "powershell.exe");
+ }
+
+ #[test]
+ fn windows_session_processes_collects_shell_and_descendants() {
+ let entries = vec![
+ test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
+ test_entry(20, 10, "cmd.exe", &["cmd.exe"]),
+ test_entry(30, 20, "node.exe", &["node.exe"]),
+ test_entry(40, 1, "unrelated.exe", &["unrelated.exe"]),
+ ];
+
+ let mut pids = super::session_processes_from_entries(10, &entries);
+ pids.sort_unstable();
+
+ assert_eq!(pids, vec![10, 20, 30]);
+ }
+
+ fn test_entry(
+ pid: u32,
+ parent_pid: u32,
+ name: &str,
+ argv: &[&str],
+ ) -> super::WindowsProcessEntry {
+ super::WindowsProcessEntry {
+ pid,
+ parent_pid,
+ name: name.to_string(),
+ argv0: argv.first().map(|value| (*value).to_string()),
+ argv: Some(argv.iter().map(|value| (*value).to_string()).collect()),
+ cmdline: Some(argv.join(" ")),
+ }
+ }
+}
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 14a9e3b3..04d1b1ae 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -61,6 +61,248 @@ pub enum ClientLaunchMode {
TerminalAttach,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientKeyKind {
+ Press,
+ Repeat,
+ Release,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientKeyCode {
+ Backspace,
+ Enter,
+ Left,
+ Right,
+ Up,
+ Down,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+ Tab,
+ BackTab,
+ Delete,
+ Insert,
+ Esc,
+ Char(char),
+ F(u8),
+ Null,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientMouseButton {
+ Left,
+ Right,
+ Middle,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientMouseKind {
+ Down(ClientMouseButton),
+ Up(ClientMouseButton),
+ Drag(ClientMouseButton),
+ Moved,
+ ScrollUp,
+ ScrollDown,
+ ScrollLeft,
+ ScrollRight,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientInputEvent {
+ Key {
+ code: ClientKeyCode,
+ modifiers: u8,
+ kind: ClientKeyKind,
+ },
+ Mouse {
+ kind: ClientMouseKind,
+ column: u16,
+ row: u16,
+ modifiers: u8,
+ },
+ Paste {
+ text: String,
+ },
+ FocusGained,
+ FocusLost,
+}
+
+impl ClientKeyKind {
+ #[cfg(windows)]
+ pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self {
+ match kind {
+ crossterm::event::KeyEventKind::Press => Self::Press,
+ crossterm::event::KeyEventKind::Repeat => Self::Repeat,
+ crossterm::event::KeyEventKind::Release => Self::Release,
+ }
+ }
+
+ pub(crate) fn to_crossterm(self) -> crossterm::event::KeyEventKind {
+ match self {
+ Self::Press => crossterm::event::KeyEventKind::Press,
+ Self::Repeat => crossterm::event::KeyEventKind::Repeat,
+ Self::Release => crossterm::event::KeyEventKind::Release,
+ }
+ }
+}
+
+impl ClientKeyCode {
+ #[cfg(windows)]
+ pub(crate) fn from_crossterm(code: crossterm::event::KeyCode) -> Option<Self> {
+ use crossterm::event::KeyCode;
+ Some(match code {
+ KeyCode::Backspace => Self::Backspace,
+ KeyCode::Enter => Self::Enter,
+ KeyCode::Left => Self::Left,
+ KeyCode::Right => Self::Right,
+ KeyCode::Up => Self::Up,
+ KeyCode::Down => Self::Down,
+ KeyCode::Home => Self::Home,
+ KeyCode::End => Self::End,
+ KeyCode::PageUp => Self::PageUp,
+ KeyCode::PageDown => Self::PageDown,
+ KeyCode::Tab => Self::Tab,
+ KeyCode::BackTab => Self::BackTab,
+ KeyCode::Delete => Self::Delete,
+ KeyCode::Insert => Self::Insert,
+ KeyCode::Esc => Self::Esc,
+ KeyCode::Char(ch) => Self::Char(ch),
+ KeyCode::F(n) => Self::F(n),
+ KeyCode::Null => Self::Null,
+ _ => return None,
+ })
+ }
+
+ pub(crate) fn to_crossterm(&self) -> crossterm::event::KeyCode {
+ use crossterm::event::KeyCode;
+ match self {
+ Self::Backspace => KeyCode::Backspace,
+ Self::Enter => KeyCode::Enter,
+ Self::Left => KeyCode::Left,
+ Self::Right => KeyCode::Right,
+ Self::Up => KeyCode::Up,
+ Self::Down => KeyCode::Down,
+ Self::Home => KeyCode::Home,
+ Self::End => KeyCode::End,
+ Self::PageUp => KeyCode::PageUp,
+ Self::PageDown => KeyCode::PageDown,
+ Self::Tab => KeyCode::Tab,
+ Self::BackTab => KeyCode::BackTab,
+ Self::Delete => KeyCode::Delete,
+ Self::Insert => KeyCode::Insert,
+ Self::Esc => KeyCode::Esc,
+ Self::Char(ch) => KeyCode::Char(*ch),
+ Self::F(n) => KeyCode::F(*n),
+ Self::Null => KeyCode::Null,
+ }
+ }
+}
+
+impl ClientMouseButton {
+ #[cfg(windows)]
+ pub(crate) fn from_crossterm(button: crossterm::event::MouseButton) -> Self {
+ match button {
+ crossterm::event::MouseButton::Left => Self::Left,
+ crossterm::event::MouseButton::Right => Self::Right,
+ crossterm::event::MouseButton::Middle => Self::Middle,
+ }
+ }
+
+ pub(crate) fn to_crossterm(self) -> crossterm::event::MouseButton {
+ match self {
+ Self::Left => crossterm::event::MouseButton::Left,
+ Self::Right => crossterm::event::MouseButton::Right,
+ Self::Middle => crossterm::event::MouseButton::Middle,
+ }
+ }
+}
+
+impl ClientMouseKind {
+ #[cfg(windows)]
+ pub(crate) fn from_crossterm(kind: crossterm::event::MouseEventKind) -> Option<Self> {
+ use crossterm::event::MouseEventKind;
+ Some(match kind {
+ MouseEventKind::Down(button) => Self::Down(ClientMouseButton::from_crossterm(button)),
+ MouseEventKind::Up(button) => Self::Up(ClientMouseButton::from_crossterm(button)),
+ MouseEventKind::Drag(button) => Self::Drag(ClientMouseButton::from_crossterm(button)),
+ MouseEventKind::Moved => Self::Moved,
+ MouseEventKind::ScrollUp => Self::ScrollUp,
+ MouseEventKind::ScrollDown => Self::ScrollDown,
+ MouseEventKind::ScrollLeft => Self::ScrollLeft,
+ MouseEventKind::ScrollRight => Self::ScrollRight,
+ })
+ }
+
+ pub(crate) fn to_crossterm(self) -> crossterm::event::MouseEventKind {
+ use crossterm::event::MouseEventKind;
+ match self {
+ Self::Down(button) => MouseEventKind::Down(button.to_crossterm()),
+ Self::Up(button) => MouseEventKind::Up(button.to_crossterm()),
+ Self::Drag(button) => MouseEventKind::Drag(button.to_crossterm()),
+ Self::Moved => MouseEventKind::Moved,
+ Self::ScrollUp => MouseEventKind::ScrollUp,
+ Self::ScrollDown => MouseEventKind::ScrollDown,
+ Self::ScrollLeft => MouseEventKind::ScrollLeft,
+ Self::ScrollRight => MouseEventKind::ScrollRight,
+ }
+ }
+}
+
+impl ClientInputEvent {
+ #[cfg(windows)]
+ pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option<Self> {
+ match event {
+ crossterm::event::Event::Key(key) => Some(Self::Key {
+ code: ClientKeyCode::from_crossterm(key.code)?,
+ modifiers: key.modifiers.bits(),
+ kind: ClientKeyKind::from_crossterm(key.kind),
+ }),
+ crossterm::event::Event::Mouse(mouse) => Some(Self::Mouse {
+ kind: ClientMouseKind::from_crossterm(mouse.kind)?,
+ column: mouse.column,
+ row: mouse.row,
+ modifiers: mouse.modifiers.bits(),
+ }),
+ crossterm::event::Event::Paste(text) => Some(Self::Paste { text }),
+ crossterm::event::Event::FocusGained => Some(Self::FocusGained),
+ crossterm::event::Event::FocusLost => Some(Self::FocusLost),
+ crossterm::event::Event::Resize(_, _) => None,
+ }
+ }
+
+ pub(crate) fn to_raw_input_event(&self) -> crate::raw_input::RawInputEvent {
+ match self {
+ Self::Key {
+ code,
+ modifiers,
+ kind,
+ } => crate::raw_input::RawInputEvent::Key(
+ crate::input::TerminalKey::new(
+ code.to_crossterm(),
+ crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
+ )
+ .with_kind(kind.to_crossterm()),
+ ),
+ Self::Mouse {
+ kind,
+ column,
+ row,
+ modifiers,
+ } => crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: kind.to_crossterm(),
+ column: *column,
+ row: *row,
+ modifiers: crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
+ }),
+ Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()),
+ Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained,
+ Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost,
+ }
+ }
+}
+
/// Messages sent from the client to the server over the client protocol socket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMessage {
@@ -90,6 +332,9 @@ pub enum ClientMessage {
data: Vec<u8>,
},
+ /// Structured input events from platform clients that do not expose Unix-style raw bytes.
+ InputEvents { events: Vec<ClientInputEvent> },
+
/// Image bytes read from the client's local clipboard for remote paste bridging.
ClipboardImage {
/// Image file extension without a leading dot.
@@ -684,6 +929,67 @@ mod tests {
}
#[test]
+ fn client_input_events_roundtrip() {
+ let msg = ClientMessage::InputEvents {
+ events: vec![
+ ClientInputEvent::Key {
+ code: ClientKeyCode::Char('N'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: ClientKeyKind::Press,
+ },
+ ClientInputEvent::Key {
+ code: ClientKeyCode::Backspace,
+ modifiers: 0,
+ kind: ClientKeyKind::Press,
+ },
+ ClientInputEvent::Mouse {
+ kind: ClientMouseKind::Down(ClientMouseButton::Left),
+ column: 3,
+ row: 4,
+ modifiers: 0,
+ },
+ ],
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_input_events_convert_to_raw_keys() {
+ let shifted = ClientInputEvent::Key {
+ code: ClientKeyCode::Char('N'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: ClientKeyKind::Press,
+ }
+ .to_raw_input_event();
+ match shifted {
+ crate::raw_input::RawInputEvent::Key(key) => {
+ assert_eq!(key.code, crossterm::event::KeyCode::Char('N'));
+ assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT);
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
+ }
+ other => panic!("expected shifted key event, got {other:?}"),
+ }
+
+ let backspace = ClientInputEvent::Key {
+ code: ClientKeyCode::Backspace,
+ modifiers: 0,
+ kind: ClientKeyKind::Press,
+ }
+ .to_raw_input_event();
+ match backspace {
+ crate::raw_input::RawInputEvent::Key(key) => {
+ assert_eq!(key.code, crossterm::event::KeyCode::Backspace);
+ assert_eq!(key.modifiers, crossterm::event::KeyModifiers::empty());
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
+ }
+ other => panic!("expected backspace key event, got {other:?}"),
+ }
+ }
+
+ #[test]
fn client_clipboard_image_roundtrip() {
let msg = ClientMessage::ClipboardImage {
extension: "png".to_owned(),
@@ -1497,6 +1803,7 @@ mod tests {
// ---- Unix socketpair integration test ----
+ #[cfg(unix)]
#[test]
fn framing_over_unix_socketpair() {
use std::os::unix::net::UnixStream;
diff --git a/src/pty/actor.rs b/src/pty/actor.rs
index 70634475..8c3a6b52 100644
--- a/src/pty/actor.rs
+++ b/src/pty/actor.rs
@@ -1,1172 +1,231 @@
-use std::{
- collections::VecDeque,
- io::{Read, Write},
- os::fd::{AsRawFd, OwnedFd, RawFd},
- sync::{mpsc as std_mpsc, Arc, Mutex},
- time::{Duration, Instant},
-};
+#[cfg(unix)]
+mod unix;
-use bytes::Bytes;
-use tokio::sync::mpsc::{self, error::TryRecvError as DataTryRecvError};
-use tracing::{debug, warn};
+#[cfg(unix)]
+pub(crate) use unix::*;
-use crate::pty::fd;
+#[cfg(windows)]
+mod windows {
+ use std::io::{Read, Write};
+ use std::sync::{mpsc as std_mpsc, Arc, Mutex};
+ use std::time::Duration;
-#[cfg(not(test))]
-const ACTOR_POLL_MS: i32 = 50;
-#[cfg(test)]
-const ACTOR_POLL_MS: i32 = 1000;
-const ACTOR_COMMAND_BUFFER: usize = 1024;
-const HANDOFF_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
+ use bytes::Bytes;
+ use portable_pty::{MasterPty, PtySize};
+ use tokio::sync::mpsc;
+ use tracing::{debug, warn};
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ActorState {
- Running,
- Quiesced,
- Released,
-}
-
-pub(crate) struct PtyReadResult {
- pub terminal_responses: Vec<Bytes>,
-}
-
-impl PtyReadResult {
- #[cfg(test)]
- pub(crate) fn empty() -> Self {
- Self {
- terminal_responses: Vec::new(),
- }
- }
-}
-
-type ReadCallback = Box<dyn FnMut(&[u8]) -> PtyReadResult + Send + 'static>;
-type ReaderExitCallback = Box<dyn FnOnce() + Send + 'static>;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct PtyResize {
- rows: u16,
- cols: u16,
- cell_width_px: u32,
- cell_height_px: u32,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct PtyResizeRequest {
- resize: PtyResize,
- terminal_responses: Vec<Bytes>,
-}
-
-#[derive(Default)]
-struct SharedPtyControls {
- resize: Option<PtyResizeRequest>,
- nudge: Option<PtyResize>,
-}
-
-pub(crate) struct PtyIoActorConfig {
- pub pane_id: u32,
- pub master_fd: OwnedFd,
- pub initially_quiesced: bool,
- pub on_read: ReadCallback,
- pub on_reader_exit: Option<ReaderExitCallback>,
-}
-
-enum PtyIoDataCommand {
- WriteUserInput(Bytes),
-}
-
-enum PtyIoControlCommand {
- BeginHandoff(std_mpsc::Sender<std::io::Result<()>>),
- DuplicateForHandoff(std_mpsc::Sender<std::io::Result<RawFd>>),
- ForegroundProcessGroup(std_mpsc::Sender<Option<u32>>),
- RollbackHandoff(std_mpsc::Sender<std::io::Result<()>>),
- ReleaseAfterCommit(std_mpsc::Sender<std::io::Result<()>>),
- Shutdown,
-}
-
-#[derive(Clone)]
-pub(crate) struct PtyIoActorHandle {
- data_tx: mpsc::Sender<PtyIoDataCommand>,
- control_tx: std_mpsc::Sender<PtyIoControlCommand>,
- wake: fd::WakeWriter,
- user_writes: Arc<Mutex<UserWriteGate>>,
- controls: Arc<Mutex<SharedPtyControls>>,
-}
-
-#[derive(Debug)]
-struct UserWriteGate {
- accepting: bool,
-}
-
-impl PtyIoActorHandle {
- pub(crate) async fn write_user_input(
- &self,
- bytes: Bytes,
- ) -> Result<(), mpsc::error::SendError<Bytes>> {
- {
- let user_writes = self
- .user_writes
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- if !user_writes.accepting {
- return Err(mpsc::error::SendError(bytes));
- }
- }
-
- let permit = match self.data_tx.reserve().await {
- Ok(permit) => permit,
- Err(_) => return Err(mpsc::error::SendError(bytes)),
- };
-
- let user_writes = self
- .user_writes
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- if !user_writes.accepting {
- return Err(mpsc::error::SendError(bytes));
- }
- permit.send(PtyIoDataCommand::WriteUserInput(bytes));
- self.wake_actor();
- Ok(())
+ pub(crate) struct PtyReadResult {
+ pub terminal_responses: Vec<Bytes>,
}
- pub(crate) fn try_write_user_input(
- &self,
- bytes: Bytes,
- ) -> Result<(), mpsc::error::TrySendError<Bytes>> {
- let user_writes = self
- .user_writes
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- if !user_writes.accepting {
- return Err(mpsc::error::TrySendError::Closed(bytes));
- }
- match self
- .data_tx
- .try_send(PtyIoDataCommand::WriteUserInput(bytes))
- {
- Ok(()) => {
- self.wake_actor();
- Ok(())
- }
- Err(mpsc::error::TrySendError::Full(PtyIoDataCommand::WriteUserInput(bytes))) => {
- Err(mpsc::error::TrySendError::Full(bytes))
- }
- Err(mpsc::error::TrySendError::Closed(PtyIoDataCommand::WriteUserInput(bytes))) => {
- Err(mpsc::error::TrySendError::Closed(bytes))
- }
- }
- }
+ type ReadCallback = Box<dyn FnMut(&[u8]) -> PtyReadResult + Send + 'static>;
+ type ReaderExitCallback = Box<dyn FnOnce() + Send + 'static>;
- pub(crate) fn resize(
- &self,
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ struct PtyResize {
rows: u16,
cols: u16,
cell_width_px: u32,
cell_height_px: u32,
- terminal_responses: Vec<Bytes>,
- ) {
- {
- let mut controls = self
- .controls
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- controls.resize = Some(PtyResizeRequest {
- resize: PtyResize {
- rows,
- cols,
- cell_width_px,
- cell_height_px,
- },
- terminal_responses,
- });
- }
- self.wake_actor();
- }
-
- pub(crate) fn nudge_child_redraw_after_handoff(
- &self,
- rows: u16,
- cols: u16,
- cell_width_px: u32,
- cell_height_px: u32,
- ) {
- {
- let mut controls = self
- .controls
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- controls.nudge = Some(PtyResize {
- rows,
- cols,
- cell_width_px,
- cell_height_px,
- });
- }
- self.wake_actor();
}
- pub(crate) fn begin_handoff(&self, timeout: Duration) -> std::io::Result<()> {
- let (reply_tx, reply_rx) = std_mpsc::channel();
- {
- let mut user_writes = self
- .user_writes
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- user_writes.accepting = false;
- if self
- .control_tx
- .send(PtyIoControlCommand::BeginHandoff(reply_tx))
- .is_err()
- {
- user_writes.accepting = true;
- return Err(std::io::Error::new(
- std::io::ErrorKind::BrokenPipe,
- "pty actor closed",
- ));
- }
- self.wake_actor();
- }
- match reply_rx.recv_timeout(timeout) {
- Ok(Ok(())) => Ok(()),
- Ok(Err(err)) => {
- let _ = self.rollback_handoff();
- Err(err)
- }
- Err(_) => {
- let _ = self.rollback_handoff();
- Err(std::io::Error::new(
- std::io::ErrorKind::TimedOut,
- "timed out waiting for PTY actor to quiesce",
- ))
- }
- }
+ struct PtyResizeRequest {
+ resize: PtyResize,
+ terminal_responses: Vec<Bytes>,
}
- pub(crate) fn duplicate_for_handoff(&self) -> std::io::Result<RawFd> {
- let (reply_tx, reply_rx) = std_mpsc::channel();
- self.control_tx
- .send(PtyIoControlCommand::DuplicateForHandoff(reply_tx))
- .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
- self.wake_actor();
- reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
- std::io::Error::new(
- std::io::ErrorKind::TimedOut,
- "timed out waiting for PTY handoff duplicate",
- )
- })?
+ pub(crate) struct PtyIoActorConfig {
+ pub pane_id: u32,
+ pub master: Box<dyn MasterPty + Send>,
+ pub initially_quiesced: bool,
+ pub on_read: ReadCallback,
+ pub on_reader_exit: Option<ReaderExitCallback>,
}
- pub(crate) fn foreground_process_group_id(&self) -> Option<u32> {
- let (reply_tx, reply_rx) = std_mpsc::channel();
- self.control_tx
- .send(PtyIoControlCommand::ForegroundProcessGroup(reply_tx))
- .ok()?;
- self.wake_actor();
- reply_rx.recv_timeout(Duration::from_secs(1)).ok()?
+ enum PtyIoControlCommand {
+ Resize(PtyResizeRequest),
+ Shutdown,
}
- pub(crate) fn rollback_handoff(&self) -> std::io::Result<()> {
- let (reply_tx, reply_rx) = std_mpsc::channel();
- self.control_tx
- .send(PtyIoControlCommand::RollbackHandoff(reply_tx))
- .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
- self.wake_actor();
- let result = reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
- std::io::Error::new(
- std::io::ErrorKind::TimedOut,
- "timed out waiting for PTY handoff rollback",
- )
- })?;
- if result.is_ok() {
- let mut user_writes = self
- .user_writes
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- user_writes.accepting = true;
- }
- result
+ #[derive(Clone)]
+ pub(crate) struct PtyIoActorHandle {
+ data_tx: mpsc::Sender<Bytes>,
+ control_tx: std_mpsc::Sender<PtyIoControlCommand>,
+ accepting: Arc<Mutex<bool>>,
}
- pub(crate) fn release_after_commit(&self) -> std::io::Result<()> {
- {
- let mut user_writes = self
- .user_writes
+ impl PtyIoActorHandle {
+ pub(crate) async fn write_user_input(
+ &self,
+ bytes: Bytes,
+ ) -> Result<(), mpsc::error::SendError<Bytes>> {
+ if !*self
+ .accepting
.lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- user_writes.accepting = false;
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ {
+ return Err(mpsc::error::SendError(bytes));
+ }
+ self.data_tx.send(bytes).await
}
- let (reply_tx, reply_rx) = std_mpsc::channel();
- self.control_tx
- .send(PtyIoControlCommand::ReleaseAfterCommit(reply_tx))
- .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
- self.wake_actor();
- reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
- std::io::Error::new(
- std::io::ErrorKind::TimedOut,
- "timed out waiting for PTY actor release",
- )
- })?
- }
- pub(crate) fn shutdown(&self) {
- {
- let mut user_writes = self
- .user_writes
+ pub(crate) fn try_write_user_input(
+ &self,
+ bytes: Bytes,
+ ) -> Result<(), mpsc::error::TrySendError<Bytes>> {
+ if !*self
+ .accepting
.lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- user_writes.accepting = false;
- }
- if self.control_tx.send(PtyIoControlCommand::Shutdown).is_ok() {
- self.wake_actor();
- }
- }
-
- fn wake_actor(&self) {
- if let Err(err) = self.wake.wake() {
- debug!(err = %err, "failed to wake PTY actor");
- }
- }
-}
-
-pub(crate) struct PtyIoActor;
-
-impl PtyIoActor {
- pub(crate) fn spawn(config: PtyIoActorConfig) -> std::io::Result<PtyIoActorHandle> {
- Self::spawn_inner(config, None)
- }
-
- fn spawn_inner(
- config: PtyIoActorConfig,
- poll_observer: Option<std_mpsc::Sender<()>>,
- ) -> std::io::Result<PtyIoActorHandle> {
- fd::set_cloexec(config.master_fd.as_raw_fd())?;
- fd::set_nonblocking(config.master_fd.as_raw_fd())?;
-
- let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER);
- let (control_tx, control_rx) = std_mpsc::channel();
- let wake_pipe = fd::create_wake_pipe()?;
- let user_writes = Arc::new(Mutex::new(UserWriteGate {
- accepting: !config.initially_quiesced,
- }));
- let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
- let handle = PtyIoActorHandle {
- data_tx,
- control_tx,
- wake: wake_pipe.writer,
- user_writes,
- controls: Arc::clone(&controls),
- };
-
- let mut runner = PtyIoActorRunner {
- pane_id: config.pane_id,
- file: std::fs::File::from(config.master_fd),
- data_rx,
- control_rx,
- state: if config.initially_quiesced {
- ActorState::Quiesced
- } else {
- ActorState::Running
- },
- pending_writes: VecDeque::new(),
- current_write_offset: 0,
- wake_read_fd: wake_pipe.read_fd,
- controls,
- on_read: config.on_read,
- on_reader_exit: config.on_reader_exit,
- poll_observer,
- };
- std::thread::Builder::new()
- .name(format!("herdr-pty-{}", config.pane_id))
- .spawn(move || runner.run())
- .map_err(|err| std::io::Error::other(err.to_string()))?;
-
- Ok(handle)
- }
-
- #[cfg(test)]
- fn spawn_with_poll_observer(
- config: PtyIoActorConfig,
- poll_observer: std_mpsc::Sender<()>,
- ) -> std::io::Result<PtyIoActorHandle> {
- Self::spawn_inner(config, Some(poll_observer))
- }
-}
-
-struct PtyIoActorRunner {
- pane_id: u32,
- file: std::fs::File,
- data_rx: mpsc::Receiver<PtyIoDataCommand>,
- control_rx: std_mpsc::Receiver<PtyIoControlCommand>,
- state: ActorState,
- pending_writes: VecDeque<Bytes>,
- current_write_offset: usize,
- wake_read_fd: OwnedFd,
- controls: Arc<Mutex<SharedPtyControls>>,
- on_read: ReadCallback,
- on_reader_exit: Option<ReaderExitCallback>,
- poll_observer: Option<std_mpsc::Sender<()>>,
-}
-
-impl PtyIoActorRunner {
- fn run(&mut self) {
- let mut should_exit = false;
- while !should_exit {
- should_exit = self.drain_commands();
- if should_exit || self.state == ActorState::Released {
- break;
- }
-
- self.apply_pending_controls();
-
- if !self.pending_writes.is_empty() {
- self.flush_pending_writes_once();
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ {
+ return Err(mpsc::error::TrySendError::Closed(bytes));
}
+ self.data_tx.try_send(bytes)
+ }
- if let Some(poll_observer) = &self.poll_observer {
- let _ = poll_observer.send(());
- }
+ pub(crate) fn resize(
+ &self,
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ terminal_responses: Vec<Bytes>,
+ ) {
+ let _ = self
+ .control_tx
+ .send(PtyIoControlCommand::Resize(PtyResizeRequest {
+ resize: PtyResize {
+ rows,
+ cols,
+ cell_width_px,
+ cell_height_px,
+ },
+ terminal_responses,
+ }));
+ }
+
+ pub(crate) fn shutdown(&self) {
+ if let Ok(mut accepting) = self.accepting.lock() {
+ *accepting = false;
+ }
+ let _ = self.control_tx.send(PtyIoControlCommand::Shutdown);
+ }
+ }
+
+ pub(crate) struct PtyIoActor;
+
+ impl PtyIoActor {
+ pub(crate) fn spawn(config: PtyIoActorConfig) -> std::io::Result<PtyIoActorHandle> {
+ let PtyIoActorConfig {
+ pane_id,
+ master,
+ initially_quiesced,
+ mut on_read,
+ on_reader_exit,
+ } = config;
+
+ let mut reader = master
+ .try_clone_reader()
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+ let writer = master
+ .take_writer()
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+ let writer = Arc::new(Mutex::new(writer));
+ let (data_tx, mut data_rx) = mpsc::channel::<Bytes>(1024);
+ let (control_tx, control_rx) = std_mpsc::channel::<PtyIoControlCommand>();
+ let accepting = Arc::new(Mutex::new(!initially_quiesced));
- match fd::poll_pty_and_wake(
- self.file.as_raw_fd(),
- self.wake_read_fd.as_raw_fd(),
- self.state == ActorState::Running,
- !self.pending_writes.is_empty(),
- ACTOR_POLL_MS,
- ) {
- Ok(readiness) => {
- if readiness.wake_ready {
- if let Err(err) = fd::drain_wake_fd(self.wake_read_fd.as_raw_fd()) {
- debug!(pane = self.pane_id, err = %err, "PTY actor wake drain failed");
+ {
+ let writer = Arc::clone(&writer);
+ std::thread::spawn(move || {
+ while let Some(bytes) = data_rx.blocking_recv() {
+ if write_all_locked(&writer, &bytes).is_err() {
break;
}
- continue;
- }
- if readiness.pty_write_ready && !self.pending_writes.is_empty() {
- self.flush_pending_writes_once();
- }
- if self.state == ActorState::Running
- && readiness.pty_read_ready
- && !self.read_once()
- {
- break;
}
- }
- Err(err) => {
- debug!(pane = self.pane_id, err = %err, "PTY actor poll failed");
- break;
- }
+ debug!(pane_id, "windows pty writer thread exiting");
+ });
}
- }
- if let Some(on_reader_exit) = self.on_reader_exit.take() {
- on_reader_exit();
- }
- debug!(pane = self.pane_id, "PTY actor exiting");
- }
-
- fn drain_commands(&mut self) -> bool {
- if self.drain_control_commands() {
- return true;
- }
- self.drain_data_commands()
- }
-
- fn drain_control_commands(&mut self) -> bool {
- let mut should_exit = false;
- loop {
- match self.control_rx.try_recv() {
- Ok(command) => {
- if self.handle_control_command(command) {
- should_exit = true;
- break;
+ {
+ let writer = Arc::clone(&writer);
+ std::thread::spawn(move || {
+ let mut buf = [0u8; 8192];
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ let result = on_read(&buf[..n]);
+ for response in result.terminal_responses {
+ if write_all_locked(&writer, &response).is_err() {
+ break;
+ }
+ }
+ }
+ Err(err) => {
+ debug!(pane_id, err = %err, "windows pty reader failed");
+ break;
+ }
+ }
}
- }
- Err(std_mpsc::TryRecvError::Empty) => break,
- Err(std_mpsc::TryRecvError::Disconnected) => {
- should_exit = true;
- break;
- }
- }
- }
- should_exit
- }
-
- fn drain_data_commands(&mut self) -> bool {
- let mut should_exit = false;
- loop {
- match self.data_rx.try_recv() {
- Ok(command) => {
- if self.handle_data_command(command) {
- should_exit = true;
- break;
+ if let Some(on_reader_exit) = on_reader_exit {
+ on_reader_exit();
}
- }
- Err(DataTryRecvError::Empty) => break,
- Err(DataTryRecvError::Disconnected) => {
- should_exit = true;
- break;
- }
+ debug!(pane_id, "windows pty reader thread exiting");
+ });
}
- }
- should_exit
- }
- fn handle_data_command(&mut self, command: PtyIoDataCommand) -> bool {
- match command {
- PtyIoDataCommand::WriteUserInput(bytes) => {
- if self.state == ActorState::Running {
- self.pending_writes.push_back(bytes);
- }
- }
- }
- false
- }
-
- fn handle_control_command(&mut self, command: PtyIoControlCommand) -> bool {
- match command {
- PtyIoControlCommand::BeginHandoff(reply) => {
- let result = self.begin_handoff();
- let _ = reply.send(result);
- }
- PtyIoControlCommand::DuplicateForHandoff(reply) => {
- let result = if self.state == ActorState::Quiesced {
- fd::duplicate_cloexec_fd(self.file.as_raw_fd())
- } else {
- Err(std::io::Error::other(
- "PTY actor must be quiesced before handoff duplication",
- ))
- };
- let _ = reply.send(result);
- }
- PtyIoControlCommand::ForegroundProcessGroup(reply) => {
- let result =
- crate::platform::foreground_process_group_id_for_tty_fd(self.file.as_raw_fd());
- let _ = reply.send(result);
- }
- PtyIoControlCommand::RollbackHandoff(reply) => {
- let result = if self.state == ActorState::Released {
- Err(std::io::Error::new(
- std::io::ErrorKind::BrokenPipe,
- "PTY actor was released before handoff rollback",
- ))
- } else {
- self.state = ActorState::Running;
- Ok(())
- };
- let _ = reply.send(result);
- }
- PtyIoControlCommand::ReleaseAfterCommit(reply) => {
- self.state = ActorState::Released;
- self.pending_writes.clear();
- let _ = reply.send(Ok(()));
- return true;
- }
- PtyIoControlCommand::Shutdown => return true,
- }
- false
- }
-
- fn begin_handoff(&mut self) -> std::io::Result<()> {
- self.drain_pre_quiesce_commands();
- self.apply_pending_controls();
- if self.state == ActorState::Released {
- return Err(std::io::Error::new(
- std::io::ErrorKind::BrokenPipe,
- "PTY actor was released before handoff quiesce",
- ));
- }
- let deadline = Instant::now() + HANDOFF_DRAIN_TIMEOUT;
- while !self.pending_writes.is_empty() {
- if Instant::now() >= deadline {
- return Err(std::io::Error::new(
- std::io::ErrorKind::TimedOut,
- "timed out draining PTY writes before handoff",
- ));
- }
- self.flush_pending_writes_once();
- }
- self.state = ActorState::Quiesced;
- Ok(())
- }
-
- fn drain_pre_quiesce_commands(&mut self) {
- while let Ok(PtyIoDataCommand::WriteUserInput(bytes)) = self.data_rx.try_recv() {
- if self.state != ActorState::Released {
- self.pending_writes.push_back(bytes);
- }
- }
- }
-
- fn apply_pending_controls(&mut self) {
- let (resize, nudge) = {
- let mut controls = self
- .controls
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- (controls.resize.take(), controls.nudge.take())
- };
- if self.state == ActorState::Released {
- return;
- }
- if let Some(request) = resize {
- self.resize(request.resize);
- self.enqueue_terminal_responses(request.terminal_responses);
- }
- if let Some(nudge) = nudge {
- self.nudge(nudge);
- }
- }
-
- fn read_once(&mut self) -> bool {
- let mut buf = [0u8; 8192];
- match self.file.read(&mut buf) {
- Ok(0) => false,
- Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => true,
- Err(err) if err.kind() == std::io::ErrorKind::Interrupted => true,
- Err(err) => {
- debug!(pane = self.pane_id, err = %err, "PTY actor read failed");
- false
- }
- Ok(n) => {
- let result = (self.on_read)(&buf[..n]);
- self.enqueue_terminal_responses(result.terminal_responses);
- true
- }
- }
- }
-
- fn enqueue_terminal_responses(&mut self, terminal_responses: Vec<Bytes>) {
- if self.state == ActorState::Released {
- return;
- }
- self.pending_writes.extend(terminal_responses);
- }
-
- fn flush_pending_writes_once(&mut self) {
- while let Some(bytes) = self.pending_writes.front() {
- let chunk = &bytes[self.current_write_offset..];
- match self.file.write(chunk) {
- Ok(0) => {
- warn!(pane = self.pane_id, "PTY actor write returned zero bytes");
- return;
- }
- Ok(written) => {
- self.current_write_offset += written;
- if self.current_write_offset >= bytes.len() {
- self.pending_writes.pop_front();
- self.current_write_offset = 0;
+ {
+ let writer = Arc::clone(&writer);
+ std::thread::spawn(move || {
+ for command in control_rx {
+ match command {
+ PtyIoControlCommand::Resize(request) => {
+ let size = request.resize;
+ if let Err(err) = master.resize(PtySize {
+ rows: size.rows,
+ cols: size.cols,
+ pixel_width: size.cell_width_px.min(u16::MAX as u32) as u16,
+ pixel_height: size.cell_height_px.min(u16::MAX as u32) as u16,
+ }) {
+ warn!(pane_id, err = %err, "windows pty resize failed");
+ }
+ for response in request.terminal_responses {
+ if write_all_locked(&writer, &response).is_err() {
+ break;
+ }
+ }
+ }
+ PtyIoControlCommand::Shutdown => break,
+ }
}
- }
- Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
- let _ = fd::poll_write_ready(self.file.as_raw_fd(), ACTOR_POLL_MS);
- return;
- }
- Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return,
- Err(err) => {
- warn!(pane = self.pane_id, err = %err, "PTY actor write failed");
- self.pending_writes.clear();
- self.current_write_offset = 0;
- return;
- }
+ debug!(pane_id, "windows pty control thread exiting");
+ });
}
- }
- let _ = self.file.flush();
- }
- fn resize(&self, resize: PtyResize) {
- self.log_resize_result(fd::resize_pty_fd(
- self.file.as_raw_fd(),
- resize.rows,
- resize.cols,
- resize.cell_width_px,
- resize.cell_height_px,
- ));
- }
-
- fn nudge(&mut self, resize: PtyResize) {
- if self.state == ActorState::Released {
- return;
- }
- let nudge = if resize.rows > 2 {
- (
- resize.rows - 1,
- resize.cols,
- resize.cell_width_px,
- resize.cell_height_px,
- )
- } else {
- (
- resize.rows,
- resize.cols.saturating_sub(1).max(4),
- resize.cell_width_px,
- resize.cell_height_px,
- )
- };
- if nudge
- == (
- resize.rows,
- resize.cols,
- resize.cell_width_px,
- resize.cell_height_px,
- )
- {
- return;
- }
- self.log_resize_result(fd::resize_pty_fd(
- self.file.as_raw_fd(),
- nudge.0,
- nudge.1,
- nudge.2,
- nudge.3,
- ));
- std::thread::sleep(Duration::from_millis(30));
- self.log_resize_result(fd::resize_pty_fd(
- self.file.as_raw_fd(),
- resize.rows,
- resize.cols,
- resize.cell_width_px,
- resize.cell_height_px,
- ));
- }
-
- fn log_resize_result(&self, result: std::io::Result<()>) {
- if let Err(err) = result {
- debug!(pane = self.pane_id, err = %err, "PTY resize failed");
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::{
- io::{Read, Write},
- os::fd::{AsRawFd, FromRawFd, IntoRawFd},
- os::unix::net::UnixStream,
- };
-
- fn test_wake_pair() -> (fd::WakeWriter, OwnedFd) {
- let pipe = fd::create_wake_pipe().expect("wake pipe");
- (pipe.writer, pipe.read_fd)
- }
-
- fn actor_with_socket_pair(
- initially_quiesced: bool,
- ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver<Bytes>) {
- actor_with_socket_pair_and_poll_observer(initially_quiesced, None)
- }
-
- fn actor_with_socket_pair_and_poll_observer(
- initially_quiesced: bool,
- poll_observer: Option<std_mpsc::Sender<()>>,
- ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver<Bytes>) {
- let (actor_socket, peer) = UnixStream::pair().expect("socket pair");
- actor_socket
- .set_nonblocking(true)
- .expect("actor socket nonblocking");
- peer.set_read_timeout(Some(Duration::from_secs(1)))
- .expect("peer timeout");
- let owned = unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) };
- let (read_tx, read_rx) = std_mpsc::channel();
- let config = PtyIoActorConfig {
- pane_id: 1,
- master_fd: owned,
- initially_quiesced,
- on_read: Box::new(move |bytes| {
- read_tx
- .send(Bytes::copy_from_slice(bytes))
- .expect("read callback receiver alive");
- PtyReadResult::empty()
- }),
- on_reader_exit: None,
- };
- let handle = if let Some(poll_observer) = poll_observer {
- PtyIoActor::spawn_with_poll_observer(config, poll_observer)
- } else {
- PtyIoActor::spawn(config)
- }
- .expect("actor spawn");
- (handle, peer, read_rx)
- }
-
- #[test]
- fn actor_writes_user_input_to_owned_fd() {
- let (handle, mut peer, _read_rx) = actor_with_socket_pair(false);
-
- handle
- .try_write_user_input(Bytes::from_static(b"hello"))
- .expect("write command accepted");
-
- let mut buf = [0u8; 5];
- peer.read_exact(&mut buf).expect("peer receives write");
- assert_eq!(&buf, b"hello");
- handle.shutdown();
- }
-
- #[test]
- fn actor_wakes_idle_poll_for_user_input() {
- let (poll_tx, poll_rx) = std_mpsc::channel();
- let (handle, mut peer, _read_rx) =
- actor_with_socket_pair_and_poll_observer(false, Some(poll_tx));
- peer.set_read_timeout(Some(Duration::from_millis(500)))
- .expect("peer timeout");
- poll_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("actor entered idle poll");
-
- let start = Instant::now();
- handle
- .try_write_user_input(Bytes::from_static(b"x"))
- .expect("write command accepted");
-
- let mut buf = [0u8; 1];
- peer.read_exact(&mut buf)
- .expect("peer receives write without waiting for actor poll timeout");
- assert_eq!(&buf, b"x");
- assert!(
- start.elapsed() < Duration::from_millis(500),
- "actor write should be driven by wake fd, not the idle poll timeout"
- );
- handle.shutdown();
- }
-
- #[test]
- fn poll_ignores_pty_hup_without_pty_interest() {
- let (actor_socket, peer) = UnixStream::pair().expect("socket pair");
- actor_socket
- .set_nonblocking(true)
- .expect("actor socket nonblocking");
- drop(peer);
- let wake_pipe = fd::create_wake_pipe().expect("wake pipe");
-
- let readiness = fd::poll_pty_and_wake(
- actor_socket.as_raw_fd(),
- wake_pipe.read_fd.as_raw_fd(),
- false,
- false,
- 10,
- )
- .expect("poll succeeds");
-
- assert!(!readiness.pty_read_ready);
- assert!(!readiness.pty_write_ready);
- assert!(!readiness.wake_ready);
- }
-
- #[test]
- fn actor_delivers_fd_reads_to_callback() {
- let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
-
- peer.write_all(b"from-peer").expect("peer write");
-
- let read = read_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("actor read callback");
- assert_eq!(read, Bytes::from_static(b"from-peer"));
- handle.shutdown();
- }
-
- #[test]
- fn begin_handoff_stops_reads_and_rejects_user_writes_until_rollback() {
- let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
-
- handle
- .begin_handoff(Duration::from_secs(1))
- .expect("handoff quiesced");
- assert!(handle
- .try_write_user_input(Bytes::from_static(b"blocked"))
- .is_err());
-
- peer.write_all(b"held").expect("peer write during quiesce");
- assert!(
- read_rx.recv_timeout(Duration::from_millis(150)).is_err(),
- "actor must not read while quiesced"
- );
-
- handle.rollback_handoff().expect("rollback resumes actor");
- let read = read_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("actor reads held bytes after rollback");
- assert_eq!(read, Bytes::from_static(b"held"));
-
- handle
- .try_write_user_input(Bytes::from_static(b"after"))
- .expect("write accepted after rollback");
- let mut buf = [0u8; 5];
- peer.read_exact(&mut buf).expect("peer receives after");
- assert_eq!(&buf, b"after");
- handle.shutdown();
- }
-
- #[test]
- fn duplicate_for_handoff_requires_quiesced_actor() {
- let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
-
- assert!(handle.duplicate_for_handoff().is_err());
- handle
- .begin_handoff(Duration::from_secs(1))
- .expect("handoff quiesced");
- let duplicate = handle
- .duplicate_for_handoff()
- .expect("handoff duplicate created");
- assert!(duplicate >= 0);
- unsafe {
- libc::close(duplicate);
- }
- handle.rollback_handoff().expect("rollback resumes actor");
-
- peer.write_all(b"still-live").expect("peer write");
- let read = read_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("actor still reads after duplicate closes");
- assert_eq!(read, Bytes::from_static(b"still-live"));
- handle.shutdown();
- }
-
- #[test]
- fn resize_and_nudge_keep_latest_request_when_command_queue_is_full() {
- let (data_tx, _data_rx) = mpsc::channel(1);
- let (control_tx, _control_rx) = std_mpsc::channel();
- data_tx
- .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
- b"fill",
- )))
- .expect("fill command queue");
- let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
- let (wake, _wake_read_fd) = test_wake_pair();
- let handle = PtyIoActorHandle {
- data_tx,
- control_tx,
- wake,
- user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
- controls: Arc::clone(&controls),
- };
-
- handle.resize(20, 80, 8, 16, vec![Bytes::from_static(b"old")]);
- handle.resize(40, 120, 9, 18, vec![Bytes::from_static(b"new")]);
- handle.nudge_child_redraw_after_handoff(41, 121, 10, 20);
-
- let controls = controls.lock().expect("controls lock");
- assert_eq!(
- controls.resize,
- Some(PtyResizeRequest {
- resize: PtyResize {
- rows: 40,
- cols: 120,
- cell_width_px: 9,
- cell_height_px: 18,
- },
- terminal_responses: vec![Bytes::from_static(b"new")],
+ Ok(PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ accepting,
})
- );
- assert_eq!(
- controls.nudge,
- Some(PtyResize {
- rows: 41,
- cols: 121,
- cell_width_px: 10,
- cell_height_px: 20,
- })
- );
- }
-
- #[test]
- fn resize_writes_terminal_responses_after_applying_resize() {
- let (handle, mut peer, _read_rx) = actor_with_socket_pair(false);
- let response = Bytes::from_static(b"\x1B[48;40;100;720;900t");
-
- handle.resize(40, 100, 9, 18, vec![response.clone()]);
-
- let mut buf = vec![0; response.len()];
- peer.read_exact(&mut buf)
- .expect("peer receives resize response");
- assert_eq!(Bytes::from(buf), response);
- handle.shutdown();
- }
-
- #[tokio::test]
- async fn async_user_input_waits_for_queue_capacity() {
- let (data_tx, mut data_rx) = mpsc::channel(1);
- let (control_tx, _control_rx) = std_mpsc::channel();
- data_tx
- .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
- b"fill",
- )))
- .expect("fill data queue");
- let (wake, _wake_read_fd) = test_wake_pair();
- let handle = PtyIoActorHandle {
- data_tx,
- control_tx,
- wake,
- user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
- controls: Arc::new(Mutex::new(SharedPtyControls::default())),
- };
-
- let write = tokio::spawn(async move {
- handle
- .write_user_input(Bytes::from_static(b"wait-for-capacity"))
- .await
- });
- tokio::time::sleep(Duration::from_millis(50)).await;
- assert!(
- !write.is_finished(),
- "async input should wait for queue capacity"
- );
-
- assert!(matches!(
- data_rx.recv().await,
- Some(PtyIoDataCommand::WriteUserInput(_))
- ));
- write
- .await
- .expect("write task joins")
- .expect("write succeeds after capacity opens");
- match data_rx.recv().await {
- Some(PtyIoDataCommand::WriteUserInput(bytes)) => {
- assert_eq!(bytes, Bytes::from_static(b"wait-for-capacity"));
- }
- _ => panic!("expected queued user input"),
- }
- }
-
- #[tokio::test]
- async fn async_user_input_waiting_for_capacity_is_rejected_after_handoff_begins() {
- let (data_tx, mut data_rx) = mpsc::channel(1);
- let (control_tx, control_rx) = std_mpsc::channel();
- data_tx
- .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
- b"fill",
- )))
- .expect("fill data queue");
- let (wake, _wake_read_fd) = test_wake_pair();
- let handle = PtyIoActorHandle {
- data_tx,
- control_tx,
- wake,
- user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
- controls: Arc::new(Mutex::new(SharedPtyControls::default())),
- };
- let write_handle = handle.clone();
- let write = tokio::spawn(async move {
- write_handle
- .write_user_input(Bytes::from_static(b"after-handoff-start"))
- .await
- });
- tokio::time::sleep(Duration::from_millis(50)).await;
-
- let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1)));
- match control_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("handoff control command")
- {
- PtyIoControlCommand::BeginHandoff(reply) => {
- reply.send(Ok(())).expect("handoff waiter alive");
- }
- _ => panic!("expected begin handoff command"),
- }
- handoff
- .join()
- .expect("handoff thread joins")
- .expect("handoff succeeds");
- assert!(matches!(
- data_rx.recv().await,
- Some(PtyIoDataCommand::WriteUserInput(_))
- ));
-
- let err = write.await.expect("write task joins").expect_err(
- "write waiting for capacity must be rejected after handoff closes the input gate",
- );
- assert_eq!(err.0, Bytes::from_static(b"after-handoff-start"));
- match tokio::time::timeout(Duration::from_millis(50), data_rx.recv()).await {
- Err(_) | Ok(None) => {}
- Ok(Some(_)) => panic!("rejected write must not be queued"),
}
}
- #[test]
- fn handoff_control_is_not_blocked_by_full_data_queue() {
- let (data_tx, _data_rx) = mpsc::channel(1);
- let (control_tx, control_rx) = std_mpsc::channel();
- data_tx
- .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
- b"fill",
- )))
- .expect("fill data queue");
- let (wake, _wake_read_fd) = test_wake_pair();
- let handle = PtyIoActorHandle {
- data_tx,
- control_tx,
- wake,
- user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
- controls: Arc::new(Mutex::new(SharedPtyControls::default())),
- };
-
- let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1)));
- match control_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("handoff control command")
- {
- PtyIoControlCommand::BeginHandoff(reply) => {
- reply.send(Ok(())).expect("handoff waiter alive");
- }
- _ => panic!("expected begin handoff command"),
- }
-
- handoff
- .join()
- .expect("handoff thread joins")
- .expect("handoff succeeds despite full data queue");
- }
-
- #[test]
- fn begin_handoff_drains_user_writes_already_in_command_queue() {
- let (actor_socket, mut peer) = UnixStream::pair().expect("socket pair");
- actor_socket
- .set_nonblocking(true)
- .expect("actor socket nonblocking");
- peer.set_read_timeout(Some(Duration::from_secs(1)))
- .expect("peer timeout");
- let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER);
- let (_control_tx, control_rx) = std_mpsc::channel();
- data_tx
- .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
- b"queued-before-ack",
- )))
- .expect("queued write");
- let mut runner = PtyIoActorRunner {
- pane_id: 1,
- file: std::fs::File::from(unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }),
- data_rx,
- control_rx,
- state: ActorState::Running,
- pending_writes: VecDeque::new(),
- current_write_offset: 0,
- wake_read_fd: fd::create_wake_pipe().expect("wake pipe").read_fd,
- controls: Arc::new(Mutex::new(SharedPtyControls::default())),
- on_read: Box::new(|_| PtyReadResult::empty()),
- on_reader_exit: None,
- poll_observer: None,
- };
-
- runner.begin_handoff().expect("handoff drains queued write");
-
- let mut buf = [0u8; 17];
- peer.read_exact(&mut buf)
- .expect("queued write reaches peer before quiesce ack");
- assert_eq!(&buf, b"queued-before-ack");
- assert_eq!(runner.state, ActorState::Quiesced);
+ fn write_all_locked(
+ writer: &Arc<Mutex<Box<dyn Write + Send>>>,
+ bytes: &[u8],
+ ) -> std::io::Result<()> {
+ let mut writer = writer
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ writer.write_all(bytes)?;
+ writer.flush()
}
- #[test]
- fn release_after_commit_prevents_further_io() {
- let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
-
- handle.release_after_commit().expect("actor released");
- assert!(handle
- .try_write_user_input(Bytes::from_static(b"blocked"))
- .is_err());
-
- let _ = peer.write_all(b"ignored");
- assert!(read_rx.recv_timeout(Duration::from_millis(150)).is_err());
- }
+ #[allow(dead_code)]
+ fn _assert_duration_send(_: Duration) {}
}
+
+#[cfg(windows)]
+pub(crate) use windows::*;
diff --git a/src/pty/actor/unix.rs b/src/pty/actor/unix.rs
new file mode 100644
index 00000000..70634475
--- /dev/null
+++ b/src/pty/actor/unix.rs
@@ -0,0 +1,1172 @@
+use std::{
+ collections::VecDeque,
+ io::{Read, Write},
+ os::fd::{AsRawFd, OwnedFd, RawFd},
+ sync::{mpsc as std_mpsc, Arc, Mutex},
+ time::{Duration, Instant},
+};
+
+use bytes::Bytes;
+use tokio::sync::mpsc::{self, error::TryRecvError as DataTryRecvError};
+use tracing::{debug, warn};
+
+use crate::pty::fd;
+
+#[cfg(not(test))]
+const ACTOR_POLL_MS: i32 = 50;
+#[cfg(test)]
+const ACTOR_POLL_MS: i32 = 1000;
+const ACTOR_COMMAND_BUFFER: usize = 1024;
+const HANDOFF_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ActorState {
+ Running,
+ Quiesced,
+ Released,
+}
+
+pub(crate) struct PtyReadResult {
+ pub terminal_responses: Vec<Bytes>,
+}
+
+impl PtyReadResult {
+ #[cfg(test)]
+ pub(crate) fn empty() -> Self {
+ Self {
+ terminal_responses: Vec::new(),
+ }
+ }
+}
+
+type ReadCallback = Box<dyn FnMut(&[u8]) -> PtyReadResult + Send + 'static>;
+type ReaderExitCallback = Box<dyn FnOnce() + Send + 'static>;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct PtyResize {
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct PtyResizeRequest {
+ resize: PtyResize,
+ terminal_responses: Vec<Bytes>,
+}
+
+#[derive(Default)]
+struct SharedPtyControls {
+ resize: Option<PtyResizeRequest>,
+ nudge: Option<PtyResize>,
+}
+
+pub(crate) struct PtyIoActorConfig {
+ pub pane_id: u32,
+ pub master_fd: OwnedFd,
+ pub initially_quiesced: bool,
+ pub on_read: ReadCallback,
+ pub on_reader_exit: Option<ReaderExitCallback>,
+}
+
+enum PtyIoDataCommand {
+ WriteUserInput(Bytes),
+}
+
+enum PtyIoControlCommand {
+ BeginHandoff(std_mpsc::Sender<std::io::Result<()>>),
+ DuplicateForHandoff(std_mpsc::Sender<std::io::Result<RawFd>>),
+ ForegroundProcessGroup(std_mpsc::Sender<Option<u32>>),
+ RollbackHandoff(std_mpsc::Sender<std::io::Result<()>>),
+ ReleaseAfterCommit(std_mpsc::Sender<std::io::Result<()>>),
+ Shutdown,
+}
+
+#[derive(Clone)]
+pub(crate) struct PtyIoActorHandle {
+ data_tx: mpsc::Sender<PtyIoDataCommand>,
+ control_tx: std_mpsc::Sender<PtyIoControlCommand>,
+ wake: fd::WakeWriter,
+ user_writes: Arc<Mutex<UserWriteGate>>,
+ controls: Arc<Mutex<SharedPtyControls>>,
+}
+
+#[derive(Debug)]
+struct UserWriteGate {
+ accepting: bool,
+}
+
+impl PtyIoActorHandle {
+ pub(crate) async fn write_user_input(
+ &self,
+ bytes: Bytes,
+ ) -> Result<(), mpsc::error::SendError<Bytes>> {
+ {
+ let user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if !user_writes.accepting {
+ return Err(mpsc::error::SendError(bytes));
+ }
+ }
+
+ let permit = match self.data_tx.reserve().await {
+ Ok(permit) => permit,
+ Err(_) => return Err(mpsc::error::SendError(bytes)),
+ };
+
+ let user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if !user_writes.accepting {
+ return Err(mpsc::error::SendError(bytes));
+ }
+ permit.send(PtyIoDataCommand::WriteUserInput(bytes));
+ self.wake_actor();
+ Ok(())
+ }
+
+ pub(crate) fn try_write_user_input(
+ &self,
+ bytes: Bytes,
+ ) -> Result<(), mpsc::error::TrySendError<Bytes>> {
+ let user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if !user_writes.accepting {
+ return Err(mpsc::error::TrySendError::Closed(bytes));
+ }
+ match self
+ .data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(bytes))
+ {
+ Ok(()) => {
+ self.wake_actor();
+ Ok(())
+ }
+ Err(mpsc::error::TrySendError::Full(PtyIoDataCommand::WriteUserInput(bytes))) => {
+ Err(mpsc::error::TrySendError::Full(bytes))
+ }
+ Err(mpsc::error::TrySendError::Closed(PtyIoDataCommand::WriteUserInput(bytes))) => {
+ Err(mpsc::error::TrySendError::Closed(bytes))
+ }
+ }
+ }
+
+ pub(crate) fn resize(
+ &self,
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ terminal_responses: Vec<Bytes>,
+ ) {
+ {
+ let mut controls = self
+ .controls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ controls.resize = Some(PtyResizeRequest {
+ resize: PtyResize {
+ rows,
+ cols,
+ cell_width_px,
+ cell_height_px,
+ },
+ terminal_responses,
+ });
+ }
+ self.wake_actor();
+ }
+
+ pub(crate) fn nudge_child_redraw_after_handoff(
+ &self,
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ ) {
+ {
+ let mut controls = self
+ .controls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ controls.nudge = Some(PtyResize {
+ rows,
+ cols,
+ cell_width_px,
+ cell_height_px,
+ });
+ }
+ self.wake_actor();
+ }
+
+ pub(crate) fn begin_handoff(&self, timeout: Duration) -> std::io::Result<()> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = false;
+ if self
+ .control_tx
+ .send(PtyIoControlCommand::BeginHandoff(reply_tx))
+ .is_err()
+ {
+ user_writes.accepting = true;
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::BrokenPipe,
+ "pty actor closed",
+ ));
+ }
+ self.wake_actor();
+ }
+ match reply_rx.recv_timeout(timeout) {
+ Ok(Ok(())) => Ok(()),
+ Ok(Err(err)) => {
+ let _ = self.rollback_handoff();
+ Err(err)
+ }
+ Err(_) => {
+ let _ = self.rollback_handoff();
+ Err(std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY actor to quiesce",
+ ))
+ }
+ }
+ }
+
+ pub(crate) fn duplicate_for_handoff(&self) -> std::io::Result<RawFd> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::DuplicateForHandoff(reply_tx))
+ .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
+ self.wake_actor();
+ reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY handoff duplicate",
+ )
+ })?
+ }
+
+ pub(crate) fn foreground_process_group_id(&self) -> Option<u32> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::ForegroundProcessGroup(reply_tx))
+ .ok()?;
+ self.wake_actor();
+ reply_rx.recv_timeout(Duration::from_secs(1)).ok()?
+ }
+
+ pub(crate) fn rollback_handoff(&self) -> std::io::Result<()> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::RollbackHandoff(reply_tx))
+ .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
+ self.wake_actor();
+ let result = reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY handoff rollback",
+ )
+ })?;
+ if result.is_ok() {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = true;
+ }
+ result
+ }
+
+ pub(crate) fn release_after_commit(&self) -> std::io::Result<()> {
+ {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = false;
+ }
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::ReleaseAfterCommit(reply_tx))
+ .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
+ self.wake_actor();
+ reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY actor release",
+ )
+ })?
+ }
+
+ pub(crate) fn shutdown(&self) {
+ {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = false;
+ }
+ if self.control_tx.send(PtyIoControlCommand::Shutdown).is_ok() {
+ self.wake_actor();
+ }
+ }
+
+ fn wake_actor(&self) {
+ if let Err(err) = self.wake.wake() {
+ debug!(err = %err, "failed to wake PTY actor");
+ }
+ }
+}
+
+pub(crate) struct PtyIoActor;
+
+impl PtyIoActor {
+ pub(crate) fn spawn(config: PtyIoActorConfig) -> std::io::Result<PtyIoActorHandle> {
+ Self::spawn_inner(config, None)
+ }
+
+ fn spawn_inner(
+ config: PtyIoActorConfig,
+ poll_observer: Option<std_mpsc::Sender<()>>,
+ ) -> std::io::Result<PtyIoActorHandle> {
+ fd::set_cloexec(config.master_fd.as_raw_fd())?;
+ fd::set_nonblocking(config.master_fd.as_raw_fd())?;
+
+ let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER);
+ let (control_tx, control_rx) = std_mpsc::channel();
+ let wake_pipe = fd::create_wake_pipe()?;
+ let user_writes = Arc::new(Mutex::new(UserWriteGate {
+ accepting: !config.initially_quiesced,
+ }));
+ let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ wake: wake_pipe.writer,
+ user_writes,
+ controls: Arc::clone(&controls),
+ };
+
+ let mut runner = PtyIoActorRunner {
+ pane_id: config.pane_id,
+ file: std::fs::File::from(config.master_fd),
+ data_rx,
+ control_rx,
+ state: if config.initially_quiesced {
+ ActorState::Quiesced
+ } else {
+ ActorState::Running
+ },
+ pending_writes: VecDeque::new(),
+ current_write_offset: 0,
+ wake_read_fd: wake_pipe.read_fd,
+ controls,
+ on_read: config.on_read,
+ on_reader_exit: config.on_reader_exit,
+ poll_observer,
+ };
+ std::thread::Builder::new()
+ .name(format!("herdr-pty-{}", config.pane_id))
+ .spawn(move || runner.run())
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+
+ Ok(handle)
+ }
+
+ #[cfg(test)]
+ fn spawn_with_poll_observer(
+ config: PtyIoActorConfig,
+ poll_observer: std_mpsc::Sender<()>,
+ ) -> std::io::Result<PtyIoActorHandle> {
+ Self::spawn_inner(config, Some(poll_observer))
+ }
+}
+
+struct PtyIoActorRunner {
+ pane_id: u32,
+ file: std::fs::File,
+ data_rx: mpsc::Receiver<PtyIoDataCommand>,
+ control_rx: std_mpsc::Receiver<PtyIoControlCommand>,
+ state: ActorState,
+ pending_writes: VecDeque<Bytes>,
+ current_write_offset: usize,
+ wake_read_fd: OwnedFd,
+ controls: Arc<Mutex<SharedPtyControls>>,
+ on_read: ReadCallback,
+ on_reader_exit: Option<ReaderExitCallback>,
+ poll_observer: Option<std_mpsc::Sender<()>>,
+}
+
+impl PtyIoActorRunner {
+ fn run(&mut self) {
+ let mut should_exit = false;
+ while !should_exit {
+ should_exit = self.drain_commands();
+ if should_exit || self.state == ActorState::Released {
+ break;
+ }
+
+ self.apply_pending_controls();
+
+ if !self.pending_writes.is_empty() {
+ self.flush_pending_writes_once();
+ }
+
+ if let Some(poll_observer) = &self.poll_observer {
+ let _ = poll_observer.send(());
+ }
+
+ match fd::poll_pty_and_wake(
+ self.file.as_raw_fd(),
+ self.wake_read_fd.as_raw_fd(),
+ self.state == ActorState::Running,
+ !self.pending_writes.is_empty(),
+ ACTOR_POLL_MS,
+ ) {
+ Ok(readiness) => {
+ if readiness.wake_ready {
+ if let Err(err) = fd::drain_wake_fd(self.wake_read_fd.as_raw_fd()) {
+ debug!(pane = self.pane_id, err = %err, "PTY actor wake drain failed");
+ break;
+ }
+ continue;
+ }
+ if readiness.pty_write_ready && !self.pending_writes.is_empty() {
+ self.flush_pending_writes_once();
+ }
+ if self.state == ActorState::Running
+ && readiness.pty_read_ready
+ && !self.read_once()
+ {
+ break;
+ }
+ }
+ Err(err) => {
+ debug!(pane = self.pane_id, err = %err, "PTY actor poll failed");
+ break;
+ }
+ }
+ }
+
+ if let Some(on_reader_exit) = self.on_reader_exit.take() {
+ on_reader_exit();
+ }
+ debug!(pane = self.pane_id, "PTY actor exiting");
+ }
+
+ fn drain_commands(&mut self) -> bool {
+ if self.drain_control_commands() {
+ return true;
+ }
+ self.drain_data_commands()
+ }
+
+ fn drain_control_commands(&mut self) -> bool {
+ let mut should_exit = false;
+ loop {
+ match self.control_rx.try_recv() {
+ Ok(command) => {
+ if self.handle_control_command(command) {
+ should_exit = true;
+ break;
+ }
+ }
+ Err(std_mpsc::TryRecvError::Empty) => break,
+ Err(std_mpsc::TryRecvError::Disconnected) => {
+ should_exit = true;
+ break;
+ }
+ }
+ }
+ should_exit
+ }
+
+ fn drain_data_commands(&mut self) -> bool {
+ let mut should_exit = false;
+ loop {
+ match self.data_rx.try_recv() {
+ Ok(command) => {
+ if self.handle_data_command(command) {
+ should_exit = true;
+ break;
+ }
+ }
+ Err(DataTryRecvError::Empty) => break,
+ Err(DataTryRecvError::Disconnected) => {
+ should_exit = true;
+ break;
+ }
+ }
+ }
+ should_exit
+ }
+
+ fn handle_data_command(&mut self, command: PtyIoDataCommand) -> bool {
+ match command {
+ PtyIoDataCommand::WriteUserInput(bytes) => {
+ if self.state == ActorState::Running {
+ self.pending_writes.push_back(bytes);
+ }
+ }
+ }
+ false
+ }
+
+ fn handle_control_command(&mut self, command: PtyIoControlCommand) -> bool {
+ match command {
+ PtyIoControlCommand::BeginHandoff(reply) => {
+ let result = self.begin_handoff();
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::DuplicateForHandoff(reply) => {
+ let result = if self.state == ActorState::Quiesced {
+ fd::duplicate_cloexec_fd(self.file.as_raw_fd())
+ } else {
+ Err(std::io::Error::other(
+ "PTY actor must be quiesced before handoff duplication",
+ ))
+ };
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::ForegroundProcessGroup(reply) => {
+ let result =
+ crate::platform::foreground_process_group_id_for_tty_fd(self.file.as_raw_fd());
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::RollbackHandoff(reply) => {
+ let result = if self.state == ActorState::Released {
+ Err(std::io::Error::new(
+ std::io::ErrorKind::BrokenPipe,
+ "PTY actor was released before handoff rollback",
+ ))
+ } else {
+ self.state = ActorState::Running;
+ Ok(())
+ };
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::ReleaseAfterCommit(reply) => {
+ self.state = ActorState::Released;
+ self.pending_writes.clear();
+ let _ = reply.send(Ok(()));
+ return true;
+ }
+ PtyIoControlCommand::Shutdown => return true,
+ }
+ false
+ }
+
+ fn begin_handoff(&mut self) -> std::io::Result<()> {
+ self.drain_pre_quiesce_commands();
+ self.apply_pending_controls();
+ if self.state == ActorState::Released {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::BrokenPipe,
+ "PTY actor was released before handoff quiesce",
+ ));
+ }
+ let deadline = Instant::now() + HANDOFF_DRAIN_TIMEOUT;
+ while !self.pending_writes.is_empty() {
+ if Instant::now() >= deadline {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out draining PTY writes before handoff",
+ ));
+ }
+ self.flush_pending_writes_once();
+ }
+ self.state = ActorState::Quiesced;
+ Ok(())
+ }
+
+ fn drain_pre_quiesce_commands(&mut self) {
+ while let Ok(PtyIoDataCommand::WriteUserInput(bytes)) = self.data_rx.try_recv() {
+ if self.state != ActorState::Released {
+ self.pending_writes.push_back(bytes);
+ }
+ }
+ }
+
+ fn apply_pending_controls(&mut self) {
+ let (resize, nudge) = {
+ let mut controls = self
+ .controls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ (controls.resize.take(), controls.nudge.take())
+ };
+ if self.state == ActorState::Released {
+ return;
+ }
+ if let Some(request) = resize {
+ self.resize(request.resize);
+ self.enqueue_terminal_responses(request.terminal_responses);
+ }
+ if let Some(nudge) = nudge {
+ self.nudge(nudge);
+ }
+ }
+
+ fn read_once(&mut self) -> bool {
+ let mut buf = [0u8; 8192];
+ match self.file.read(&mut buf) {
+ Ok(0) => false,
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => true,
+ Err(err) if err.kind() == std::io::ErrorKind::Interrupted => true,
+ Err(err) => {
+ debug!(pane = self.pane_id, err = %err, "PTY actor read failed");
+ false
+ }
+ Ok(n) => {
+ let result = (self.on_read)(&buf[..n]);
+ self.enqueue_terminal_responses(result.terminal_responses);
+ true
+ }
+ }
+ }
+
+ fn enqueue_terminal_responses(&mut self, terminal_responses: Vec<Bytes>) {
+ if self.state == ActorState::Released {
+ return;
+ }
+ self.pending_writes.extend(terminal_responses);
+ }
+
+ fn flush_pending_writes_once(&mut self) {
+ while let Some(bytes) = self.pending_writes.front() {
+ let chunk = &bytes[self.current_write_offset..];
+ match self.file.write(chunk) {
+ Ok(0) => {
+ warn!(pane = self.pane_id, "PTY actor write returned zero bytes");
+ return;
+ }
+ Ok(written) => {
+ self.current_write_offset += written;
+ if self.current_write_offset >= bytes.len() {
+ self.pending_writes.pop_front();
+ self.current_write_offset = 0;
+ }
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ let _ = fd::poll_write_ready(self.file.as_raw_fd(), ACTOR_POLL_MS);
+ return;
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return,
+ Err(err) => {
+ warn!(pane = self.pane_id, err = %err, "PTY actor write failed");
+ self.pending_writes.clear();
+ self.current_write_offset = 0;
+ return;
+ }
+ }
+ }
+ let _ = self.file.flush();
+ }
+
+ fn resize(&self, resize: PtyResize) {
+ self.log_resize_result(fd::resize_pty_fd(
+ self.file.as_raw_fd(),
+ resize.rows,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ ));
+ }
+
+ fn nudge(&mut self, resize: PtyResize) {
+ if self.state == ActorState::Released {
+ return;
+ }
+ let nudge = if resize.rows > 2 {
+ (
+ resize.rows - 1,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ )
+ } else {
+ (
+ resize.rows,
+ resize.cols.saturating_sub(1).max(4),
+ resize.cell_width_px,
+ resize.cell_height_px,
+ )
+ };
+ if nudge
+ == (
+ resize.rows,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ )
+ {
+ return;
+ }
+ self.log_resize_result(fd::resize_pty_fd(
+ self.file.as_raw_fd(),
+ nudge.0,
+ nudge.1,
+ nudge.2,
+ nudge.3,
+ ));
+ std::thread::sleep(Duration::from_millis(30));
+ self.log_resize_result(fd::resize_pty_fd(
+ self.file.as_raw_fd(),
+ resize.rows,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ ));
+ }
+
+ fn log_resize_result(&self, result: std::io::Result<()>) {
+ if let Err(err) = result {
+ debug!(pane = self.pane_id, err = %err, "PTY resize failed");
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::{
+ io::{Read, Write},
+ os::fd::{AsRawFd, FromRawFd, IntoRawFd},
+ os::unix::net::UnixStream,
+ };
+
+ fn test_wake_pair() -> (fd::WakeWriter, OwnedFd) {
+ let pipe = fd::create_wake_pipe().expect("wake pipe");
+ (pipe.writer, pipe.read_fd)
+ }
+
+ fn actor_with_socket_pair(
+ initially_quiesced: bool,
+ ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver<Bytes>) {
+ actor_with_socket_pair_and_poll_observer(initially_quiesced, None)
+ }
+
+ fn actor_with_socket_pair_and_poll_observer(
+ initially_quiesced: bool,
+ poll_observer: Option<std_mpsc::Sender<()>>,
+ ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver<Bytes>) {
+ let (actor_socket, peer) = UnixStream::pair().expect("socket pair");
+ actor_socket
+ .set_nonblocking(true)
+ .expect("actor socket nonblocking");
+ peer.set_read_timeout(Some(Duration::from_secs(1)))
+ .expect("peer timeout");
+ let owned = unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) };
+ let (read_tx, read_rx) = std_mpsc::channel();
+ let config = PtyIoActorConfig {
+ pane_id: 1,
+ master_fd: owned,
+ initially_quiesced,
+ on_read: Box::new(move |bytes| {
+ read_tx
+ .send(Bytes::copy_from_slice(bytes))
+ .expect("read callback receiver alive");
+ PtyReadResult::empty()
+ }),
+ on_reader_exit: None,
+ };
+ let handle = if let Some(poll_observer) = poll_observer {
+ PtyIoActor::spawn_with_poll_observer(config, poll_observer)
+ } else {
+ PtyIoActor::spawn(config)
+ }
+ .expect("actor spawn");
+ (handle, peer, read_rx)
+ }
+
+ #[test]
+ fn actor_writes_user_input_to_owned_fd() {
+ let (handle, mut peer, _read_rx) = actor_with_socket_pair(false);
+
+ handle
+ .try_write_user_input(Bytes::from_static(b"hello"))
+ .expect("write command accepted");
+
+ let mut buf = [0u8; 5];
+ peer.read_exact(&mut buf).expect("peer receives write");
+ assert_eq!(&buf, b"hello");
+ handle.shutdown();
+ }
+
+ #[test]
+ fn actor_wakes_idle_poll_for_user_input() {
+ let (poll_tx, poll_rx) = std_mpsc::channel();
+ let (handle, mut peer, _read_rx) =
+ actor_with_socket_pair_and_poll_observer(false, Some(poll_tx));
+ peer.set_read_timeout(Some(Duration::from_millis(500)))
+ .expect("peer timeout");
+ poll_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("actor entered idle poll");
+
+ let start = Instant::now();
+ handle
+ .try_write_user_input(Bytes::from_static(b"x"))
+ .expect("write command accepted");
+
+ let mut buf = [0u8; 1];
+ peer.read_exact(&mut buf)
+ .expect("peer receives write without waiting for actor poll timeout");
+ assert_eq!(&buf, b"x");
+ assert!(
+ start.elapsed() < Duration::from_millis(500),
+ "actor write should be driven by wake fd, not the idle poll timeout"
+ );
+ handle.shutdown();
+ }
+
+ #[test]
+ fn poll_ignores_pty_hup_without_pty_interest() {
+ let (actor_socket, peer) = UnixStream::pair().expect("socket pair");
+ actor_socket
+ .set_nonblocking(true)
+ .expect("actor socket nonblocking");
+ drop(peer);
+ let wake_pipe = fd::create_wake_pipe().expect("wake pipe");
+
+ let readiness = fd::poll_pty_and_wake(
+ actor_socket.as_raw_fd(),
+ wake_pipe.read_fd.as_raw_fd(),
+ false,
+ false,
+ 10,
+ )
+ .expect("poll succeeds");
+
+ assert!(!readiness.pty_read_ready);
+ assert!(!readiness.pty_write_ready);
+ assert!(!readiness.wake_ready);
+ }
+
+ #[test]
+ fn actor_delivers_fd_reads_to_callback() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ peer.write_all(b"from-peer").expect("peer write");
+
+ let read = read_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("actor read callback");
+ assert_eq!(read, Bytes::from_static(b"from-peer"));
+ handle.shutdown();
+ }
+
+ #[test]
+ fn begin_handoff_stops_reads_and_rejects_user_writes_until_rollback() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ handle
+ .begin_handoff(Duration::from_secs(1))
+ .expect("handoff quiesced");
+ assert!(handle
+ .try_write_user_input(Bytes::from_static(b"blocked"))
+ .is_err());
+
+ peer.write_all(b"held").expect("peer write during quiesce");
+ assert!(
+ read_rx.recv_timeout(Duration::from_millis(150)).is_err(),
+ "actor must not read while quiesced"
+ );
+
+ handle.rollback_handoff().expect("rollback resumes actor");
+ let read = read_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("actor reads held bytes after rollback");
+ assert_eq!(read, Bytes::from_static(b"held"));
+
+ handle
+ .try_write_user_input(Bytes::from_static(b"after"))
+ .expect("write accepted after rollback");
+ let mut buf = [0u8; 5];
+ peer.read_exact(&mut buf).expect("peer receives after");
+ assert_eq!(&buf, b"after");
+ handle.shutdown();
+ }
+
+ #[test]
+ fn duplicate_for_handoff_requires_quiesced_actor() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ assert!(handle.duplicate_for_handoff().is_err());
+ handle
+ .begin_handoff(Duration::from_secs(1))
+ .expect("handoff quiesced");
+ let duplicate = handle
+ .duplicate_for_handoff()
+ .expect("handoff duplicate created");
+ assert!(duplicate >= 0);
+ unsafe {
+ libc::close(duplicate);
+ }
+ handle.rollback_handoff().expect("rollback resumes actor");
+
+ peer.write_all(b"still-live").expect("peer write");
+ let read = read_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("actor still reads after duplicate closes");
+ assert_eq!(read, Bytes::from_static(b"still-live"));
+ handle.shutdown();
+ }
+
+ #[test]
+ fn resize_and_nudge_keep_latest_request_when_command_queue_is_full() {
+ let (data_tx, _data_rx) = mpsc::channel(1);
+ let (control_tx, _control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
+ b"fill",
+ )))
+ .expect("fill command queue");
+ let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
+ let (wake, _wake_read_fd) = test_wake_pair();
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ wake,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::clone(&controls),
+ };
+
+ handle.resize(20, 80, 8, 16, vec![Bytes::from_static(b"old")]);
+ handle.resize(40, 120, 9, 18, vec![Bytes::from_static(b"new")]);
+ handle.nudge_child_redraw_after_handoff(41, 121, 10, 20);
+
+ let controls = controls.lock().expect("controls lock");
+ assert_eq!(
+ controls.resize,
+ Some(PtyResizeRequest {
+ resize: PtyResize {
+ rows: 40,
+ cols: 120,
+ cell_width_px: 9,
+ cell_height_px: 18,
+ },
+ terminal_responses: vec![Bytes::from_static(b"new")],
+ })
+ );
+ assert_eq!(
+ controls.nudge,
+ Some(PtyResize {
+ rows: 41,
+ cols: 121,
+ cell_width_px: 10,
+ cell_height_px: 20,
+ })
+ );
+ }
+
+ #[test]
+ fn resize_writes_terminal_responses_after_applying_resize() {
+ let (handle, mut peer, _read_rx) = actor_with_socket_pair(false);
+ let response = Bytes::from_static(b"\x1B[48;40;100;720;900t");
+
+ handle.resize(40, 100, 9, 18, vec![response.clone()]);
+
+ let mut buf = vec![0; response.len()];
+ peer.read_exact(&mut buf)
+ .expect("peer receives resize response");
+ assert_eq!(Bytes::from(buf), response);
+ handle.shutdown();
+ }
+
+ #[tokio::test]
+ async fn async_user_input_waits_for_queue_capacity() {
+ let (data_tx, mut data_rx) = mpsc::channel(1);
+ let (control_tx, _control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
+ b"fill",
+ )))
+ .expect("fill data queue");
+ let (wake, _wake_read_fd) = test_wake_pair();
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ wake,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ };
+
+ let write = tokio::spawn(async move {
+ handle
+ .write_user_input(Bytes::from_static(b"wait-for-capacity"))
+ .await
+ });
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ assert!(
+ !write.is_finished(),
+ "async input should wait for queue capacity"
+ );
+
+ assert!(matches!(
+ data_rx.recv().await,
+ Some(PtyIoDataCommand::WriteUserInput(_))
+ ));
+ write
+ .await
+ .expect("write task joins")
+ .expect("write succeeds after capacity opens");
+ match data_rx.recv().await {
+ Some(PtyIoDataCommand::WriteUserInput(bytes)) => {
+ assert_eq!(bytes, Bytes::from_static(b"wait-for-capacity"));
+ }
+ _ => panic!("expected queued user input"),
+ }
+ }
+
+ #[tokio::test]
+ async fn async_user_input_waiting_for_capacity_is_rejected_after_handoff_begins() {
+ let (data_tx, mut data_rx) = mpsc::channel(1);
+ let (control_tx, control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
+ b"fill",
+ )))
+ .expect("fill data queue");
+ let (wake, _wake_read_fd) = test_wake_pair();
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ wake,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ };
+ let write_handle = handle.clone();
+ let write = tokio::spawn(async move {
+ write_handle
+ .write_user_input(Bytes::from_static(b"after-handoff-start"))
+ .await
+ });
+ tokio::time::sleep(Duration::from_millis(50)).await;
+
+ let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1)));
+ match control_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("handoff control command")
+ {
+ PtyIoControlCommand::BeginHandoff(reply) => {
+ reply.send(Ok(())).expect("handoff waiter alive");
+ }
+ _ => panic!("expected begin handoff command"),
+ }
+ handoff
+ .join()
+ .expect("handoff thread joins")
+ .expect("handoff succeeds");
+ assert!(matches!(
+ data_rx.recv().await,
+ Some(PtyIoDataCommand::WriteUserInput(_))
+ ));
+
+ let err = write.await.expect("write task joins").expect_err(
+ "write waiting for capacity must be rejected after handoff closes the input gate",
+ );
+ assert_eq!(err.0, Bytes::from_static(b"after-handoff-start"));
+ match tokio::time::timeout(Duration::from_millis(50), data_rx.recv()).await {
+ Err(_) | Ok(None) => {}
+ Ok(Some(_)) => panic!("rejected write must not be queued"),
+ }
+ }
+
+ #[test]
+ fn handoff_control_is_not_blocked_by_full_data_queue() {
+ let (data_tx, _data_rx) = mpsc::channel(1);
+ let (control_tx, control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
+ b"fill",
+ )))
+ .expect("fill data queue");
+ let (wake, _wake_read_fd) = test_wake_pair();
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ wake,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ };
+
+ let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1)));
+ match control_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("handoff control command")
+ {
+ PtyIoControlCommand::BeginHandoff(reply) => {
+ reply.send(Ok(())).expect("handoff waiter alive");
+ }
+ _ => panic!("expected begin handoff command"),
+ }
+
+ handoff
+ .join()
+ .expect("handoff thread joins")
+ .expect("handoff succeeds despite full data queue");
+ }
+
+ #[test]
+ fn begin_handoff_drains_user_writes_already_in_command_queue() {
+ let (actor_socket, mut peer) = UnixStream::pair().expect("socket pair");
+ actor_socket
+ .set_nonblocking(true)
+ .expect("actor socket nonblocking");
+ peer.set_read_timeout(Some(Duration::from_secs(1)))
+ .expect("peer timeout");
+ let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER);
+ let (_control_tx, control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
+ b"queued-before-ack",
+ )))
+ .expect("queued write");
+ let mut runner = PtyIoActorRunner {
+ pane_id: 1,
+ file: std::fs::File::from(unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }),
+ data_rx,
+ control_rx,
+ state: ActorState::Running,
+ pending_writes: VecDeque::new(),
+ current_write_offset: 0,
+ wake_read_fd: fd::create_wake_pipe().expect("wake pipe").read_fd,
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ on_read: Box::new(|_| PtyReadResult::empty()),
+ on_reader_exit: None,
+ poll_observer: None,
+ };
+
+ runner.begin_handoff().expect("handoff drains queued write");
+
+ let mut buf = [0u8; 17];
+ peer.read_exact(&mut buf)
+ .expect("queued write reaches peer before quiesce ack");
+ assert_eq!(&buf, b"queued-before-ack");
+ assert_eq!(runner.state, ActorState::Quiesced);
+ }
+
+ #[test]
+ fn release_after_commit_prevents_further_io() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ handle.release_after_commit().expect("actor released");
+ assert!(handle
+ .try_write_user_input(Bytes::from_static(b"blocked"))
+ .is_err());
+
+ let _ = peer.write_all(b"ignored");
+ assert!(read_rx.recv_timeout(Duration::from_millis(150)).is_err());
+ }
+}
diff --git a/src/pty/backend.rs b/src/pty/backend.rs
index 5871004a..d09922b7 100644
--- a/src/pty/backend.rs
+++ b/src/pty/backend.rs
@@ -1,14 +1,19 @@
-use std::os::fd::{FromRawFd, OwnedFd};
+#[cfg(unix)]
+mod unix;
-use portable_pty::{native_pty_system, Child, CommandBuilder, PtySize};
+#[cfg(unix)]
+pub(crate) use unix::*;
-use crate::pty::fd;
+#[cfg(windows)]
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+#[cfg(windows)]
pub(crate) struct SpawnedPty {
- pub master_fd: OwnedFd,
+ pub master: Box<dyn MasterPty + Send>,
pub child: Box<dyn Child + Send + Sync>,
}
+#[cfg(windows)]
pub(crate) fn spawn_with_portable_pty(
rows: u16,
cols: u16,
@@ -23,72 +28,13 @@ pub(crate) fn spawn_with_portable_pty(
pixel_height: 0,
})
.map_err(|err| std::io::Error::other(err.to_string()))?;
- let master_fd = pair
- .master
- .as_raw_fd()
- .ok_or_else(|| std::io::Error::other("pty master fd is unavailable"))?;
- let actor_fd = fd::duplicate_cloexec_fd(master_fd)?;
- let actor_fd = unsafe { OwnedFd::from_raw_fd(actor_fd) };
let child = pair
.slave
.spawn_command(cmd)
.map_err(|err| std::io::Error::other(err.to_string()))?;
- drop(pair);
Ok(SpawnedPty {
- master_fd: actor_fd,
+ master: pair.master,
child,
})
}
-
-#[cfg(all(test, target_os = "linux"))]
-mod tests {
- use super::*;
- use std::sync::{Mutex, OnceLock};
-
- fn pty_fd_test_lock() -> &'static Mutex<()> {
- static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
- LOCK.get_or_init(|| Mutex::new(()))
- }
-
- fn parent_pty_fd_targets() -> Vec<String> {
- let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
- return Vec::new();
- };
- let mut targets: Vec<String> = entries
- .filter_map(Result::ok)
- .filter_map(|entry| std::fs::read_link(entry.path()).ok())
- .map(|target| target.to_string_lossy().into_owned())
- .filter(|target| target.starts_with("/dev/pts/") || target == "/dev/ptmx")
- .collect();
- targets.sort();
- targets
- }
-
- fn parent_pty_fd_count() -> usize {
- parent_pty_fd_targets().len()
- }
-
- #[test]
- fn portable_pty_setup_leaves_one_parent_pty_fd() {
- let _guard = pty_fd_test_lock().lock().expect("pty fd test lock");
- let before = parent_pty_fd_count();
- let mut cmd = CommandBuilder::new("/bin/cat");
- cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
-
- let mut spawned =
- spawn_with_portable_pty(24, 80, cmd).expect("portable pty setup succeeds");
- let after_spawn = parent_pty_fd_count();
-
- assert_eq!(
- after_spawn,
- before + 1,
- "portable-pty setup should leave only the Herdr-owned master fd in the parent: {:?}",
- parent_pty_fd_targets()
- );
-
- let _ = spawned.child.kill();
- let _ = spawned.child.wait();
- drop(spawned.master_fd);
- }
-}
diff --git a/src/pty/backend/unix.rs b/src/pty/backend/unix.rs
new file mode 100644
index 00000000..5871004a
--- /dev/null
+++ b/src/pty/backend/unix.rs
@@ -0,0 +1,94 @@
+use std::os::fd::{FromRawFd, OwnedFd};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, PtySize};
+
+use crate::pty::fd;
+
+pub(crate) struct SpawnedPty {
+ pub master_fd: OwnedFd,
+ pub child: Box<dyn Child + Send + Sync>,
+}
+
+pub(crate) fn spawn_with_portable_pty(
+ rows: u16,
+ cols: u16,
+ cmd: CommandBuilder,
+) -> std::io::Result<SpawnedPty> {
+ let pty_system = native_pty_system();
+ let pair = pty_system
+ .openpty(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+ let master_fd = pair
+ .master
+ .as_raw_fd()
+ .ok_or_else(|| std::io::Error::other("pty master fd is unavailable"))?;
+ let actor_fd = fd::duplicate_cloexec_fd(master_fd)?;
+ let actor_fd = unsafe { OwnedFd::from_raw_fd(actor_fd) };
+ let child = pair
+ .slave
+ .spawn_command(cmd)
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+ drop(pair);
+
+ Ok(SpawnedPty {
+ master_fd: actor_fd,
+ child,
+ })
+}
+
+#[cfg(all(test, target_os = "linux"))]
+mod tests {
+ use super::*;
+ use std::sync::{Mutex, OnceLock};
+
+ fn pty_fd_test_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
+
+ fn parent_pty_fd_targets() -> Vec<String> {
+ let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
+ return Vec::new();
+ };
+ let mut targets: Vec<String> = entries
+ .filter_map(Result::ok)
+ .filter_map(|entry| std::fs::read_link(entry.path()).ok())
+ .map(|target| target.to_string_lossy().into_owned())
+ .filter(|target| target.starts_with("/dev/pts/") || target == "/dev/ptmx")
+ .collect();
+ targets.sort();
+ targets
+ }
+
+ fn parent_pty_fd_count() -> usize {
+ parent_pty_fd_targets().len()
+ }
+
+ #[test]
+ fn portable_pty_setup_leaves_one_parent_pty_fd() {
+ let _guard = pty_fd_test_lock().lock().expect("pty fd test lock");
+ let before = parent_pty_fd_count();
+ let mut cmd = CommandBuilder::new("/bin/cat");
+ cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
+
+ let mut spawned =
+ spawn_with_portable_pty(24, 80, cmd).expect("portable pty setup succeeds");
+ let after_spawn = parent_pty_fd_count();
+
+ assert_eq!(
+ after_spawn,
+ before + 1,
+ "portable-pty setup should leave only the Herdr-owned master fd in the parent: {:?}",
+ parent_pty_fd_targets()
+ );
+
+ let _ = spawned.child.kill();
+ let _ = spawned.child.wait();
+ drop(spawned.master_fd);
+ }
+}
diff --git a/src/pty/mod.rs b/src/pty/mod.rs
index 74bbb124..ce838679 100644
--- a/src/pty/mod.rs
+++ b/src/pty/mod.rs
@@ -1,6 +1,4 @@
-#[cfg(unix)]
pub(crate) mod actor;
-#[cfg(unix)]
pub(crate) mod backend;
#[cfg(unix)]
pub(crate) mod fd;
diff --git a/src/remote.rs b/src/remote.rs
index ce56ba8a..edfb9269 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -1,38 +1,22 @@
-//! Remote thin-client launcher over SSH command stdio.
+#[cfg(unix)]
+mod unix;
-use std::collections::BTreeMap;
-use std::fs::{self, File};
-use std::io::{self, IsTerminal, Write as _};
-use std::os::unix::net::{UnixListener, UnixStream};
-use std::path::{Path, PathBuf};
-use std::process::{Command, Output, Stdio};
+#[cfg(unix)]
+pub(crate) use unix::*;
-use serde::Deserialize;
-use std::sync::{
- atomic::{AtomicBool, Ordering},
- Arc,
-};
-use std::thread::{self, JoinHandle};
-use std::time::{Duration, Instant};
-
-const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
-const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
-const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
-const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
-const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
-const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
-const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
-const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
+#[cfg(windows)]
pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
-
+#[cfg(windows)]
pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS";
+#[cfg(windows)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RemoteKeybindings {
Local,
Server,
}
+#[cfg(windows)]
impl RemoteKeybindings {
fn parse(value: &str) -> Result<Self, String> {
match value {
@@ -41,15 +25,9 @@ impl RemoteKeybindings {
_ => Err("--remote-keybindings must be 'local' or 'server'".to_string()),
}
}
-
- fn as_str(self) -> &'static str {
- match self {
- Self::Local => "local",
- Self::Server => "server",
- }
- }
}
+#[cfg(windows)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteLaunch {
pub(crate) target: String,
@@ -57,6 +35,7 @@ pub(crate) struct RemoteLaunch {
pub(crate) live_handoff: bool,
}
+#[cfg(windows)]
pub(crate) fn extract_remote_args(
args: &[String],
) -> Result<(Vec<String>, Option<RemoteLaunch>), String> {
@@ -141,6 +120,7 @@ pub(crate) fn extract_remote_args(
Ok((cleaned, remote))
}
+#[cfg(windows)]
fn validate_remote_target(target: &str) -> Result<&str, String> {
if target.is_empty() {
return Err("missing value for --remote".to_string());
@@ -151,2346 +131,18 @@ fn validate_remote_target(target: &str) -> Result<&str, String> {
Ok(target)
}
-pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
- let session_name = crate::session::active_name()
- .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string());
- let local_socket = local_forward_socket_path(&remote.target, &session_name);
- let program = std::env::args()
- .next()
- .unwrap_or_else(|| "herdr".to_string());
- let reattach_command = reattach_command(
- &program,
- &remote.target,
- &session_name,
- remote.keybindings,
- remote.live_handoff,
- );
- let prepared_remote = prepare_remote_herdr(&remote.target, remote.live_handoff)?;
- ensure_remote_server_ready(
- &remote.target,
- &prepared_remote.remote_herdr,
- prepared_remote.installed_or_replaced,
- prepared_remote.stop_after_install_approved,
- remote.live_handoff,
- )?;
-
- let manage_ssh_config = crate::config::Config::load()
- .config
- .remote
- .manage_ssh_config;
- let _bridge = SshStdioBridge::start(
- remote.target,
- prepared_remote.remote_herdr,
- local_socket.clone(),
- session_name,
- manage_ssh_config,
- )?;
-
- run_client_process(&local_socket, &reattach_command, remote.keybindings)
-}
-
-pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
- ensure_remote_server_running()?;
-
- let socket_path = crate::server::socket_paths::client_socket_path();
- let stream = UnixStream::connect(&socket_path).map_err(|err| {
- io::Error::new(
- err.kind(),
- format!(
- "failed to connect to remote Herdr client socket {}: {err}",
- socket_path.display()
- ),
- )
- })?;
-
- let mut stdout = io::stdout().lock();
- let mut socket_to_stdout = stream.try_clone()?;
- let mut stdin_to_socket = stream;
-
- let _upload = thread::spawn(move || {
- let mut stdin = io::stdin();
- let _ = copy_flush(&mut stdin, &mut stdin_to_socket);
- let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
- });
-
- copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ())
-}
-
-fn ensure_remote_server_running() -> io::Result<()> {
- let socket_path = crate::server::socket_paths::client_socket_path();
- if crate::server::autodetect::is_server_listening() {
- let status = crate::api::read_runtime_status_at(
- &crate::api::socket_path(),
- Duration::from_millis(500),
- )?
- .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?;
- if status.protocol == Some(CURRENT_PROTOCOL) {
- return Ok(());
- }
- return Err(io::Error::other(
- "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it",
- ));
- }
-
- crate::server::autodetect::spawn_server_daemon()?;
- crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct RemotePlatform {
- os: &'static str,
- arch: &'static str,
-}
-
-impl RemotePlatform {
- fn from_uname(os: &str, arch: &str) -> Option<Self> {
- let os = match os.trim() {
- "Linux" => "linux",
- "Darwin" => "macos",
- _ => return None,
- };
- let arch = match arch.trim() {
- "x86_64" | "amd64" => "x86_64",
- "aarch64" | "arm64" => "aarch64",
- _ => return None,
- };
- Some(Self { os, arch })
- }
-
- fn local() -> Self {
- let os = if cfg!(target_os = "linux") {
- "linux"
- } else if cfg!(target_os = "macos") {
- "macos"
- } else {
- "unknown"
- };
-
- let arch = if cfg!(target_arch = "x86_64") {
- "x86_64"
- } else if cfg!(target_arch = "aarch64") {
- "aarch64"
- } else {
- "unknown"
- };
-
- Self { os, arch }
- }
-
- fn asset_key(&self) -> String {
- format!("{}-{}", self.os, self.arch)
- }
-}
-
-#[derive(Debug, Clone)]
-struct RemoteHerdr {
- install_suffix: String,
- shell_path: String,
- platform: RemotePlatform,
-}
-
-impl RemoteHerdr {
- fn for_platform(platform: RemotePlatform) -> Self {
- let install_suffix = ".local/bin/herdr".to_string();
- let shell_path = format!("\"$HOME/{install_suffix}\"");
- Self {
- install_suffix,
- shell_path,
- platform,
- }
- }
-
- fn with_shell_path(mut self, shell_path: String) -> Self {
- self.shell_path = shell_path;
- self
- }
-}
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(untagged)]
-enum RemoteAssetRef {
- Url(String),
- Object { url: String, sha256: Option<String> },
-}
-
-impl RemoteAssetRef {
- fn url(&self) -> &str {
- match self {
- Self::Url(url) => url,
- Self::Object { url, .. } => url,
- }
- }
-
- fn sha256(&self) -> Option<&str> {
- match self {
- Self::Url(_) => None,
- Self::Object { sha256, .. } => {
- sha256.as_deref().filter(|value| !value.trim().is_empty())
- }
- }
- }
-}
-
-#[derive(Deserialize)]
-struct RemoteUpdateManifest {
- version: String,
- protocol: Option<u32>,
- assets: BTreeMap<String, RemoteAssetRef>,
- #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
- releases: BTreeMap<String, RemoteReleaseMetadata>,
-}
-
-#[derive(Deserialize)]
-struct RemoteReleaseMetadata {
- protocol: Option<u32>,
- #[serde(default)]
- assets: BTreeMap<String, RemoteAssetRef>,
-}
-
-#[derive(Deserialize)]
-struct RemotePreviewManifest {
- build_id: String,
- protocol: u32,
- assets: BTreeMap<String, RemoteAssetRef>,
- #[serde(default)]
- builds: BTreeMap<String, RemotePreviewBuildMetadata>,
-}
-
-#[derive(Deserialize)]
-struct RemotePreviewBuildMetadata {
- protocol: u32,
- assets: BTreeMap<String, RemoteAssetRef>,
-}
-
-fn deserialize_remote_manifest_releases<'de, D>(
- deserializer: D,
-) -> Result<BTreeMap<String, RemoteReleaseMetadata>, D::Error>
-where
- D: serde::Deserializer<'de>,
-{
- let value = Option::<serde_json::Value>::deserialize(deserializer)?;
- Ok(match value {
- Some(serde_json::Value::Object(object)) => object
- .into_iter()
- .filter_map(|(version, release)| {
- serde_json::from_value::<RemoteReleaseMetadata>(release)
- .ok()
- .map(|metadata| (version, metadata))
- })
- .collect(),
- _ => BTreeMap::new(),
- })
-}
-
-impl RemoteUpdateManifest {
- fn release_for_version(&self, version: &str) -> Option<RemoteManifestReleaseRef<'_>> {
- if self.version.trim_start_matches('v') == version {
- return Some(RemoteManifestReleaseRef {
- protocol: self.protocol,
- assets: &self.assets,
- });
- }
-
- self.releases.get(version).and_then(|release| {
- (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef {
- protocol: release.protocol,
- assets: &release.assets,
- })
- })
- }
-}
-
-#[derive(Clone, Copy)]
-struct RemoteManifestReleaseRef<'a> {
- protocol: Option<u32>,
- assets: &'a BTreeMap<String, RemoteAssetRef>,
-}
-
-fn current_version() -> String {
- crate::build_info::version()
-}
-
-fn current_channel() -> &'static str {
- crate::build_info::channel()
-}
-
-struct InstallSource {
- path: PathBuf,
- temporary_dir: Option<PathBuf>,
-}
-
-struct RemoteReleaseAsset {
- url: String,
- sha256: Option<String>,
-}
-
-struct PreparedRemoteHerdr {
- remote_herdr: RemoteHerdr,
- installed_or_replaced: bool,
- stop_after_install_approved: bool,
-}
-
-impl InstallSource {
- fn persistent(path: PathBuf) -> Self {
- Self {
- path,
- temporary_dir: None,
- }
- }
-
- fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self {
- Self {
- path,
- temporary_dir: Some(temporary_dir),
- }
- }
-
- fn cleanup(&self) {
- if let Some(dir) = &self.temporary_dir {
- let _ = fs::remove_dir_all(dir);
- }
- }
-}
-
-fn prepare_remote_herdr(
- target: &str,
- live_handoff_enabled: bool,
-) -> io::Result<PreparedRemoteHerdr> {
- let platform = detect_remote_platform(target)?;
- let remote_herdr = RemoteHerdr::for_platform(platform);
- let override_binary = remote_binary_override_path()?;
- let path_remote_herdr = remote_binary_on_path_any(target, &remote_herdr)?;
-
- if override_binary.is_none() {
- if let Some(path_remote_herdr) = path_remote_herdr
- .as_ref()
- .filter(|candidate| remote_binary_matches(target, candidate).unwrap_or(false))
- {
- return Ok(PreparedRemoteHerdr {
- remote_herdr: path_remote_herdr.clone(),
- installed_or_replaced: false,
- stop_after_install_approved: false,
- });
- }
- if remote_binary_matches(target, &remote_herdr)? {
- return Ok(PreparedRemoteHerdr {
- remote_herdr,
- installed_or_replaced: false,
- stop_after_install_approved: false,
- });
- }
- }
-
- let mut stop_after_install_approved = false;
- if let Some(status_probe_herdr) = path_remote_herdr.as_ref().or_else(|| {
- remote_binary_exists(target, &remote_herdr)
- .ok()
- .and_then(|exists| exists.then_some(&remote_herdr))
- }) {
- stop_after_install_approved = confirm_remote_install_with_running_server(
- target,
- status_probe_herdr,
- live_handoff_enabled,
- )?;
- }
- confirm_remote_install(
- target,
- &remote_herdr,
- &install_source_description(&remote_herdr.platform, override_binary.as_deref()),
- )?;
- let source = resolve_install_source(&remote_herdr.platform, override_binary)?;
- let install_result = install_remote_herdr(target, &remote_herdr, &source.path);
- source.cleanup();
- install_result?;
-
- if !remote_binary_matches(target, &remote_herdr)? {
- return Err(io::Error::other(format!(
- "installed remote herdr at {}, but it did not report version {}",
- remote_herdr.shell_path,
- current_version()
- )));
- }
- warn_if_remote_bin_not_on_path(target)?;
-
- Ok(PreparedRemoteHerdr {
- remote_herdr,
- installed_or_replaced: true,
- stop_after_install_approved,
- })
-}
-
-fn detect_remote_platform(target: &str) -> io::Result<RemotePlatform> {
- let output = ssh_sh_output(target, "uname -s\nuname -m\n")?;
- if !output.status.success() {
- return Err(command_failed("remote platform detection failed", &output));
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- let mut lines = stdout.lines();
- let os = lines.next().unwrap_or_default();
- let arch = lines.next().unwrap_or_default();
- RemotePlatform::from_uname(os, arch).ok_or_else(|| {
- io::Error::other(format!(
- "unsupported remote platform: {} {}",
- os.trim(),
- arch.trim()
- ))
- })
-}
-
-fn remote_binary_on_path_any(
- target: &str,
- remote_herdr: &RemoteHerdr,
-) -> io::Result<Option<RemoteHerdr>> {
- let output = ssh_user_shell_output(target, "command -v herdr")?;
- if !output.status.success() {
- return Ok(None);
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout))
-}
-
-fn remote_herdr_from_path_discovery(
- remote_herdr: &RemoteHerdr,
- stdout: &str,
-) -> Option<RemoteHerdr> {
- let mut lines = stdout.lines();
- let path = lines.next()?;
- if !path.starts_with('/') {
- return None;
- }
- Some(remote_herdr.clone().with_shell_path(shell_quote(path)))
-}
-
-fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
- let command = format!(
- "test -x {0} && {0} --version && {0} status client --json",
- remote_herdr.shell_path
- );
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Ok(false);
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- let mut lines = stdout.lines();
- let version = lines.next().unwrap_or_default().trim();
- let status = lines.next().unwrap_or_default();
- Ok(version == format!("herdr {}", current_version())
- && parse_client_status_json(status)
- .map(|status| status.protocol == CURRENT_PROTOCOL)
- .unwrap_or(false))
-}
-
-fn remote_binary_exists(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
- let command = format!("test -x {}", remote_herdr.shell_path);
- Ok(ssh_sh_output(target, &command)?.status.success())
-}
-
-fn remote_binary_override_path() -> io::Result<Option<PathBuf>> {
- let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else {
- return Ok(None);
- };
- if value.is_empty() {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- format!("{REMOTE_BINARY_ENV_VAR} must not be empty"),
- ));
- }
-
- let path = PathBuf::from(value);
- let metadata = fs::metadata(&path).map_err(|err| {
- io::Error::new(
- err.kind(),
- format!(
- "failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}",
- path.display()
- ),
- )
- })?;
- if !metadata.is_file() {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- format!(
- "{REMOTE_BINARY_ENV_VAR} path is not a file: {}",
- path.display()
- ),
- ));
- }
-
- Ok(Some(path))
-}
-
-fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String {
- install_source_description_for(
- platform,
- override_binary,
- local_binary_can_seed_remote(platform),
- )
-}
-
-fn install_source_description_for(
- platform: &RemotePlatform,
- override_binary: Option<&Path>,
- local_binary_can_seed_remote: bool,
-) -> String {
- if let Some(path) = override_binary {
- return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display());
- }
-
- if local_binary_can_seed_remote {
- "the current local herdr binary".to_string()
- } else {
- format!(
- "the {} {} asset for {}",
- current_version(),
- current_channel(),
- platform.asset_key()
- )
- }
-}
-
-fn resolve_install_source(
- platform: &RemotePlatform,
- override_binary: Option<PathBuf>,
-) -> io::Result<InstallSource> {
- if let Some(path) = override_binary {
- return Ok(InstallSource::persistent(path));
- }
-
- if *platform == RemotePlatform::local() {
- let path = std::env::current_exe()?;
- if !crate::update::is_package_manager_managed_exe_path(&path) {
- return Ok(InstallSource::persistent(path));
- }
- }
-
- download_release_asset(platform)
-}
-
-fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
- if *platform != RemotePlatform::local() {
- return false;
- }
-
- std::env::current_exe()
- .map(|path| !crate::update::is_package_manager_managed_exe_path(&path))
- .unwrap_or(false)
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-enum RemoteServerStatus {
- Running {
- version: Option<String>,
- protocol: Option<u32>,
- live_handoff: bool,
- },
- NotRunning,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RemoteServerRestartReason {
- ProtocolMismatch,
- BinaryUpdated,
- VersionMismatch,
-}
-
-fn ensure_remote_server_ready(
- target: &str,
- remote_herdr: &RemoteHerdr,
- remote_binary_changed: bool,
- stop_after_install_approved: bool,
- live_handoff_enabled: bool,
-) -> io::Result<()> {
- let status = remote_server_status(target, remote_herdr)?;
- let RemoteServerStatus::Running {
- version,
- protocol,
- live_handoff,
- } = status
- else {
- return Ok(());
- };
-
- let Some(reason) =
- remote_server_restart_reason(version.as_deref(), protocol, remote_binary_changed)
- else {
- return Ok(());
- };
-
- if live_handoff_enabled && live_handoff {
- match live_handoff_remote_server(target, remote_herdr) {
- Ok(()) => return Ok(()),
- Err(err) => {
- eprintln!("remote live handoff failed: {err}");
- eprintln!("falling back to remote server restart.");
- }
- }
- }
-
- if stop_after_install_approved {
- stop_remote_server(target, remote_herdr)?;
- return Ok(());
- }
-
- if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? {
- stop_remote_server(target, remote_herdr)?;
- }
- Ok(())
-}
-
-fn remote_server_restart_reason(
- version: Option<&str>,
- protocol: Option<u32>,
- remote_binary_changed: bool,
-) -> Option<RemoteServerRestartReason> {
- if protocol != Some(CURRENT_PROTOCOL) {
- return Some(RemoteServerRestartReason::ProtocolMismatch);
- }
- if remote_binary_changed {
- return Some(RemoteServerRestartReason::BinaryUpdated);
- }
- if version != Some(current_version().as_str()) {
- return Some(RemoteServerRestartReason::VersionMismatch);
- }
- None
-}
-
-fn confirm_remote_install_with_running_server(
- target: &str,
- remote_herdr: &RemoteHerdr,
- live_handoff_enabled: bool,
-) -> io::Result<bool> {
- let status = match remote_server_status(target, remote_herdr) {
- Ok(status) => status,
- Err(err) => {
- if !io::stdin().is_terminal() {
- return Err(io::Error::other(format!(
- "could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary"
- )));
- }
- eprintln!(
- "could not inspect the running remote herdr server on {target} before installing: {err}"
- );
- eprint!("continue installing the remote herdr binary? [y/N] ");
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer != "y" && answer != "yes" {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr install cancelled",
- ));
- }
- return Ok(false);
- }
- };
- let RemoteServerStatus::Running {
- version,
- protocol: _,
- live_handoff,
- } = status
- else {
- return Ok(false);
- };
- if !io::stdin().is_terminal() {
- if live_handoff_enabled && live_handoff {
- return Ok(false);
- }
- return Err(io::Error::other(format!(
- "remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update",
- version_label(version.as_deref())
- )));
- }
-
- if live_handoff_enabled && live_handoff {
- eprintln!("remote herdr server on {target} is currently running:");
- eprintln!(" server: v{}", version_label(version.as_deref()));
- eprintln!(
- "Herdr will install {} and hand off live pane processes to the prepared server.",
- current_version()
- );
- return Ok(false);
- }
-
- eprintln!("remote herdr server on {target} is currently running:");
- eprintln!(" server: v{}", version_label(version.as_deref()));
- eprintln!(
- "To complete the remote update, Herdr must stop the running remote server after installing."
- );
- eprintln!("This stops active remote pane processes, including shells, dev servers, and tests.");
- eprintln!();
- eprint!(
- "Install {} and stop the remote server now? [y/N] ",
- current_version()
- );
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer != "y" && answer != "yes" {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr install cancelled",
- ));
- }
-
- Ok(true)
-}
-
-fn remote_server_status(
- target: &str,
- remote_herdr: &RemoteHerdr,
-) -> io::Result<RemoteServerStatus> {
- let command = format!("{} status server --json", remote_herdr.shell_path);
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Err(command_failed("remote server status failed", &output));
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- parse_remote_server_status_json(stdout.trim())
-}
-
-#[derive(Debug, Deserialize)]
-struct RemoteClientStatusJson {
- protocol: u32,
-}
-
-#[derive(Debug, Deserialize)]
-struct RemoteServerStatusJson {
- running: bool,
- version: Option<String>,
- protocol: Option<u32>,
- capabilities: Option<RemoteServerCapabilitiesJson>,
-}
-
-#[derive(Debug, Deserialize)]
-struct RemoteServerCapabilitiesJson {
- live_handoff: bool,
-}
-
-fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
- serde_json::from_str(status).ok()
-}
-
-fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatus> {
- let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| {
- io::Error::other(format!(
- "could not parse remote server status JSON from `{status}`: {err}"
- ))
- })?;
- if !parsed.running {
- return Ok(RemoteServerStatus::NotRunning);
- }
-
- Ok(RemoteServerStatus::Running {
- version: parsed.version,
- protocol: parsed.protocol,
- live_handoff: parsed
- .capabilities
- .is_some_and(|capabilities| capabilities.live_handoff),
- })
-}
-
-fn confirm_remote_server_stop(
- target: &str,
- version: Option<&str>,
- _protocol: Option<u32>,
- reason: RemoteServerRestartReason,
-) -> io::Result<bool> {
- if !io::stdin().is_terminal() {
- if reason == RemoteServerRestartReason::ProtocolMismatch {
- return Err(io::Error::other(format!(
- "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it"
- )));
- }
-
- eprintln!(
- "remote herdr server on {target} is still running v{}; it will use {} after it restarts.",
- version_label(version),
- current_version()
- );
- return Ok(false);
- }
-
- eprintln!("remote herdr server on {target} is currently running:");
- eprintln!(" server: v{}", version_label(version));
- eprintln!(" prepared binary: {}", current_version());
- eprintln!();
-
- match reason {
- RemoteServerRestartReason::ProtocolMismatch => {
- eprintln!("the remote server must stop before this client can attach.");
- }
- RemoteServerRestartReason::BinaryUpdated => {
- eprintln!(
- "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary."
- );
- }
- RemoteServerRestartReason::VersionMismatch => {
- eprintln!(
- "the remote server is still running a different herdr version. restart it so it uses the prepared binary."
- );
- }
- }
-
- let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch {
- "stop the remote server and continue attaching? [Y/n] "
- } else {
- "restart the remote server now? [y/N] "
- };
- eprint!("{prompt}");
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer == "y" || answer == "yes" {
- return Ok(true);
- }
- if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch {
- return Ok(true);
- }
- if reason == RemoteServerRestartReason::ProtocolMismatch {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr server stop cancelled",
- ));
- }
-
- Ok(false)
-}
-
-fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let command = format!(
- "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
- remote_herdr.shell_path,
- remote_herdr.shell_path,
- CURRENT_PROTOCOL,
- current_version()
- );
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Err(command_failed("remote server live handoff failed", &output));
- }
-
- eprintln!(
- "handed off the remote herdr server on {target}; reconnecting to the prepared server."
- );
- Ok(())
-}
-
-fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let command = format!("{} server stop", remote_herdr.shell_path);
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Err(command_failed("remote server stop failed", &output));
- }
-
- wait_for_remote_server_shutdown(target, remote_herdr)?;
- eprintln!("stopped the remote herdr server on {target}; it will restart when the remote client bridge attaches.");
- Ok(())
-}
-
-fn wait_for_remote_server_shutdown(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT;
- loop {
- if remote_server_status(target, remote_herdr)? == RemoteServerStatus::NotRunning {
- return Ok(());
- }
- if Instant::now() >= deadline {
- return Err(io::Error::new(
- io::ErrorKind::TimedOut,
- format!(
- "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds",
- REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs()
- ),
- ));
- }
- thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL);
- }
-}
-
-fn version_label(version: Option<&str>) -> &str {
- version.unwrap_or("unknown")
-}
-
-fn warn_if_remote_bin_not_on_path(target: &str) -> io::Result<()> {
- let output = ssh_user_shell_output(target, "command -v herdr")?;
- if output.status.success()
- && remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout))
- {
- return Ok(());
- }
-
- eprintln!(
- "herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path"
- );
- Ok(())
-}
-
-fn remote_shell_resolves_managed_install(stdout: &str) -> bool {
- stdout
- .lines()
- .next()
- .map(str::trim)
- .is_some_and(|path| path.ends_with("/.local/bin/herdr"))
-}
-
-fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource> {
- let asset_key = platform.asset_key();
- let asset = remote_release_asset(&asset_key)?;
-
- let dir = private_download_dir(&asset_key)?;
- let path = dir.join("herdr.tmp");
- let status = Command::new("curl")
- .args(["-sfL", "--max-time", "120", "-o"])
- .arg(&path)
- .arg(&asset.url)
- .status()
- .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
- if !status.success() {
- let _ = fs::remove_dir_all(&dir);
- return Err(io::Error::other("download failed"));
- }
- if let Some(expected) = &asset.sha256 {
- if let Err(err) = crate::checksum::verify_sha256(&path, expected) {
- let _ = fs::remove_dir_all(&dir);
- return Err(io::Error::new(
- err.kind(),
- format!("downloaded remote asset checksum verification failed: {err}"),
- ));
- }
- }
-
- Ok(InstallSource::temporary(path, dir))
-}
-
-fn fetch_remote_manifest(url: &str) -> io::Result<Vec<u8>> {
- let output = Command::new("curl")
- .args([
- "-sfL",
- "--retry",
- "3",
- "--connect-timeout",
- "10",
- "--max-time",
- "20",
- url,
- ])
- .output()
- .map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?;
- if !output.status.success() {
- return Err(command_failed("failed to fetch update manifest", &output));
- }
- Ok(output.stdout)
-}
-
-fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset {
- RemoteReleaseAsset {
- url: asset.url().to_string(),
- sha256: asset.sha256().map(str::to_string),
- }
-}
-
-fn preview_assets_for_build<'a>(
- manifest: &'a RemotePreviewManifest,
- build_id: &str,
-) -> io::Result<(u32, &'a BTreeMap<String, RemoteAssetRef>)> {
- if manifest.build_id == build_id {
- return Ok((manifest.protocol, &manifest.assets));
- }
- let build = manifest.builds.get(build_id).ok_or_else(|| {
- io::Error::other(format!(
- "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr"
- ))
- })?;
- Ok((build.protocol, &build.assets))
-}
-
-fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
- if crate::build_info::is_preview() {
- let build_id = crate::build_info::build_id().ok_or_else(|| {
- io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually")
- })?;
- let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?;
- let manifest: RemotePreviewManifest =
- serde_json::from_slice(&manifest_bytes).map_err(|err| {
- io::Error::other(format!("failed to parse preview manifest JSON: {err}"))
- })?;
- let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?;
- if protocol != CURRENT_PROTOCOL {
- return Err(io::Error::other(format!(
- "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually"
- )));
- }
- return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| {
- io::Error::other(format!(
- "no {asset_key} binary in the preview manifest for build {build_id}"
- ))
- });
- }
-
- let current_version = current_version();
- let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?;
- let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes)
- .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
- let release = manifest.release_for_version(&current_version).ok_or_else(|| {
- io::Error::other(format!(
- "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually",
- asset_key
- ))
- })?;
- if let Some(protocol) = release.protocol {
- if protocol != CURRENT_PROTOCOL {
- return Err(io::Error::other(format!(
- "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
- )));
- }
- }
- release
- .assets
- .get(asset_key)
- .map(remote_asset_info)
- .ok_or_else(|| {
- io::Error::other(format!(
- "no {asset_key} binary in the release manifest for herdr {current_version}"
- ))
- })
-}
-
-fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
- let base = std::env::temp_dir();
- for attempt in 0..100 {
- let dir = base.join(format!(
- "herdr-remote-{}-{}-{attempt}",
- std::process::id(),
- asset_key
- ));
- match fs::create_dir(&dir) {
- Ok(()) => return Ok(dir),
- Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
- Err(err) => return Err(err),
- }
- }
-
- Err(io::Error::new(
- io::ErrorKind::AlreadyExists,
- "failed to create private herdr remote download directory",
+#[cfg(windows)]
+pub(crate) fn run_remote(_remote: RemoteLaunch) -> std::io::Result<()> {
+ debug_assert!(!crate::platform::capabilities().remote_attach);
+ Err(std::io::Error::other(
+ "remote mode is not supported on Windows yet",
))
}
-fn confirm_remote_install(
- target: &str,
- remote_herdr: &RemoteHerdr,
- source_description: &str,
-) -> io::Result<()> {
- if !io::stdin().is_terminal() {
- return Err(io::Error::other(format!(
- "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation",
- current_version(),
- remote_herdr.shell_path
- )));
- }
-
- eprintln!(
- "matching herdr {} is not installed on {target} for {}.",
- current_version(),
- remote_herdr.platform.asset_key()
- );
- eprint!(
- "Install {} to {}? [Y/n] ",
- source_description, remote_herdr.shell_path
- );
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer == "n" || answer == "no" {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr installation cancelled",
- ));
- }
-
- Ok(())
-}
-
-fn install_remote_herdr(
- target: &str,
- remote_herdr: &RemoteHerdr,
- source_path: &Path,
-) -> io::Result<()> {
- let script = format!(
- r#"dest="$HOME/{install_suffix}"
-dir="${{dest%/*}}"
-mkdir -p "$dir"
-tmp="${{dest}}.tmp.$$"
-cat > "$tmp"
-chmod 755 "$tmp"
-mv "$tmp" "$dest"
-"#,
- install_suffix = remote_herdr.install_suffix
- );
-
- let mut child = Command::new("ssh")
- .arg("-T")
- .arg(target)
- .arg(format!("/bin/sh -eu -c {}", shell_quote(&script)))
- .stdin(Stdio::piped())
- .stdout(Stdio::inherit())
- .stderr(Stdio::inherit())
- .spawn()
- .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh install: {err}")))?;
-
- let mut source = File::open(source_path)?;
- let copy_result = if let Some(mut stdin) = child.stdin.take() {
- io::copy(&mut source, &mut stdin).map(|_| ())
- } else {
- Err(io::Error::new(
- io::ErrorKind::BrokenPipe,
- "ssh install stdin missing",
- ))
- };
- let status = child.wait()?;
- copy_result?;
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::other(format!(
- "remote install exited with {status}"
- )))
- }
-}
-
-fn ssh_sh_output(target: &str, script: &str) -> io::Result<Output> {
- // Feed POSIX bootstrap scripts to /bin/sh so the user's login shell only
- // has to parse a simple executable invocation.
- let mut child = Command::new("ssh")
- .arg("-T")
- .arg(target)
- .arg("/bin/sh -s")
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()?;
-
- let write_result = if let Some(mut stdin) = child.stdin.take() {
- stdin.write_all(script.as_bytes())
- } else {
- Err(io::Error::new(
- io::ErrorKind::BrokenPipe,
- "ssh bootstrap stdin missing",
- ))
- };
- let output = child.wait_with_output()?;
- write_result?;
- Ok(output)
-}
-
-fn ssh_user_shell_output(target: &str, command: &str) -> io::Result<Output> {
- Command::new("ssh")
- .arg("-T")
- .arg(target)
- .arg(command)
- .output()
-}
-
-fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String {
- let mut command = format!("exec {}", remote_herdr.shell_path);
- if session_name != crate::session::DEFAULT_SESSION_NAME {
- command.push_str(" --session ");
- command.push_str(&shell_quote(session_name));
- }
- command.push_str(" remote-client-bridge");
- command
-}
-
-fn reattach_command(
- program: &str,
- target: &str,
- session_name: &str,
- keybindings: RemoteKeybindings,
- live_handoff: bool,
-) -> String {
- let program = if program.is_empty() { "herdr" } else { program };
- let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target));
- if keybindings != RemoteKeybindings::Local {
- command.push_str(" --remote-keybindings ");
- command.push_str(keybindings.as_str());
- }
- if live_handoff {
- command.push_str(" --handoff");
- }
- if session_name != crate::session::DEFAULT_SESSION_NAME {
- command.push_str(" --session ");
- command.push_str(&shell_quote(session_name));
- }
- command
-}
-
-fn shell_quote(value: &str) -> String {
- if !value.is_empty()
- && value.chars().all(|ch| {
- ch.is_ascii_alphanumeric()
- || matches!(
- ch,
- '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-'
- )
- })
- {
- return value.to_string();
- }
-
- format!("'{}'", value.replace('\'', "'\\''"))
-}
-
-fn command_failed(context: &str, output: &Output) -> io::Error {
- let stderr = String::from_utf8_lossy(&output.stderr);
- let stderr = stderr.trim();
- if stderr.is_empty() {
- io::Error::other(format!("{context}: {}", output.status))
- } else {
- io::Error::other(format!("{context}: {stderr}"))
- }
-}
-
-struct SshStdioBridge {
- local_socket: PathBuf,
- keepalive_ssh_config: Option<PathBuf>,
- should_stop: Arc<AtomicBool>,
- thread: Option<JoinHandle<()>>,
-}
-
-impl SshStdioBridge {
- fn start(
- target: String,
- remote_herdr: RemoteHerdr,
- local_socket: PathBuf,
- session_name: String,
- manage_ssh_config: bool,
- ) -> io::Result<Self> {
- let _ = std::fs::remove_file(&local_socket);
- let listener = UnixListener::bind(&local_socket)?;
- crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?;
- listener.set_nonblocking(true)?;
-
- let keepalive_ssh_config = if manage_ssh_config {
- write_keepalive_ssh_config()
- .inspect_err(|err| {
- tracing::debug!(%err, "could not write ssh keepalive config; using plain ssh");
- })
- .ok()
- } else {
- None
- };
-
- let should_stop = Arc::new(AtomicBool::new(false));
- let thread_stop = Arc::clone(&should_stop);
- let thread_ssh_config = keepalive_ssh_config.clone();
- let thread = thread::spawn(move || {
- while !thread_stop.load(Ordering::Acquire) {
- match listener.accept() {
- Ok((stream, _addr)) => {
- if let Err(err) = stream.set_nonblocking(false) {
- eprintln!(
- "herdr: remote bridge failed to prepare client socket: {err}"
- );
- continue;
- }
- if let Err(err) = bridge_connection(
- stream,
- &target,
- &remote_herdr,
- &session_name,
- thread_ssh_config.as_deref(),
- ) {
- eprintln!("herdr: remote bridge failed: {err}");
- }
- }
- Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
- thread::sleep(BRIDGE_ACCEPT_POLL);
- }
- Err(err) => {
- eprintln!("herdr: remote bridge listener failed: {err}");
- break;
- }
- }
- }
- });
-
- Ok(Self {
- local_socket,
- keepalive_ssh_config,
- should_stop,
- thread: Some(thread),
- })
- }
-}
-
-impl Drop for SshStdioBridge {
- fn drop(&mut self) {
- self.should_stop.store(true, Ordering::Release);
- let _ = std::fs::remove_file(&self.local_socket);
- if let Some(thread) = self.thread.take() {
- let _ = thread.join();
- }
- // Remove the generated ssh config only after the bridge thread has
- // joined, so it can never start a connection with a config path that
- // was just deleted.
- if let Some(dir) = self.keepalive_ssh_config.as_deref().and_then(Path::parent) {
- let _ = std::fs::remove_dir_all(dir);
- }
- }
-}
-
-/// Creates a fresh user-only (`0700`) directory under the temp dir for the
-/// bridge's generated ssh config, returning its path.
-///
-/// Using a private directory created with fail-if-exists semantics โ€” rather
-/// than a predictable file in the world-writable temp dir โ€” stops a local user
-/// from pre-planting a symlink or world-writable file that herdr would write
-/// and `ssh -F` would then read.
-fn private_ssh_config_dir() -> io::Result<PathBuf> {
- use std::os::unix::fs::DirBuilderExt;
-
- let base = std::env::temp_dir();
- for attempt in 0..100 {
- let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id()));
- match fs::DirBuilder::new().mode(0o700).create(&dir) {
- Ok(()) => return Ok(dir),
- Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
- Err(err) => return Err(err),
- }
- }
-
- Err(io::Error::new(
- io::ErrorKind::AlreadyExists,
- "failed to create private herdr ssh config directory",
+#[cfg(windows)]
+pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> {
+ debug_assert!(!crate::platform::capabilities().remote_attach);
+ Err(std::io::Error::other(
+ "remote client bridge is not supported on Windows yet",
))
}
-
-/// Quotes a path for an ssh_config `Include` so a path containing spaces (or
-/// glob metacharacters) is treated as one literal token instead of being split
-/// or expanded by ssh โ€” otherwise the user's config might not be Included and
-/// herdr's fallback would wrongly take effect.
-fn ssh_config_quote(path: &str) -> String {
- format!("\"{path}\"")
-}
-
-/// Builds a temporary ssh config that keeps the bridge tunnel alive without
-/// overriding the user's own settings, returning its path.
-///
-/// The file `Include`s the user's real ssh config first, so ssh's
-/// first-value-wins rule keeps any `ServerAlive*` the user set there (including
-/// an explicit `0` to disable it); herdr's values apply only when the user has
-/// none.
-fn write_keepalive_ssh_config() -> io::Result<PathBuf> {
- use std::os::unix::fs::OpenOptionsExt;
-
- let path = private_ssh_config_dir()?.join("config");
-
- let mut contents = String::new();
- if let Some(home) = std::env::var_os("HOME") {
- let user_config = PathBuf::from(home).join(".ssh").join("config");
- if user_config.is_file() {
- contents.push_str(&format!(
- "Include {}\n",
- ssh_config_quote(&user_config.to_string_lossy())
- ));
- }
- }
- if Path::new("/etc/ssh/ssh_config").is_file() {
- contents.push_str("Include /etc/ssh/ssh_config\n");
- }
- contents.push_str("Host *\n");
- contents.push_str(" ServerAliveInterval 15\n");
- contents.push_str(" ServerAliveCountMax 4\n");
-
- let mut file = fs::OpenOptions::new()
- .write(true)
- .create_new(true)
- .mode(BRIDGE_SOCKET_PERMISSION_MODE)
- .open(&path)?;
- file.write_all(contents.as_bytes())?;
- Ok(path)
-}
-
-fn bridge_connection(
- stream: UnixStream,
- target: &str,
- remote_herdr: &RemoteHerdr,
- session_name: &str,
- keepalive_ssh_config: Option<&Path>,
-) -> io::Result<()> {
- let mut command = Command::new("ssh");
- // Use the generated keepalive ssh config when present; otherwise plain ssh.
- if let Some(ssh_config) = keepalive_ssh_config {
- command.arg("-F").arg(ssh_config);
- }
- command
- .arg("-T")
- .arg(target)
- .arg(remote_bridge_command(remote_herdr, session_name));
- command
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::inherit());
-
- let mut child = command
- .spawn()
- .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?;
- let mut child_stdin = child
- .stdin
- .take()
- .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?;
- let mut child_stdout = child
- .stdout
- .take()
- .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?;
- let mut stream_to_child = stream.try_clone()?;
- let mut child_to_stream = stream;
-
- let upload = thread::spawn(move || {
- let _ = copy_flush(&mut stream_to_child, &mut child_stdin);
- });
- let download = thread::spawn(move || {
- let _ = copy_flush(&mut child_stdout, &mut child_to_stream);
- let _ = child_to_stream.shutdown(std::net::Shutdown::Write);
- });
-
- let status = child.wait()?;
- let _ = upload.join();
- let _ = download.join();
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::new(
- io::ErrorKind::ConnectionAborted,
- format!("ssh bridge exited with {status}"),
- ))
- }
-}
-
-fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
- let mut buffer = [0_u8; 16 * 1024];
- let mut total = 0;
-
- loop {
- let bytes_read = match reader.read(&mut buffer) {
- Ok(0) => return Ok(total),
- Ok(bytes_read) => bytes_read,
- Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
- Err(err) => return Err(err),
- };
-
- writer.write_all(&buffer[..bytes_read])?;
- writer.flush()?;
- total += bytes_read as u64;
- }
-}
-
-fn run_client_process(
- local_socket: &Path,
- reattach_command: &str,
- keybindings: RemoteKeybindings,
-) -> io::Result<()> {
- let exe = std::env::current_exe()?;
- let status = Command::new(exe)
- .arg("client")
- .env(
- crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR,
- local_socket,
- )
- .env("HERDR_RENDER_ENCODING", "terminal-ansi")
- .env(REATTACH_COMMAND_ENV_VAR, reattach_command)
- .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str())
- .env_remove(crate::api::SOCKET_PATH_ENV_VAR)
- .stdin(Stdio::inherit())
- .stdout(Stdio::inherit())
- .stderr(Stdio::inherit())
- .status()?;
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::new(
- io::ErrorKind::Interrupted,
- format!("remote client exited with {status}"),
- ))
- }
-}
-
-fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf {
- let pid = std::process::id();
- let target_clean = sanitize_path_component(target);
- let session_clean = sanitize_path_component(session_name);
-
- let tmpdir = std::env::temp_dir();
- let readable = tmpdir.join(format!(
- "herdr-remote-{pid}-{target_clean}-{session_clean}.sock"
- ));
- if fits_unix_socket_path(&readable) {
- return readable;
- }
-
- // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the
- // readable name past sun_path's 104-byte ceiling. Fall back to a hashed
- // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself
- // is longer than the budget. The hash covers the full unsanitized
- // target/session so uniqueness does not depend on the prefix truncation;
- // the prefix is kept only for debuggability.
- let target_prefix: String = target_clean.chars().take(8).collect();
- let hash = short_socket_hash(target, session_name);
- let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock");
- let short_in_tmp = tmpdir.join(&short_name);
- if fits_unix_socket_path(&short_in_tmp) {
- return short_in_tmp;
- }
- PathBuf::from("/tmp").join(short_name)
-}
-
-fn fits_unix_socket_path(path: &Path) -> bool {
- use std::os::unix::ffi::OsStrExt;
- // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve
- // 1 byte for the trailing NUL and use the smaller cap for portability.
- const MAX: usize = 103;
- path.as_os_str().as_bytes().len() <= MAX
-}
-
-fn short_socket_hash(target: &str, session: &str) -> String {
- use std::collections::hash_map::DefaultHasher;
- use std::hash::{Hash, Hasher};
- let mut hasher = DefaultHasher::new();
- target.hash(&mut hasher);
- 0u8.hash(&mut hasher);
- session.hash(&mut hasher);
- format!("{:016x}", hasher.finish())
-}
-
-fn sanitize_path_component(input: &str) -> String {
- let sanitized: String = input
- .chars()
- .map(|ch| {
- if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
- ch
- } else {
- '-'
- }
- })
- .collect();
-
- sanitized.trim_matches('-').chars().take(32).collect()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn bridge_socket_is_user_only() {
- use std::os::unix::fs::PermissionsExt;
-
- let socket = std::env::temp_dir().join(format!(
- "herdr-bridge-permissions-test-{}.sock",
- std::process::id()
- ));
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let bridge = SshStdioBridge::start(
- "example".to_string(),
- remote_herdr,
- socket.clone(),
- "default".to_string(),
- false,
- )
- .expect("start bridge listener");
-
- let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
- assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE);
-
- drop(bridge);
- let _ = std::fs::remove_file(socket);
- }
-
- #[test]
- fn keepalive_ssh_config_includes_user_config_then_fallback() {
- use std::os::unix::fs::PermissionsExt;
-
- let path = write_keepalive_ssh_config().expect("write keepalive config");
- let contents = std::fs::read_to_string(&path).expect("read keepalive config");
-
- // herdr's fallback keepalive is present...
- assert!(
- contents.contains("Host *"),
- "config should add a Host * fallback block: {contents}"
- );
- assert!(
- contents.contains("ServerAliveInterval 15"),
- "config should set the keepalive interval: {contents}"
- );
- assert!(
- contents.contains("ServerAliveCountMax 4"),
- "config should set the keepalive count: {contents}"
- );
- // ...and any user config is Included (quoted) BEFORE it so first-value-wins
- // keeps the user's own settings.
- if let Some(home) = std::env::var_os("HOME") {
- let user_config = PathBuf::from(home).join(".ssh").join("config");
- if user_config.is_file() {
- let include = format!(
- "Include {}",
- ssh_config_quote(&user_config.to_string_lossy())
- );
- let include_at = contents.find(&include).expect("user config Included");
- let fallback_at = contents.find("Host *").expect("fallback present");
- assert!(
- include_at < fallback_at,
- "user config must be Included before herdr's fallback: {contents}"
- );
- }
- }
-
- let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
- assert_eq!(
- mode, BRIDGE_SOCKET_PERMISSION_MODE,
- "keepalive config must be user-only"
- );
- // The config lives in a private 0700 dir, not a predictable temp path.
- let dir = path.parent().expect("config has a parent dir");
- let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
- assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only");
-
- let _ = std::fs::remove_dir_all(dir);
- }
-
- #[test]
- fn ssh_config_quote_wraps_path_with_spaces() {
- assert_eq!(
- ssh_config_quote("/home/a b/.ssh/config"),
- "\"/home/a b/.ssh/config\""
- );
- }
-
- #[test]
- fn extract_remote_args_removes_space_form() {
- let args = vec![
- "herdr".into(),
- "--remote".into(),
- "dev".into(),
- "--help".into(),
- ];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr", "--help"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "dev");
- assert_eq!(remote.keybindings, RemoteKeybindings::Local);
- }
-
- #[test]
- fn extract_remote_args_removes_equals_form() {
- let args = vec!["herdr".into(), "--remote=user@host".into()];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "user@host");
- assert_eq!(remote.keybindings, RemoteKeybindings::Local);
- }
-
- #[test]
- fn extract_remote_args_accepts_remote_keybindings_server() {
- let args = vec![
- "herdr".into(),
- "--remote".into(),
- "dev".into(),
- "--remote-keybindings=server".into(),
- ];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "dev");
- assert_eq!(remote.keybindings, RemoteKeybindings::Server);
- }
-
- #[test]
- fn extract_remote_args_accepts_remote_keybindings_space_form() {
- let args = vec![
- "herdr".into(),
- "--remote=dev".into(),
- "--remote-keybindings".into(),
- "server".into(),
- ];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr"]);
- assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server);
- }
-
- #[test]
- fn extract_remote_args_accepts_explicit_handoff() {
- let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()];
-
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
-
- assert_eq!(cleaned, vec!["herdr"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "dev");
- assert!(remote.live_handoff);
- }
-
- #[test]
- fn extract_remote_args_preserves_child_remote_options_after_separator() {
- let args = vec![
- "herdr".into(),
- "agent".into(),
- "start".into(),
- "repro".into(),
- "--".into(),
- "child".into(),
- "--remote".into(),
- "dev".into(),
- "--remote-keybindings=server".into(),
- "--handoff".into(),
- ];
-
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
-
- assert_eq!(cleaned, args);
- assert!(remote.is_none());
- }
-
- #[test]
- fn extract_remote_args_preserves_handoff_without_remote() {
- let args = vec!["herdr".into(), "update".into(), "--handoff".into()];
-
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
-
- assert_eq!(cleaned, args);
- assert!(remote.is_none());
- }
-
- #[test]
- fn extract_remote_args_rejects_remote_keybindings_without_remote() {
- let args = vec!["herdr".into(), "--remote-keybindings=server".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote-keybindings requires --remote");
- }
-
- #[test]
- fn extract_remote_args_rejects_duplicate_remote_keybindings() {
- let args = vec![
- "herdr".into(),
- "--remote=dev".into(),
- "--remote-keybindings=local".into(),
- "--remote-keybindings=server".into(),
- ];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote-keybindings can only be specified once");
- }
-
- #[test]
- fn extract_remote_args_requires_value() {
- let args = vec!["herdr".into(), "--remote".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "missing value for --remote");
- }
-
- #[test]
- fn extract_remote_args_rejects_empty_value() {
- let args = vec!["herdr".into(), "--remote=".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "missing value for --remote");
- }
-
- #[test]
- fn extract_remote_args_rejects_duplicate_values() {
- let args = vec![
- "herdr".into(),
- "--remote=dev".into(),
- "--remote=prod".into(),
- ];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote can only be specified once");
- }
-
- #[test]
- fn extract_remote_args_rejects_option_like_target() {
- let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote target must not start with '-'");
- }
-
- #[test]
- fn sanitize_path_component_removes_shell_sensitive_chars() {
- assert_eq!(sanitize_path_component("user@host:22"), "user-host-22");
- }
-
- #[test]
- fn remote_platform_maps_uname_values() {
- assert_eq!(
- RemotePlatform::from_uname("Linux", "amd64")
- .unwrap()
- .asset_key(),
- "linux-x86_64"
- );
- assert_eq!(
- RemotePlatform::from_uname("Darwin", "arm64")
- .unwrap()
- .asset_key(),
- "macos-aarch64"
- );
- assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none());
- }
-
- #[test]
- fn reattach_command_includes_remote_and_session() {
- assert_eq!(
- reattach_command(
- "target/release/herdr",
- "user@host",
- "work",
- RemoteKeybindings::Local,
- false,
- ),
- "target/release/herdr --remote user@host --session work"
- );
- assert_eq!(
- reattach_command(
- "herdr",
- "host name",
- crate::session::DEFAULT_SESSION_NAME,
- RemoteKeybindings::Local,
- false,
- ),
- "herdr --remote 'host name'"
- );
- assert_eq!(
- reattach_command(
- "herdr",
- "host",
- crate::session::DEFAULT_SESSION_NAME,
- RemoteKeybindings::Server,
- false,
- ),
- "herdr --remote host --remote-keybindings server"
- );
- assert_eq!(
- reattach_command(
- "herdr",
- "host",
- crate::session::DEFAULT_SESSION_NAME,
- RemoteKeybindings::Local,
- true,
- ),
- "herdr --remote host --handoff"
- );
- }
-
- #[test]
- fn remote_bridge_command_uses_installed_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec \"$HOME/.local/bin/herdr\" remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_uses_path_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec /usr/bin/herdr remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_quotes_discovered_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr =
- remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec '/opt/herdr bin/herdr' remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_uses_macos_path_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "macos",
- arch: "aarch64",
- });
- let remote_herdr =
- remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec /opt/homebrew/bin/herdr remote-client-bridge"
- );
- assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64");
- }
-
- #[test]
- fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr =
- remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_ignores_relative_paths() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n");
-
- assert!(remote_herdr.is_none());
- }
-
- #[test]
- fn remote_path_discovery_ignores_empty_output() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n");
-
- assert!(remote_herdr.is_none());
- }
-
- #[test]
- fn remote_shell_path_warning_accepts_managed_install() {
- assert!(remote_shell_resolves_managed_install(
- "/home/can/.local/bin/herdr\n"
- ));
- assert!(remote_shell_resolves_managed_install(
- "/Users/can/.local/bin/herdr\n"
- ));
- assert!(!remote_shell_resolves_managed_install(
- "/usr/local/bin/herdr\n"
- ));
- assert!(!remote_shell_resolves_managed_install(""));
- }
-
- #[test]
- fn parse_client_status_json_reads_protocol() {
- assert_eq!(
- parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#)
- .map(|status| status.protocol),
- Some(8)
- );
- assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none());
- }
-
- #[test]
- fn parse_remote_server_status_json_reads_running_server() {
- assert_eq!(
- parse_remote_server_status_json(
- r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true}}"#
- )
- .unwrap(),
- RemoteServerStatus::Running {
- version: Some("0.6.0".into()),
- protocol: Some(8),
- live_handoff: true
- }
- );
- }
-
- #[test]
- fn parse_remote_server_status_json_treats_missing_capability_as_no_handoff() {
- assert_eq!(
- parse_remote_server_status_json(
- r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"#
- )
- .unwrap(),
- RemoteServerStatus::Running {
- version: Some("0.6.0".into()),
- protocol: Some(8),
- live_handoff: false
- }
- );
- }
-
- #[test]
- fn parse_remote_server_status_json_reads_stopped_server() {
- assert_eq!(
- parse_remote_server_status_json(
- r#"{"status":"not_running","running":false,"version":null,"protocol":null}"#
- )
- .unwrap(),
- RemoteServerStatus::NotRunning
- );
- }
-
- #[test]
- fn remote_update_manifest_uses_root_assets_for_latest_version() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.3",
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.assets.get("linux-x86_64"))
- .map(RemoteAssetRef::url),
- Some("https://example.com/latest")
- );
- }
-
- #[test]
- fn remote_update_manifest_reads_archived_release_assets() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.4",
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "notes": "ignored",
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.assets.get("linux-x86_64"))
- .map(RemoteAssetRef::url),
- Some("https://example.com/archive")
- );
- }
-
- #[test]
- fn remote_update_manifest_uses_archived_release_protocol() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.4",
- "protocol": 42,
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "notes": "ignored",
- "protocol": 41,
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.protocol),
- Some(41)
- );
- }
-
- #[test]
- fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.4",
- "protocol": 42,
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "notes": "ignored",
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.protocol),
- None
- );
- }
-
- #[test]
- fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() {
- let manifest: RemotePreviewManifest = serde_json::from_str(
- r#"{
- "build_id": "2026-06-06-new",
- "protocol": 12,
- "assets": {
- "linux-x86_64": {
- "url": "https://example.com/new",
- "sha256": "new"
- }
- },
- "builds": {
- "2026-06-02-old": {
- "protocol": 11,
- "assets": {
- "linux-x86_64": {
- "url": "https://example.com/old",
- "sha256": "old"
- }
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- let (protocol, assets) =
- preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build");
- let asset = assets.get("linux-x86_64").expect("asset");
- assert_eq!(protocol, 11);
- assert_eq!(asset.url(), "https://example.com/old");
- assert_eq!(asset.sha256(), Some("old"));
- }
-
- #[test]
- fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
- assert_eq!(
- remote_server_restart_reason(Some(&current_version()), Some(0), false),
- Some(RemoteServerRestartReason::ProtocolMismatch)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_offers_restart_after_binary_update() {
- assert_eq!(
- remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), true),
- Some(RemoteServerRestartReason::BinaryUpdated)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_offers_restart_for_version_mismatch() {
- assert_eq!(
- remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), false),
- Some(RemoteServerRestartReason::VersionMismatch)
- );
- assert_eq!(
- remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), false),
- Some(RemoteServerRestartReason::VersionMismatch)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_allows_current_server() {
- assert_eq!(
- remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), false),
- None
- );
- }
-
- #[test]
- fn install_source_description_uses_override_binary() {
- let platform = RemotePlatform {
- os: "linux",
- arch: "aarch64",
- };
- assert_eq!(
- install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false),
- "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)"
- );
- }
-
- #[test]
- fn install_source_description_uses_local_binary_when_allowed() {
- let platform = RemotePlatform::local();
-
- assert_eq!(
- install_source_description_for(&platform, None, true),
- "the current local herdr binary"
- );
- }
-
- #[test]
- fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() {
- let platform = RemotePlatform::local();
-
- assert_eq!(
- install_source_description_for(&platform, None, false),
- format!(
- "the {} {} asset for {}",
- current_version(),
- current_channel(),
- platform.asset_key()
- )
- );
- }
-
- #[test]
- fn resolve_install_source_uses_override_binary_without_temporary_cleanup() {
- let platform = RemotePlatform {
- os: "linux",
- arch: "aarch64",
- };
- let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64")))
- .expect("override source");
- assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64"));
- assert!(source.temporary_dir.is_none());
- }
-
- fn remote_env_lock() -> &'static std::sync::Mutex<()> {
- static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
- LOCK.get_or_init(|| std::sync::Mutex::new(()))
- }
-
- fn socket_path_byte_len(path: &Path) -> usize {
- use std::os::unix::ffi::OsStrExt;
- path.as_os_str().as_bytes().len()
- }
-
- #[test]
- fn local_forward_socket_path_uses_readable_name_when_it_fits() {
- let _guard = remote_env_lock().lock().unwrap();
- // Short target + session leave plenty of room โ€” keep the human-
- // readable form so the socket path stays grep-friendly.
- let path = local_forward_socket_path("dev", "default");
- let filename = path
- .file_name()
- .and_then(|s| s.to_str())
- .unwrap_or("")
- .to_string();
- assert!(
- filename.starts_with("herdr-remote-"),
- "expected readable name, got {filename}"
- );
- assert!(filename.contains("-dev-default."), "got {filename}");
- assert!(
- fits_unix_socket_path(&path),
- "socket path too long: {} ({} bytes)",
- path.display(),
- socket_path_byte_len(&path)
- );
- }
-
- #[test]
- fn local_forward_socket_path_fits_in_sun_path() {
- let _guard = remote_env_lock().lock().unwrap();
- // Worst case for the readable form: macOS-style 49-char TMPDIR +
- // max-length sanitized components. Should fall back to the hashed
- // short name, which fits under TMPDIR.
- let target = "longish-host.example.com";
- let session = "a-fairly-long-session-name-here";
- let path = local_forward_socket_path(target, session);
- assert!(
- fits_unix_socket_path(&path),
- "socket path too long for sun_path: {} ({} bytes)",
- path.display(),
- socket_path_byte_len(&path)
- );
- }
-
- #[test]
- fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() {
- let _guard = remote_env_lock().lock().unwrap();
- // Force a TMPDIR long enough that even the hashed short name cannot
- // fit inside it. The fallback should drop to /tmp.
- let prior = std::env::var_os("TMPDIR");
- let long_dir = std::env::temp_dir().join("a".repeat(80));
- let _ = fs::create_dir_all(&long_dir);
- std::env::set_var("TMPDIR", &long_dir);
-
- let path = local_forward_socket_path("longish-host.example.com", "default");
- let fits = fits_unix_socket_path(&path);
- let parent = path.parent().map(Path::to_path_buf);
- let filename = path
- .file_name()
- .and_then(|s| s.to_str())
- .unwrap_or("")
- .to_string();
-
- match prior {
- Some(v) => std::env::set_var("TMPDIR", v),
- None => std::env::remove_var("TMPDIR"),
- }
- let _ = fs::remove_dir_all(&long_dir);
-
- assert!(fits, "fallback path still overflows: {}", path.display());
- assert_eq!(parent.as_deref(), Some(Path::new("/tmp")));
- assert!(
- filename.starts_with("herdr-r-"),
- "expected hashed fallback, got {filename}"
- );
- }
-
- #[test]
- fn install_source_cleanup_removes_temporary_directory() {
- let dir = std::env::temp_dir().join(format!(
- "herdr-install-source-cleanup-test-{}",
- std::process::id()
- ));
- let _ = fs::remove_dir_all(&dir);
- fs::create_dir(&dir).expect("create temp dir");
- let path = dir.join("herdr.tmp");
- fs::write(&path, b"test").expect("write temp file");
-
- InstallSource::temporary(path, dir.clone()).cleanup();
-
- assert!(!dir.exists());
- }
-}
diff --git a/src/remote/unix.rs b/src/remote/unix.rs
new file mode 100644
index 00000000..ce56ba8a
--- /dev/null
+++ b/src/remote/unix.rs
@@ -0,0 +1,2496 @@
+//! Remote thin-client launcher over SSH command stdio.
+
+use std::collections::BTreeMap;
+use std::fs::{self, File};
+use std::io::{self, IsTerminal, Write as _};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output, Stdio};
+
+use serde::Deserialize;
+use std::sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+};
+use std::thread::{self, JoinHandle};
+use std::time::{Duration, Instant};
+
+const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
+const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
+const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
+const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
+const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
+const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
+const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
+pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
+
+pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RemoteKeybindings {
+ Local,
+ Server,
+}
+
+impl RemoteKeybindings {
+ fn parse(value: &str) -> Result<Self, String> {
+ match value {
+ "local" => Ok(Self::Local),
+ "server" => Ok(Self::Server),
+ _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()),
+ }
+ }
+
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Local => "local",
+ Self::Server => "server",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct RemoteLaunch {
+ pub(crate) target: String,
+ pub(crate) keybindings: RemoteKeybindings,
+ pub(crate) live_handoff: bool,
+}
+
+pub(crate) fn extract_remote_args(
+ args: &[String],
+) -> Result<(Vec<String>, Option<RemoteLaunch>), String> {
+ let mut cleaned = Vec::with_capacity(args.len());
+ if let Some(program) = args.first() {
+ cleaned.push(program.clone());
+ }
+
+ let mut remote_target = None;
+ let mut keybindings = RemoteKeybindings::Local;
+ let mut keybindings_seen = false;
+ let mut live_handoff = false;
+ let mut index = 1;
+ while index < args.len() {
+ let arg = &args[index];
+ if arg == "--" {
+ cleaned.extend_from_slice(&args[index..]);
+ break;
+ }
+ if arg == "--handoff" {
+ live_handoff = true;
+ index += 1;
+ continue;
+ }
+ if arg == "--remote" {
+ if remote_target.is_some() {
+ return Err("--remote can only be specified once".to_string());
+ }
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --remote".to_string());
+ };
+ remote_target = Some(validate_remote_target(value)?.to_owned());
+ index += 2;
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("--remote=") {
+ if remote_target.is_some() {
+ return Err("--remote can only be specified once".to_string());
+ }
+ remote_target = Some(validate_remote_target(value)?.to_owned());
+ index += 1;
+ continue;
+ }
+ if arg == "--remote-keybindings" {
+ if keybindings_seen {
+ return Err("--remote-keybindings can only be specified once".to_string());
+ }
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --remote-keybindings".to_string());
+ };
+ keybindings = RemoteKeybindings::parse(value)?;
+ keybindings_seen = true;
+ index += 2;
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("--remote-keybindings=") {
+ if keybindings_seen {
+ return Err("--remote-keybindings can only be specified once".to_string());
+ }
+ keybindings = RemoteKeybindings::parse(value)?;
+ keybindings_seen = true;
+ index += 1;
+ continue;
+ }
+
+ cleaned.push(arg.clone());
+ index += 1;
+ }
+
+ let remote = remote_target.map(|target| RemoteLaunch {
+ target,
+ keybindings,
+ live_handoff,
+ });
+ if remote.is_none() && keybindings_seen {
+ return Err("--remote-keybindings requires --remote".to_string());
+ }
+ if remote.is_none() && live_handoff {
+ cleaned.push("--handoff".to_string());
+ }
+
+ Ok((cleaned, remote))
+}
+
+fn validate_remote_target(target: &str) -> Result<&str, String> {
+ if target.is_empty() {
+ return Err("missing value for --remote".to_string());
+ }
+ if target.starts_with('-') {
+ return Err("--remote target must not start with '-'".to_string());
+ }
+ Ok(target)
+}
+
+pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
+ let session_name = crate::session::active_name()
+ .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string());
+ let local_socket = local_forward_socket_path(&remote.target, &session_name);
+ let program = std::env::args()
+ .next()
+ .unwrap_or_else(|| "herdr".to_string());
+ let reattach_command = reattach_command(
+ &program,
+ &remote.target,
+ &session_name,
+ remote.keybindings,
+ remote.live_handoff,
+ );
+ let prepared_remote = prepare_remote_herdr(&remote.target, remote.live_handoff)?;
+ ensure_remote_server_ready(
+ &remote.target,
+ &prepared_remote.remote_herdr,
+ prepared_remote.installed_or_replaced,
+ prepared_remote.stop_after_install_approved,
+ remote.live_handoff,
+ )?;
+
+ let manage_ssh_config = crate::config::Config::load()
+ .config
+ .remote
+ .manage_ssh_config;
+ let _bridge = SshStdioBridge::start(
+ remote.target,
+ prepared_remote.remote_herdr,
+ local_socket.clone(),
+ session_name,
+ manage_ssh_config,
+ )?;
+
+ run_client_process(&local_socket, &reattach_command, remote.keybindings)
+}
+
+pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
+ ensure_remote_server_running()?;
+
+ let socket_path = crate::server::socket_paths::client_socket_path();
+ let stream = UnixStream::connect(&socket_path).map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!(
+ "failed to connect to remote Herdr client socket {}: {err}",
+ socket_path.display()
+ ),
+ )
+ })?;
+
+ let mut stdout = io::stdout().lock();
+ let mut socket_to_stdout = stream.try_clone()?;
+ let mut stdin_to_socket = stream;
+
+ let _upload = thread::spawn(move || {
+ let mut stdin = io::stdin();
+ let _ = copy_flush(&mut stdin, &mut stdin_to_socket);
+ let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
+ });
+
+ copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ())
+}
+
+fn ensure_remote_server_running() -> io::Result<()> {
+ let socket_path = crate::server::socket_paths::client_socket_path();
+ if crate::server::autodetect::is_server_listening() {
+ let status = crate::api::read_runtime_status_at(
+ &crate::api::socket_path(),
+ Duration::from_millis(500),
+ )?
+ .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?;
+ if status.protocol == Some(CURRENT_PROTOCOL) {
+ return Ok(());
+ }
+ return Err(io::Error::other(
+ "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it",
+ ));
+ }
+
+ crate::server::autodetect::spawn_server_daemon()?;
+ crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5))
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct RemotePlatform {
+ os: &'static str,
+ arch: &'static str,
+}
+
+impl RemotePlatform {
+ fn from_uname(os: &str, arch: &str) -> Option<Self> {
+ let os = match os.trim() {
+ "Linux" => "linux",
+ "Darwin" => "macos",
+ _ => return None,
+ };
+ let arch = match arch.trim() {
+ "x86_64" | "amd64" => "x86_64",
+ "aarch64" | "arm64" => "aarch64",
+ _ => return None,
+ };
+ Some(Self { os, arch })
+ }
+
+ fn local() -> Self {
+ let os = if cfg!(target_os = "linux") {
+ "linux"
+ } else if cfg!(target_os = "macos") {
+ "macos"
+ } else {
+ "unknown"
+ };
+
+ let arch = if cfg!(target_arch = "x86_64") {
+ "x86_64"
+ } else if cfg!(target_arch = "aarch64") {
+ "aarch64"
+ } else {
+ "unknown"
+ };
+
+ Self { os, arch }
+ }
+
+ fn asset_key(&self) -> String {
+ format!("{}-{}", self.os, self.arch)
+ }
+}
+
+#[derive(Debug, Clone)]
+struct RemoteHerdr {
+ install_suffix: String,
+ shell_path: String,
+ platform: RemotePlatform,
+}
+
+impl RemoteHerdr {
+ fn for_platform(platform: RemotePlatform) -> Self {
+ let install_suffix = ".local/bin/herdr".to_string();
+ let shell_path = format!("\"$HOME/{install_suffix}\"");
+ Self {
+ install_suffix,
+ shell_path,
+ platform,
+ }
+ }
+
+ fn with_shell_path(mut self, shell_path: String) -> Self {
+ self.shell_path = shell_path;
+ self
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+enum RemoteAssetRef {
+ Url(String),
+ Object { url: String, sha256: Option<String> },
+}
+
+impl RemoteAssetRef {
+ fn url(&self) -> &str {
+ match self {
+ Self::Url(url) => url,
+ Self::Object { url, .. } => url,
+ }
+ }
+
+ fn sha256(&self) -> Option<&str> {
+ match self {
+ Self::Url(_) => None,
+ Self::Object { sha256, .. } => {
+ sha256.as_deref().filter(|value| !value.trim().is_empty())
+ }
+ }
+ }
+}
+
+#[derive(Deserialize)]
+struct RemoteUpdateManifest {
+ version: String,
+ protocol: Option<u32>,
+ assets: BTreeMap<String, RemoteAssetRef>,
+ #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
+ releases: BTreeMap<String, RemoteReleaseMetadata>,
+}
+
+#[derive(Deserialize)]
+struct RemoteReleaseMetadata {
+ protocol: Option<u32>,
+ #[serde(default)]
+ assets: BTreeMap<String, RemoteAssetRef>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewManifest {
+ build_id: String,
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
+ #[serde(default)]
+ builds: BTreeMap<String, RemotePreviewBuildMetadata>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewBuildMetadata {
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
+}
+
+fn deserialize_remote_manifest_releases<'de, D>(
+ deserializer: D,
+) -> Result<BTreeMap<String, RemoteReleaseMetadata>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let value = Option::<serde_json::Value>::deserialize(deserializer)?;
+ Ok(match value {
+ Some(serde_json::Value::Object(object)) => object
+ .into_iter()
+ .filter_map(|(version, release)| {
+ serde_json::from_value::<RemoteReleaseMetadata>(release)
+ .ok()
+ .map(|metadata| (version, metadata))
+ })
+ .collect(),
+ _ => BTreeMap::new(),
+ })
+}
+
+impl RemoteUpdateManifest {
+ fn release_for_version(&self, version: &str) -> Option<RemoteManifestReleaseRef<'_>> {
+ if self.version.trim_start_matches('v') == version {
+ return Some(RemoteManifestReleaseRef {
+ protocol: self.protocol,
+ assets: &self.assets,
+ });
+ }
+
+ self.releases.get(version).and_then(|release| {
+ (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef {
+ protocol: release.protocol,
+ assets: &release.assets,
+ })
+ })
+ }
+}
+
+#[derive(Clone, Copy)]
+struct RemoteManifestReleaseRef<'a> {
+ protocol: Option<u32>,
+ assets: &'a BTreeMap<String, RemoteAssetRef>,
+}
+
+fn current_version() -> String {
+ crate::build_info::version()
+}
+
+fn current_channel() -> &'static str {
+ crate::build_info::channel()
+}
+
+struct InstallSource {
+ path: PathBuf,
+ temporary_dir: Option<PathBuf>,
+}
+
+struct RemoteReleaseAsset {
+ url: String,
+ sha256: Option<String>,
+}
+
+struct PreparedRemoteHerdr {
+ remote_herdr: RemoteHerdr,
+ installed_or_replaced: bool,
+ stop_after_install_approved: bool,
+}
+
+impl InstallSource {
+ fn persistent(path: PathBuf) -> Self {
+ Self {
+ path,
+ temporary_dir: None,
+ }
+ }
+
+ fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self {
+ Self {
+ path,
+ temporary_dir: Some(temporary_dir),
+ }
+ }
+
+ fn cleanup(&self) {
+ if let Some(dir) = &self.temporary_dir {
+ let _ = fs::remove_dir_all(dir);
+ }
+ }
+}
+
+fn prepare_remote_herdr(
+ target: &str,
+ live_handoff_enabled: bool,
+) -> io::Result<PreparedRemoteHerdr> {
+ let platform = detect_remote_platform(target)?;
+ let remote_herdr = RemoteHerdr::for_platform(platform);
+ let override_binary = remote_binary_override_path()?;
+ let path_remote_herdr = remote_binary_on_path_any(target, &remote_herdr)?;
+
+ if override_binary.is_none() {
+ if let Some(path_remote_herdr) = path_remote_herdr
+ .as_ref()
+ .filter(|candidate| remote_binary_matches(target, candidate).unwrap_or(false))
+ {
+ return Ok(PreparedRemoteHerdr {
+ remote_herdr: path_remote_herdr.clone(),
+ installed_or_replaced: false,
+ stop_after_install_approved: false,
+ });
+ }
+ if remote_binary_matches(target, &remote_herdr)? {
+ return Ok(PreparedRemoteHerdr {
+ remote_herdr,
+ installed_or_replaced: false,
+ stop_after_install_approved: false,
+ });
+ }
+ }
+
+ let mut stop_after_install_approved = false;
+ if let Some(status_probe_herdr) = path_remote_herdr.as_ref().or_else(|| {
+ remote_binary_exists(target, &remote_herdr)
+ .ok()
+ .and_then(|exists| exists.then_some(&remote_herdr))
+ }) {
+ stop_after_install_approved = confirm_remote_install_with_running_server(
+ target,
+ status_probe_herdr,
+ live_handoff_enabled,
+ )?;
+ }
+ confirm_remote_install(
+ target,
+ &remote_herdr,
+ &install_source_description(&remote_herdr.platform, override_binary.as_deref()),
+ )?;
+ let source = resolve_install_source(&remote_herdr.platform, override_binary)?;
+ let install_result = install_remote_herdr(target, &remote_herdr, &source.path);
+ source.cleanup();
+ install_result?;
+
+ if !remote_binary_matches(target, &remote_herdr)? {
+ return Err(io::Error::other(format!(
+ "installed remote herdr at {}, but it did not report version {}",
+ remote_herdr.shell_path,
+ current_version()
+ )));
+ }
+ warn_if_remote_bin_not_on_path(target)?;
+
+ Ok(PreparedRemoteHerdr {
+ remote_herdr,
+ installed_or_replaced: true,
+ stop_after_install_approved,
+ })
+}
+
+fn detect_remote_platform(target: &str) -> io::Result<RemotePlatform> {
+ let output = ssh_sh_output(target, "uname -s\nuname -m\n")?;
+ if !output.status.success() {
+ return Err(command_failed("remote platform detection failed", &output));
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut lines = stdout.lines();
+ let os = lines.next().unwrap_or_default();
+ let arch = lines.next().unwrap_or_default();
+ RemotePlatform::from_uname(os, arch).ok_or_else(|| {
+ io::Error::other(format!(
+ "unsupported remote platform: {} {}",
+ os.trim(),
+ arch.trim()
+ ))
+ })
+}
+
+fn remote_binary_on_path_any(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+) -> io::Result<Option<RemoteHerdr>> {
+ let output = ssh_user_shell_output(target, "command -v herdr")?;
+ if !output.status.success() {
+ return Ok(None);
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout))
+}
+
+fn remote_herdr_from_path_discovery(
+ remote_herdr: &RemoteHerdr,
+ stdout: &str,
+) -> Option<RemoteHerdr> {
+ let mut lines = stdout.lines();
+ let path = lines.next()?;
+ if !path.starts_with('/') {
+ return None;
+ }
+ Some(remote_herdr.clone().with_shell_path(shell_quote(path)))
+}
+
+fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
+ let command = format!(
+ "test -x {0} && {0} --version && {0} status client --json",
+ remote_herdr.shell_path
+ );
+ let output = ssh_sh_output(target, &command)?;
+ if !output.status.success() {
+ return Ok(false);
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut lines = stdout.lines();
+ let version = lines.next().unwrap_or_default().trim();
+ let status = lines.next().unwrap_or_default();
+ Ok(version == format!("herdr {}", current_version())
+ && parse_client_status_json(status)
+ .map(|status| status.protocol == CURRENT_PROTOCOL)
+ .unwrap_or(false))
+}
+
+fn remote_binary_exists(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
+ let command = format!("test -x {}", remote_herdr.shell_path);
+ Ok(ssh_sh_output(target, &command)?.status.success())
+}
+
+fn remote_binary_override_path() -> io::Result<Option<PathBuf>> {
+ let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else {
+ return Ok(None);
+ };
+ if value.is_empty() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("{REMOTE_BINARY_ENV_VAR} must not be empty"),
+ ));
+ }
+
+ let path = PathBuf::from(value);
+ let metadata = fs::metadata(&path).map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!(
+ "failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}",
+ path.display()
+ ),
+ )
+ })?;
+ if !metadata.is_file() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "{REMOTE_BINARY_ENV_VAR} path is not a file: {}",
+ path.display()
+ ),
+ ));
+ }
+
+ Ok(Some(path))
+}
+
+fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String {
+ install_source_description_for(
+ platform,
+ override_binary,
+ local_binary_can_seed_remote(platform),
+ )
+}
+
+fn install_source_description_for(
+ platform: &RemotePlatform,
+ override_binary: Option<&Path>,
+ local_binary_can_seed_remote: bool,
+) -> String {
+ if let Some(path) = override_binary {
+ return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display());
+ }
+
+ if local_binary_can_seed_remote {
+ "the current local herdr binary".to_string()
+ } else {
+ format!(
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
+ platform.asset_key()
+ )
+ }
+}
+
+fn resolve_install_source(
+ platform: &RemotePlatform,
+ override_binary: Option<PathBuf>,
+) -> io::Result<InstallSource> {
+ if let Some(path) = override_binary {
+ return Ok(InstallSource::persistent(path));
+ }
+
+ if *platform == RemotePlatform::local() {
+ let path = std::env::current_exe()?;
+ if !crate::update::is_package_manager_managed_exe_path(&path) {
+ return Ok(InstallSource::persistent(path));
+ }
+ }
+
+ download_release_asset(platform)
+}
+
+fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
+ if *platform != RemotePlatform::local() {
+ return false;
+ }
+
+ std::env::current_exe()
+ .map(|path| !crate::update::is_package_manager_managed_exe_path(&path))
+ .unwrap_or(false)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum RemoteServerStatus {
+ Running {
+ version: Option<String>,
+ protocol: Option<u32>,
+ live_handoff: bool,
+ },
+ NotRunning,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RemoteServerRestartReason {
+ ProtocolMismatch,
+ BinaryUpdated,
+ VersionMismatch,
+}
+
+fn ensure_remote_server_ready(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ remote_binary_changed: bool,
+ stop_after_install_approved: bool,
+ live_handoff_enabled: bool,
+) -> io::Result<()> {
+ let status = remote_server_status(target, remote_herdr)?;
+ let RemoteServerStatus::Running {
+ version,
+ protocol,
+ live_handoff,
+ } = status
+ else {
+ return Ok(());
+ };
+
+ let Some(reason) =
+ remote_server_restart_reason(version.as_deref(), protocol, remote_binary_changed)
+ else {
+ return Ok(());
+ };
+
+ if live_handoff_enabled && live_handoff {
+ match live_handoff_remote_server(target, remote_herdr) {
+ Ok(()) => return Ok(()),
+ Err(err) => {
+ eprintln!("remote live handoff failed: {err}");
+ eprintln!("falling back to remote server restart.");
+ }
+ }
+ }
+
+ if stop_after_install_approved {
+ stop_remote_server(target, remote_herdr)?;
+ return Ok(());
+ }
+
+ if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? {
+ stop_remote_server(target, remote_herdr)?;
+ }
+ Ok(())
+}
+
+fn remote_server_restart_reason(
+ version: Option<&str>,
+ protocol: Option<u32>,
+ remote_binary_changed: bool,
+) -> Option<RemoteServerRestartReason> {
+ if protocol != Some(CURRENT_PROTOCOL) {
+ return Some(RemoteServerRestartReason::ProtocolMismatch);
+ }
+ if remote_binary_changed {
+ return Some(RemoteServerRestartReason::BinaryUpdated);
+ }
+ if version != Some(current_version().as_str()) {
+ return Some(RemoteServerRestartReason::VersionMismatch);
+ }
+ None
+}
+
+fn confirm_remote_install_with_running_server(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ live_handoff_enabled: bool,
+) -> io::Result<bool> {
+ let status = match remote_server_status(target, remote_herdr) {
+ Ok(status) => status,
+ Err(err) => {
+ if !io::stdin().is_terminal() {
+ return Err(io::Error::other(format!(
+ "could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary"
+ )));
+ }
+ eprintln!(
+ "could not inspect the running remote herdr server on {target} before installing: {err}"
+ );
+ eprint!("continue installing the remote herdr binary? [y/N] ");
+ io::stderr().flush()?;
+
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ let answer = answer.trim().to_ascii_lowercase();
+ if answer != "y" && answer != "yes" {
+ return Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote herdr install cancelled",
+ ));
+ }
+ return Ok(false);
+ }
+ };
+ let RemoteServerStatus::Running {
+ version,
+ protocol: _,
+ live_handoff,
+ } = status
+ else {
+ return Ok(false);
+ };
+ if !io::stdin().is_terminal() {
+ if live_handoff_enabled && live_handoff {
+ return Ok(false);
+ }
+ return Err(io::Error::other(format!(
+ "remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update",
+ version_label(version.as_deref())
+ )));
+ }
+
+ if live_handoff_enabled && live_handoff {
+ eprintln!("remote herdr server on {target} is currently running:");
+ eprintln!(" server: v{}", version_label(version.as_deref()));
+ eprintln!(
+ "Herdr will install {} and hand off live pane processes to the prepared server.",
+ current_version()
+ );
+ return Ok(false);
+ }
+
+ eprintln!("remote herdr server on {target} is currently running:");
+ eprintln!(" server: v{}", version_label(version.as_deref()));
+ eprintln!(
+ "To complete the remote update, Herdr must stop the running remote server after installing."
+ );
+ eprintln!("This stops active remote pane processes, including shells, dev servers, and tests.");
+ eprintln!();
+ eprint!(
+ "Install {} and stop the remote server now? [y/N] ",
+ current_version()
+ );
+ io::stderr().flush()?;
+
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ let answer = answer.trim().to_ascii_lowercase();
+ if answer != "y" && answer != "yes" {
+ return Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote herdr install cancelled",
+ ));
+ }
+
+ Ok(true)
+}
+
+fn remote_server_status(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+) -> io::Result<RemoteServerStatus> {
+ let command = format!("{} status server --json", remote_herdr.shell_path);
+ let output = ssh_sh_output(target, &command)?;
+ if !output.status.success() {
+ return Err(command_failed("remote server status failed", &output));
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ parse_remote_server_status_json(stdout.trim())
+}
+
+#[derive(Debug, Deserialize)]
+struct RemoteClientStatusJson {
+ protocol: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct RemoteServerStatusJson {
+ running: bool,
+ version: Option<String>,
+ protocol: Option<u32>,
+ capabilities: Option<RemoteServerCapabilitiesJson>,
+}
+
+#[derive(Debug, Deserialize)]
+struct RemoteServerCapabilitiesJson {
+ live_handoff: bool,
+}
+
+fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
+ serde_json::from_str(status).ok()
+}
+
+fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatus> {
+ let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| {
+ io::Error::other(format!(
+ "could not parse remote server status JSON from `{status}`: {err}"
+ ))
+ })?;
+ if !parsed.running {
+ return Ok(RemoteServerStatus::NotRunning);
+ }
+
+ Ok(RemoteServerStatus::Running {
+ version: parsed.version,
+ protocol: parsed.protocol,
+ live_handoff: parsed
+ .capabilities
+ .is_some_and(|capabilities| capabilities.live_handoff),
+ })
+}
+
+fn confirm_remote_server_stop(
+ target: &str,
+ version: Option<&str>,
+ _protocol: Option<u32>,
+ reason: RemoteServerRestartReason,
+) -> io::Result<bool> {
+ if !io::stdin().is_terminal() {
+ if reason == RemoteServerRestartReason::ProtocolMismatch {
+ return Err(io::Error::other(format!(
+ "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it"
+ )));
+ }
+
+ eprintln!(
+ "remote herdr server on {target} is still running v{}; it will use {} after it restarts.",
+ version_label(version),
+ current_version()
+ );
+ return Ok(false);
+ }
+
+ eprintln!("remote herdr server on {target} is currently running:");
+ eprintln!(" server: v{}", version_label(version));
+ eprintln!(" prepared binary: {}", current_version());
+ eprintln!();
+
+ match reason {
+ RemoteServerRestartReason::ProtocolMismatch => {
+ eprintln!("the remote server must stop before this client can attach.");
+ }
+ RemoteServerRestartReason::BinaryUpdated => {
+ eprintln!(
+ "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary."
+ );
+ }
+ RemoteServerRestartReason::VersionMismatch => {
+ eprintln!(
+ "the remote server is still running a different herdr version. restart it so it uses the prepared binary."
+ );
+ }
+ }
+
+ let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch {
+ "stop the remote server and continue attaching? [Y/n] "
+ } else {
+ "restart the remote server now? [y/N] "
+ };
+ eprint!("{prompt}");
+ io::stderr().flush()?;
+
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ let answer = answer.trim().to_ascii_lowercase();
+ if answer == "y" || answer == "yes" {
+ return Ok(true);
+ }
+ if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch {
+ return Ok(true);
+ }
+ if reason == RemoteServerRestartReason::ProtocolMismatch {
+ return Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote herdr server stop cancelled",
+ ));
+ }
+
+ Ok(false)
+}
+
+fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+ let command = format!(
+ "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
+ remote_herdr.shell_path,
+ remote_herdr.shell_path,
+ CURRENT_PROTOCOL,
+ current_version()
+ );
+ let output = ssh_sh_output(target, &command)?;
+ if !output.status.success() {
+ return Err(command_failed("remote server live handoff failed", &output));
+ }
+
+ eprintln!(
+ "handed off the remote herdr server on {target}; reconnecting to the prepared server."
+ );
+ Ok(())
+}
+
+fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+ let command = format!("{} server stop", remote_herdr.shell_path);
+ let output = ssh_sh_output(target, &command)?;
+ if !output.status.success() {
+ return Err(command_failed("remote server stop failed", &output));
+ }
+
+ wait_for_remote_server_shutdown(target, remote_herdr)?;
+ eprintln!("stopped the remote herdr server on {target}; it will restart when the remote client bridge attaches.");
+ Ok(())
+}
+
+fn wait_for_remote_server_shutdown(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+ let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT;
+ loop {
+ if remote_server_status(target, remote_herdr)? == RemoteServerStatus::NotRunning {
+ return Ok(());
+ }
+ if Instant::now() >= deadline {
+ return Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ format!(
+ "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds",
+ REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs()
+ ),
+ ));
+ }
+ thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL);
+ }
+}
+
+fn version_label(version: Option<&str>) -> &str {
+ version.unwrap_or("unknown")
+}
+
+fn warn_if_remote_bin_not_on_path(target: &str) -> io::Result<()> {
+ let output = ssh_user_shell_output(target, "command -v herdr")?;
+ if output.status.success()
+ && remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout))
+ {
+ return Ok(());
+ }
+
+ eprintln!(
+ "herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path"
+ );
+ Ok(())
+}
+
+fn remote_shell_resolves_managed_install(stdout: &str) -> bool {
+ stdout
+ .lines()
+ .next()
+ .map(str::trim)
+ .is_some_and(|path| path.ends_with("/.local/bin/herdr"))
+}
+
+fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource> {
+ let asset_key = platform.asset_key();
+ let asset = remote_release_asset(&asset_key)?;
+
+ let dir = private_download_dir(&asset_key)?;
+ let path = dir.join("herdr.tmp");
+ let status = Command::new("curl")
+ .args(["-sfL", "--max-time", "120", "-o"])
+ .arg(&path)
+ .arg(&asset.url)
+ .status()
+ .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
+ if !status.success() {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::other("download failed"));
+ }
+ if let Some(expected) = &asset.sha256 {
+ if let Err(err) = crate::checksum::verify_sha256(&path, expected) {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::new(
+ err.kind(),
+ format!("downloaded remote asset checksum verification failed: {err}"),
+ ));
+ }
+ }
+
+ Ok(InstallSource::temporary(path, dir))
+}
+
+fn fetch_remote_manifest(url: &str) -> io::Result<Vec<u8>> {
+ let output = Command::new("curl")
+ .args([
+ "-sfL",
+ "--retry",
+ "3",
+ "--connect-timeout",
+ "10",
+ "--max-time",
+ "20",
+ url,
+ ])
+ .output()
+ .map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?;
+ if !output.status.success() {
+ return Err(command_failed("failed to fetch update manifest", &output));
+ }
+ Ok(output.stdout)
+}
+
+fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset {
+ RemoteReleaseAsset {
+ url: asset.url().to_string(),
+ sha256: asset.sha256().map(str::to_string),
+ }
+}
+
+fn preview_assets_for_build<'a>(
+ manifest: &'a RemotePreviewManifest,
+ build_id: &str,
+) -> io::Result<(u32, &'a BTreeMap<String, RemoteAssetRef>)> {
+ if manifest.build_id == build_id {
+ return Ok((manifest.protocol, &manifest.assets));
+ }
+ let build = manifest.builds.get(build_id).ok_or_else(|| {
+ io::Error::other(format!(
+ "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr"
+ ))
+ })?;
+ Ok((build.protocol, &build.assets))
+}
+
+fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
+ if crate::build_info::is_preview() {
+ let build_id = crate::build_info::build_id().ok_or_else(|| {
+ io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually")
+ })?;
+ let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?;
+ let manifest: RemotePreviewManifest =
+ serde_json::from_slice(&manifest_bytes).map_err(|err| {
+ io::Error::other(format!("failed to parse preview manifest JSON: {err}"))
+ })?;
+ let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?;
+ if protocol != CURRENT_PROTOCOL {
+ return Err(io::Error::other(format!(
+ "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually"
+ )));
+ }
+ return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the preview manifest for build {build_id}"
+ ))
+ });
+ }
+
+ let current_version = current_version();
+ let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?;
+ let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes)
+ .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
+ let release = manifest.release_for_version(&current_version).ok_or_else(|| {
+ io::Error::other(format!(
+ "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually",
+ asset_key
+ ))
+ })?;
+ if let Some(protocol) = release.protocol {
+ if protocol != CURRENT_PROTOCOL {
+ return Err(io::Error::other(format!(
+ "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
+ )));
+ }
+ }
+ release
+ .assets
+ .get(asset_key)
+ .map(remote_asset_info)
+ .ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the release manifest for herdr {current_version}"
+ ))
+ })
+}
+
+fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
+ let base = std::env::temp_dir();
+ for attempt in 0..100 {
+ let dir = base.join(format!(
+ "herdr-remote-{}-{}-{attempt}",
+ std::process::id(),
+ asset_key
+ ));
+ match fs::create_dir(&dir) {
+ Ok(()) => return Ok(dir),
+ Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(err) => return Err(err),
+ }
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "failed to create private herdr remote download directory",
+ ))
+}
+
+fn confirm_remote_install(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ source_description: &str,
+) -> io::Result<()> {
+ if !io::stdin().is_terminal() {
+ return Err(io::Error::other(format!(
+ "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation",
+ current_version(),
+ remote_herdr.shell_path
+ )));
+ }
+
+ eprintln!(
+ "matching herdr {} is not installed on {target} for {}.",
+ current_version(),
+ remote_herdr.platform.asset_key()
+ );
+ eprint!(
+ "Install {} to {}? [Y/n] ",
+ source_description, remote_herdr.shell_path
+ );
+ io::stderr().flush()?;
+
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ let answer = answer.trim().to_ascii_lowercase();
+ if answer == "n" || answer == "no" {
+ return Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote herdr installation cancelled",
+ ));
+ }
+
+ Ok(())
+}
+
+fn install_remote_herdr(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ source_path: &Path,
+) -> io::Result<()> {
+ let script = format!(
+ r#"dest="$HOME/{install_suffix}"
+dir="${{dest%/*}}"
+mkdir -p "$dir"
+tmp="${{dest}}.tmp.$$"
+cat > "$tmp"
+chmod 755 "$tmp"
+mv "$tmp" "$dest"
+"#,
+ install_suffix = remote_herdr.install_suffix
+ );
+
+ let mut child = Command::new("ssh")
+ .arg("-T")
+ .arg(target)
+ .arg(format!("/bin/sh -eu -c {}", shell_quote(&script)))
+ .stdin(Stdio::piped())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh install: {err}")))?;
+
+ let mut source = File::open(source_path)?;
+ let copy_result = if let Some(mut stdin) = child.stdin.take() {
+ io::copy(&mut source, &mut stdin).map(|_| ())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ "ssh install stdin missing",
+ ))
+ };
+ let status = child.wait()?;
+ copy_result?;
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::other(format!(
+ "remote install exited with {status}"
+ )))
+ }
+}
+
+fn ssh_sh_output(target: &str, script: &str) -> io::Result<Output> {
+ // Feed POSIX bootstrap scripts to /bin/sh so the user's login shell only
+ // has to parse a simple executable invocation.
+ let mut child = Command::new("ssh")
+ .arg("-T")
+ .arg(target)
+ .arg("/bin/sh -s")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()?;
+
+ let write_result = if let Some(mut stdin) = child.stdin.take() {
+ stdin.write_all(script.as_bytes())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ "ssh bootstrap stdin missing",
+ ))
+ };
+ let output = child.wait_with_output()?;
+ write_result?;
+ Ok(output)
+}
+
+fn ssh_user_shell_output(target: &str, command: &str) -> io::Result<Output> {
+ Command::new("ssh")
+ .arg("-T")
+ .arg(target)
+ .arg(command)
+ .output()
+}
+
+fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String {
+ let mut command = format!("exec {}", remote_herdr.shell_path);
+ if session_name != crate::session::DEFAULT_SESSION_NAME {
+ command.push_str(" --session ");
+ command.push_str(&shell_quote(session_name));
+ }
+ command.push_str(" remote-client-bridge");
+ command
+}
+
+fn reattach_command(
+ program: &str,
+ target: &str,
+ session_name: &str,
+ keybindings: RemoteKeybindings,
+ live_handoff: bool,
+) -> String {
+ let program = if program.is_empty() { "herdr" } else { program };
+ let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target));
+ if keybindings != RemoteKeybindings::Local {
+ command.push_str(" --remote-keybindings ");
+ command.push_str(keybindings.as_str());
+ }
+ if live_handoff {
+ command.push_str(" --handoff");
+ }
+ if session_name != crate::session::DEFAULT_SESSION_NAME {
+ command.push_str(" --session ");
+ command.push_str(&shell_quote(session_name));
+ }
+ command
+}
+
+fn shell_quote(value: &str) -> String {
+ if !value.is_empty()
+ && value.chars().all(|ch| {
+ ch.is_ascii_alphanumeric()
+ || matches!(
+ ch,
+ '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-'
+ )
+ })
+ {
+ return value.to_string();
+ }
+
+ format!("'{}'", value.replace('\'', "'\\''"))
+}
+
+fn command_failed(context: &str, output: &Output) -> io::Error {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let stderr = stderr.trim();
+ if stderr.is_empty() {
+ io::Error::other(format!("{context}: {}", output.status))
+ } else {
+ io::Error::other(format!("{context}: {stderr}"))
+ }
+}
+
+struct SshStdioBridge {
+ local_socket: PathBuf,
+ keepalive_ssh_config: Option<PathBuf>,
+ should_stop: Arc<AtomicBool>,
+ thread: Option<JoinHandle<()>>,
+}
+
+impl SshStdioBridge {
+ fn start(
+ target: String,
+ remote_herdr: RemoteHerdr,
+ local_socket: PathBuf,
+ session_name: String,
+ manage_ssh_config: bool,
+ ) -> io::Result<Self> {
+ let _ = std::fs::remove_file(&local_socket);
+ let listener = UnixListener::bind(&local_socket)?;
+ crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?;
+ listener.set_nonblocking(true)?;
+
+ let keepalive_ssh_config = if manage_ssh_config {
+ write_keepalive_ssh_config()
+ .inspect_err(|err| {
+ tracing::debug!(%err, "could not write ssh keepalive config; using plain ssh");
+ })
+ .ok()
+ } else {
+ None
+ };
+
+ let should_stop = Arc::new(AtomicBool::new(false));
+ let thread_stop = Arc::clone(&should_stop);
+ let thread_ssh_config = keepalive_ssh_config.clone();
+ let thread = thread::spawn(move || {
+ while !thread_stop.load(Ordering::Acquire) {
+ match listener.accept() {
+ Ok((stream, _addr)) => {
+ if let Err(err) = stream.set_nonblocking(false) {
+ eprintln!(
+ "herdr: remote bridge failed to prepare client socket: {err}"
+ );
+ continue;
+ }
+ if let Err(err) = bridge_connection(
+ stream,
+ &target,
+ &remote_herdr,
+ &session_name,
+ thread_ssh_config.as_deref(),
+ ) {
+ eprintln!("herdr: remote bridge failed: {err}");
+ }
+ }
+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+ thread::sleep(BRIDGE_ACCEPT_POLL);
+ }
+ Err(err) => {
+ eprintln!("herdr: remote bridge listener failed: {err}");
+ break;
+ }
+ }
+ }
+ });
+
+ Ok(Self {
+ local_socket,
+ keepalive_ssh_config,
+ should_stop,
+ thread: Some(thread),
+ })
+ }
+}
+
+impl Drop for SshStdioBridge {
+ fn drop(&mut self) {
+ self.should_stop.store(true, Ordering::Release);
+ let _ = std::fs::remove_file(&self.local_socket);
+ if let Some(thread) = self.thread.take() {
+ let _ = thread.join();
+ }
+ // Remove the generated ssh config only after the bridge thread has
+ // joined, so it can never start a connection with a config path that
+ // was just deleted.
+ if let Some(dir) = self.keepalive_ssh_config.as_deref().and_then(Path::parent) {
+ let _ = std::fs::remove_dir_all(dir);
+ }
+ }
+}
+
+/// Creates a fresh user-only (`0700`) directory under the temp dir for the
+/// bridge's generated ssh config, returning its path.
+///
+/// Using a private directory created with fail-if-exists semantics โ€” rather
+/// than a predictable file in the world-writable temp dir โ€” stops a local user
+/// from pre-planting a symlink or world-writable file that herdr would write
+/// and `ssh -F` would then read.
+fn private_ssh_config_dir() -> io::Result<PathBuf> {
+ use std::os::unix::fs::DirBuilderExt;
+
+ let base = std::env::temp_dir();
+ for attempt in 0..100 {
+ let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id()));
+ match fs::DirBuilder::new().mode(0o700).create(&dir) {
+ Ok(()) => return Ok(dir),
+ Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(err) => return Err(err),
+ }
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "failed to create private herdr ssh config directory",
+ ))
+}
+
+/// Quotes a path for an ssh_config `Include` so a path containing spaces (or
+/// glob metacharacters) is treated as one literal token instead of being split
+/// or expanded by ssh โ€” otherwise the user's config might not be Included and
+/// herdr's fallback would wrongly take effect.
+fn ssh_config_quote(path: &str) -> String {
+ format!("\"{path}\"")
+}
+
+/// Builds a temporary ssh config that keeps the bridge tunnel alive without
+/// overriding the user's own settings, returning its path.
+///
+/// The file `Include`s the user's real ssh config first, so ssh's
+/// first-value-wins rule keeps any `ServerAlive*` the user set there (including
+/// an explicit `0` to disable it); herdr's values apply only when the user has
+/// none.
+fn write_keepalive_ssh_config() -> io::Result<PathBuf> {
+ use std::os::unix::fs::OpenOptionsExt;
+
+ let path = private_ssh_config_dir()?.join("config");
+
+ let mut contents = String::new();
+ if let Some(home) = std::env::var_os("HOME") {
+ let user_config = PathBuf::from(home).join(".ssh").join("config");
+ if user_config.is_file() {
+ contents.push_str(&format!(
+ "Include {}\n",
+ ssh_config_quote(&user_config.to_string_lossy())
+ ));
+ }
+ }
+ if Path::new("/etc/ssh/ssh_config").is_file() {
+ contents.push_str("Include /etc/ssh/ssh_config\n");
+ }
+ contents.push_str("Host *\n");
+ contents.push_str(" ServerAliveInterval 15\n");
+ contents.push_str(" ServerAliveCountMax 4\n");
+
+ let mut file = fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(BRIDGE_SOCKET_PERMISSION_MODE)
+ .open(&path)?;
+ file.write_all(contents.as_bytes())?;
+ Ok(path)
+}
+
+fn bridge_connection(
+ stream: UnixStream,
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ session_name: &str,
+ keepalive_ssh_config: Option<&Path>,
+) -> io::Result<()> {
+ let mut command = Command::new("ssh");
+ // Use the generated keepalive ssh config when present; otherwise plain ssh.
+ if let Some(ssh_config) = keepalive_ssh_config {
+ command.arg("-F").arg(ssh_config);
+ }
+ command
+ .arg("-T")
+ .arg(target)
+ .arg(remote_bridge_command(remote_herdr, session_name));
+ command
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::inherit());
+
+ let mut child = command
+ .spawn()
+ .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?;
+ let mut child_stdin = child
+ .stdin
+ .take()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?;
+ let mut child_stdout = child
+ .stdout
+ .take()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?;
+ let mut stream_to_child = stream.try_clone()?;
+ let mut child_to_stream = stream;
+
+ let upload = thread::spawn(move || {
+ let _ = copy_flush(&mut stream_to_child, &mut child_stdin);
+ });
+ let download = thread::spawn(move || {
+ let _ = copy_flush(&mut child_stdout, &mut child_to_stream);
+ let _ = child_to_stream.shutdown(std::net::Shutdown::Write);
+ });
+
+ let status = child.wait()?;
+ let _ = upload.join();
+ let _ = download.join();
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ format!("ssh bridge exited with {status}"),
+ ))
+ }
+}
+
+fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
+ let mut buffer = [0_u8; 16 * 1024];
+ let mut total = 0;
+
+ loop {
+ let bytes_read = match reader.read(&mut buffer) {
+ Ok(0) => return Ok(total),
+ Ok(bytes_read) => bytes_read,
+ Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
+ Err(err) => return Err(err),
+ };
+
+ writer.write_all(&buffer[..bytes_read])?;
+ writer.flush()?;
+ total += bytes_read as u64;
+ }
+}
+
+fn run_client_process(
+ local_socket: &Path,
+ reattach_command: &str,
+ keybindings: RemoteKeybindings,
+) -> io::Result<()> {
+ let exe = std::env::current_exe()?;
+ let status = Command::new(exe)
+ .arg("client")
+ .env(
+ crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR,
+ local_socket,
+ )
+ .env("HERDR_RENDER_ENCODING", "terminal-ansi")
+ .env(REATTACH_COMMAND_ENV_VAR, reattach_command)
+ .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str())
+ .env_remove(crate::api::SOCKET_PATH_ENV_VAR)
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .status()?;
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ format!("remote client exited with {status}"),
+ ))
+ }
+}
+
+fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf {
+ let pid = std::process::id();
+ let target_clean = sanitize_path_component(target);
+ let session_clean = sanitize_path_component(session_name);
+
+ let tmpdir = std::env::temp_dir();
+ let readable = tmpdir.join(format!(
+ "herdr-remote-{pid}-{target_clean}-{session_clean}.sock"
+ ));
+ if fits_unix_socket_path(&readable) {
+ return readable;
+ }
+
+ // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the
+ // readable name past sun_path's 104-byte ceiling. Fall back to a hashed
+ // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself
+ // is longer than the budget. The hash covers the full unsanitized
+ // target/session so uniqueness does not depend on the prefix truncation;
+ // the prefix is kept only for debuggability.
+ let target_prefix: String = target_clean.chars().take(8).collect();
+ let hash = short_socket_hash(target, session_name);
+ let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock");
+ let short_in_tmp = tmpdir.join(&short_name);
+ if fits_unix_socket_path(&short_in_tmp) {
+ return short_in_tmp;
+ }
+ PathBuf::from("/tmp").join(short_name)
+}
+
+fn fits_unix_socket_path(path: &Path) -> bool {
+ use std::os::unix::ffi::OsStrExt;
+ // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve
+ // 1 byte for the trailing NUL and use the smaller cap for portability.
+ const MAX: usize = 103;
+ path.as_os_str().as_bytes().len() <= MAX
+}
+
+fn short_socket_hash(target: &str, session: &str) -> String {
+ use std::collections::hash_map::DefaultHasher;
+ use std::hash::{Hash, Hasher};
+ let mut hasher = DefaultHasher::new();
+ target.hash(&mut hasher);
+ 0u8.hash(&mut hasher);
+ session.hash(&mut hasher);
+ format!("{:016x}", hasher.finish())
+}
+
+fn sanitize_path_component(input: &str) -> String {
+ let sanitized: String = input
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
+ ch
+ } else {
+ '-'
+ }
+ })
+ .collect();
+
+ sanitized.trim_matches('-').chars().take(32).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bridge_socket_is_user_only() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let socket = std::env::temp_dir().join(format!(
+ "herdr-bridge-permissions-test-{}.sock",
+ std::process::id()
+ ));
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let bridge = SshStdioBridge::start(
+ "example".to_string(),
+ remote_herdr,
+ socket.clone(),
+ "default".to_string(),
+ false,
+ )
+ .expect("start bridge listener");
+
+ let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
+ assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE);
+
+ drop(bridge);
+ let _ = std::fs::remove_file(socket);
+ }
+
+ #[test]
+ fn keepalive_ssh_config_includes_user_config_then_fallback() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let path = write_keepalive_ssh_config().expect("write keepalive config");
+ let contents = std::fs::read_to_string(&path).expect("read keepalive config");
+
+ // herdr's fallback keepalive is present...
+ assert!(
+ contents.contains("Host *"),
+ "config should add a Host * fallback block: {contents}"
+ );
+ assert!(
+ contents.contains("ServerAliveInterval 15"),
+ "config should set the keepalive interval: {contents}"
+ );
+ assert!(
+ contents.contains("ServerAliveCountMax 4"),
+ "config should set the keepalive count: {contents}"
+ );
+ // ...and any user config is Included (quoted) BEFORE it so first-value-wins
+ // keeps the user's own settings.
+ if let Some(home) = std::env::var_os("HOME") {
+ let user_config = PathBuf::from(home).join(".ssh").join("config");
+ if user_config.is_file() {
+ let include = format!(
+ "Include {}",
+ ssh_config_quote(&user_config.to_string_lossy())
+ );
+ let include_at = contents.find(&include).expect("user config Included");
+ let fallback_at = contents.find("Host *").expect("fallback present");
+ assert!(
+ include_at < fallback_at,
+ "user config must be Included before herdr's fallback: {contents}"
+ );
+ }
+ }
+
+ let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
+ assert_eq!(
+ mode, BRIDGE_SOCKET_PERMISSION_MODE,
+ "keepalive config must be user-only"
+ );
+ // The config lives in a private 0700 dir, not a predictable temp path.
+ let dir = path.parent().expect("config has a parent dir");
+ let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
+ assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only");
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn ssh_config_quote_wraps_path_with_spaces() {
+ assert_eq!(
+ ssh_config_quote("/home/a b/.ssh/config"),
+ "\"/home/a b/.ssh/config\""
+ );
+ }
+
+ #[test]
+ fn extract_remote_args_removes_space_form() {
+ let args = vec![
+ "herdr".into(),
+ "--remote".into(),
+ "dev".into(),
+ "--help".into(),
+ ];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr", "--help"]);
+ let remote = remote.unwrap();
+ assert_eq!(remote.target, "dev");
+ assert_eq!(remote.keybindings, RemoteKeybindings::Local);
+ }
+
+ #[test]
+ fn extract_remote_args_removes_equals_form() {
+ let args = vec!["herdr".into(), "--remote=user@host".into()];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr"]);
+ let remote = remote.unwrap();
+ assert_eq!(remote.target, "user@host");
+ assert_eq!(remote.keybindings, RemoteKeybindings::Local);
+ }
+
+ #[test]
+ fn extract_remote_args_accepts_remote_keybindings_server() {
+ let args = vec![
+ "herdr".into(),
+ "--remote".into(),
+ "dev".into(),
+ "--remote-keybindings=server".into(),
+ ];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr"]);
+ let remote = remote.unwrap();
+ assert_eq!(remote.target, "dev");
+ assert_eq!(remote.keybindings, RemoteKeybindings::Server);
+ }
+
+ #[test]
+ fn extract_remote_args_accepts_remote_keybindings_space_form() {
+ let args = vec![
+ "herdr".into(),
+ "--remote=dev".into(),
+ "--remote-keybindings".into(),
+ "server".into(),
+ ];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr"]);
+ assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server);
+ }
+
+ #[test]
+ fn extract_remote_args_accepts_explicit_handoff() {
+ let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()];
+
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+
+ assert_eq!(cleaned, vec!["herdr"]);
+ let remote = remote.unwrap();
+ assert_eq!(remote.target, "dev");
+ assert!(remote.live_handoff);
+ }
+
+ #[test]
+ fn extract_remote_args_preserves_child_remote_options_after_separator() {
+ let args = vec![
+ "herdr".into(),
+ "agent".into(),
+ "start".into(),
+ "repro".into(),
+ "--".into(),
+ "child".into(),
+ "--remote".into(),
+ "dev".into(),
+ "--remote-keybindings=server".into(),
+ "--handoff".into(),
+ ];
+
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+
+ assert_eq!(cleaned, args);
+ assert!(remote.is_none());
+ }
+
+ #[test]
+ fn extract_remote_args_preserves_handoff_without_remote() {
+ let args = vec!["herdr".into(), "update".into(), "--handoff".into()];
+
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+
+ assert_eq!(cleaned, args);
+ assert!(remote.is_none());
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_remote_keybindings_without_remote() {
+ let args = vec!["herdr".into(), "--remote-keybindings=server".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote-keybindings requires --remote");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_duplicate_remote_keybindings() {
+ let args = vec![
+ "herdr".into(),
+ "--remote=dev".into(),
+ "--remote-keybindings=local".into(),
+ "--remote-keybindings=server".into(),
+ ];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote-keybindings can only be specified once");
+ }
+
+ #[test]
+ fn extract_remote_args_requires_value() {
+ let args = vec!["herdr".into(), "--remote".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "missing value for --remote");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_empty_value() {
+ let args = vec!["herdr".into(), "--remote=".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "missing value for --remote");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_duplicate_values() {
+ let args = vec![
+ "herdr".into(),
+ "--remote=dev".into(),
+ "--remote=prod".into(),
+ ];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote can only be specified once");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_option_like_target() {
+ let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote target must not start with '-'");
+ }
+
+ #[test]
+ fn sanitize_path_component_removes_shell_sensitive_chars() {
+ assert_eq!(sanitize_path_component("user@host:22"), "user-host-22");
+ }
+
+ #[test]
+ fn remote_platform_maps_uname_values() {
+ assert_eq!(
+ RemotePlatform::from_uname("Linux", "amd64")
+ .unwrap()
+ .asset_key(),
+ "linux-x86_64"
+ );
+ assert_eq!(
+ RemotePlatform::from_uname("Darwin", "arm64")
+ .unwrap()
+ .asset_key(),
+ "macos-aarch64"
+ );
+ assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none());
+ }
+
+ #[test]
+ fn reattach_command_includes_remote_and_session() {
+ assert_eq!(
+ reattach_command(
+ "target/release/herdr",
+ "user@host",
+ "work",
+ RemoteKeybindings::Local,
+ false,
+ ),
+ "target/release/herdr --remote user@host --session work"
+ );
+ assert_eq!(
+ reattach_command(
+ "herdr",
+ "host name",
+ crate::session::DEFAULT_SESSION_NAME,
+ RemoteKeybindings::Local,
+ false,
+ ),
+ "herdr --remote 'host name'"
+ );
+ assert_eq!(
+ reattach_command(
+ "herdr",
+ "host",
+ crate::session::DEFAULT_SESSION_NAME,
+ RemoteKeybindings::Server,
+ false,
+ ),
+ "herdr --remote host --remote-keybindings server"
+ );
+ assert_eq!(
+ reattach_command(
+ "herdr",
+ "host",
+ crate::session::DEFAULT_SESSION_NAME,
+ RemoteKeybindings::Local,
+ true,
+ ),
+ "herdr --remote host --handoff"
+ );
+ }
+
+ #[test]
+ fn remote_bridge_command_uses_installed_binary() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ assert_eq!(
+ remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
+ "exec \"$HOME/.local/bin/herdr\" remote-client-bridge"
+ );
+ }
+
+ #[test]
+ fn remote_path_discovery_uses_path_binary() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n")
+ .expect("path binary");
+
+ assert_eq!(
+ remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
+ "exec /usr/bin/herdr remote-client-bridge"
+ );
+ }
+
+ #[test]
+ fn remote_path_discovery_quotes_discovered_binary() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let remote_herdr =
+ remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n")
+ .expect("path binary");
+
+ assert_eq!(
+ remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
+ "exec '/opt/herdr bin/herdr' remote-client-bridge"
+ );
+ }
+
+ #[test]
+ fn remote_path_discovery_uses_macos_path_binary() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "macos",
+ arch: "aarch64",
+ });
+ let remote_herdr =
+ remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n")
+ .expect("path binary");
+
+ assert_eq!(
+ remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
+ "exec /opt/homebrew/bin/herdr remote-client-bridge"
+ );
+ assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64");
+ }
+
+ #[test]
+ fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let remote_herdr =
+ remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n")
+ .expect("path binary");
+
+ assert_eq!(
+ remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
+ "exec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge"
+ );
+ }
+
+ #[test]
+ fn remote_path_discovery_ignores_relative_paths() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n");
+
+ assert!(remote_herdr.is_none());
+ }
+
+ #[test]
+ fn remote_path_discovery_ignores_empty_output() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n");
+
+ assert!(remote_herdr.is_none());
+ }
+
+ #[test]
+ fn remote_shell_path_warning_accepts_managed_install() {
+ assert!(remote_shell_resolves_managed_install(
+ "/home/can/.local/bin/herdr\n"
+ ));
+ assert!(remote_shell_resolves_managed_install(
+ "/Users/can/.local/bin/herdr\n"
+ ));
+ assert!(!remote_shell_resolves_managed_install(
+ "/usr/local/bin/herdr\n"
+ ));
+ assert!(!remote_shell_resolves_managed_install(""));
+ }
+
+ #[test]
+ fn parse_client_status_json_reads_protocol() {
+ assert_eq!(
+ parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#)
+ .map(|status| status.protocol),
+ Some(8)
+ );
+ assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none());
+ }
+
+ #[test]
+ fn parse_remote_server_status_json_reads_running_server() {
+ assert_eq!(
+ parse_remote_server_status_json(
+ r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true}}"#
+ )
+ .unwrap(),
+ RemoteServerStatus::Running {
+ version: Some("0.6.0".into()),
+ protocol: Some(8),
+ live_handoff: true
+ }
+ );
+ }
+
+ #[test]
+ fn parse_remote_server_status_json_treats_missing_capability_as_no_handoff() {
+ assert_eq!(
+ parse_remote_server_status_json(
+ r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"#
+ )
+ .unwrap(),
+ RemoteServerStatus::Running {
+ version: Some("0.6.0".into()),
+ protocol: Some(8),
+ live_handoff: false
+ }
+ );
+ }
+
+ #[test]
+ fn parse_remote_server_status_json_reads_stopped_server() {
+ assert_eq!(
+ parse_remote_server_status_json(
+ r#"{"status":"not_running","running":false,"version":null,"protocol":null}"#
+ )
+ .unwrap(),
+ RemoteServerStatus::NotRunning
+ );
+ }
+
+ #[test]
+ fn remote_update_manifest_uses_root_assets_for_latest_version() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.3",
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.assets.get("linux-x86_64"))
+ .map(RemoteAssetRef::url),
+ Some("https://example.com/latest")
+ );
+ }
+
+ #[test]
+ fn remote_update_manifest_reads_archived_release_assets() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.4",
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "notes": "ignored",
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.assets.get("linux-x86_64"))
+ .map(RemoteAssetRef::url),
+ Some("https://example.com/archive")
+ );
+ }
+
+ #[test]
+ fn remote_update_manifest_uses_archived_release_protocol() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.4",
+ "protocol": 42,
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "notes": "ignored",
+ "protocol": 41,
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.protocol),
+ Some(41)
+ );
+ }
+
+ #[test]
+ fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.4",
+ "protocol": 42,
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "notes": "ignored",
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.protocol),
+ None
+ );
+ }
+
+ #[test]
+ fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() {
+ let manifest: RemotePreviewManifest = serde_json::from_str(
+ r#"{
+ "build_id": "2026-06-06-new",
+ "protocol": 12,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/new",
+ "sha256": "new"
+ }
+ },
+ "builds": {
+ "2026-06-02-old": {
+ "protocol": 11,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/old",
+ "sha256": "old"
+ }
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ let (protocol, assets) =
+ preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build");
+ let asset = assets.get("linux-x86_64").expect("asset");
+ assert_eq!(protocol, 11);
+ assert_eq!(asset.url(), "https://example.com/old");
+ assert_eq!(asset.sha256(), Some("old"));
+ }
+
+ #[test]
+ fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
+ assert_eq!(
+ remote_server_restart_reason(Some(&current_version()), Some(0), false),
+ Some(RemoteServerRestartReason::ProtocolMismatch)
+ );
+ }
+
+ #[test]
+ fn remote_server_restart_reason_offers_restart_after_binary_update() {
+ assert_eq!(
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), true),
+ Some(RemoteServerRestartReason::BinaryUpdated)
+ );
+ }
+
+ #[test]
+ fn remote_server_restart_reason_offers_restart_for_version_mismatch() {
+ assert_eq!(
+ remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), false),
+ Some(RemoteServerRestartReason::VersionMismatch)
+ );
+ assert_eq!(
+ remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), false),
+ Some(RemoteServerRestartReason::VersionMismatch)
+ );
+ }
+
+ #[test]
+ fn remote_server_restart_reason_allows_current_server() {
+ assert_eq!(
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), false),
+ None
+ );
+ }
+
+ #[test]
+ fn install_source_description_uses_override_binary() {
+ let platform = RemotePlatform {
+ os: "linux",
+ arch: "aarch64",
+ };
+ assert_eq!(
+ install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false),
+ "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)"
+ );
+ }
+
+ #[test]
+ fn install_source_description_uses_local_binary_when_allowed() {
+ let platform = RemotePlatform::local();
+
+ assert_eq!(
+ install_source_description_for(&platform, None, true),
+ "the current local herdr binary"
+ );
+ }
+
+ #[test]
+ fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() {
+ let platform = RemotePlatform::local();
+
+ assert_eq!(
+ install_source_description_for(&platform, None, false),
+ format!(
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
+ platform.asset_key()
+ )
+ );
+ }
+
+ #[test]
+ fn resolve_install_source_uses_override_binary_without_temporary_cleanup() {
+ let platform = RemotePlatform {
+ os: "linux",
+ arch: "aarch64",
+ };
+ let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64")))
+ .expect("override source");
+ assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64"));
+ assert!(source.temporary_dir.is_none());
+ }
+
+ fn remote_env_lock() -> &'static std::sync::Mutex<()> {
+ static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
+ LOCK.get_or_init(|| std::sync::Mutex::new(()))
+ }
+
+ fn socket_path_byte_len(path: &Path) -> usize {
+ use std::os::unix::ffi::OsStrExt;
+ path.as_os_str().as_bytes().len()
+ }
+
+ #[test]
+ fn local_forward_socket_path_uses_readable_name_when_it_fits() {
+ let _guard = remote_env_lock().lock().unwrap();
+ // Short target + session leave plenty of room โ€” keep the human-
+ // readable form so the socket path stays grep-friendly.
+ let path = local_forward_socket_path("dev", "default");
+ let filename = path
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or("")
+ .to_string();
+ assert!(
+ filename.starts_with("herdr-remote-"),
+ "expected readable name, got {filename}"
+ );
+ assert!(filename.contains("-dev-default."), "got {filename}");
+ assert!(
+ fits_unix_socket_path(&path),
+ "socket path too long: {} ({} bytes)",
+ path.display(),
+ socket_path_byte_len(&path)
+ );
+ }
+
+ #[test]
+ fn local_forward_socket_path_fits_in_sun_path() {
+ let _guard = remote_env_lock().lock().unwrap();
+ // Worst case for the readable form: macOS-style 49-char TMPDIR +
+ // max-length sanitized components. Should fall back to the hashed
+ // short name, which fits under TMPDIR.
+ let target = "longish-host.example.com";
+ let session = "a-fairly-long-session-name-here";
+ let path = local_forward_socket_path(target, session);
+ assert!(
+ fits_unix_socket_path(&path),
+ "socket path too long for sun_path: {} ({} bytes)",
+ path.display(),
+ socket_path_byte_len(&path)
+ );
+ }
+
+ #[test]
+ fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() {
+ let _guard = remote_env_lock().lock().unwrap();
+ // Force a TMPDIR long enough that even the hashed short name cannot
+ // fit inside it. The fallback should drop to /tmp.
+ let prior = std::env::var_os("TMPDIR");
+ let long_dir = std::env::temp_dir().join("a".repeat(80));
+ let _ = fs::create_dir_all(&long_dir);
+ std::env::set_var("TMPDIR", &long_dir);
+
+ let path = local_forward_socket_path("longish-host.example.com", "default");
+ let fits = fits_unix_socket_path(&path);
+ let parent = path.parent().map(Path::to_path_buf);
+ let filename = path
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or("")
+ .to_string();
+
+ match prior {
+ Some(v) => std::env::set_var("TMPDIR", v),
+ None => std::env::remove_var("TMPDIR"),
+ }
+ let _ = fs::remove_dir_all(&long_dir);
+
+ assert!(fits, "fallback path still overflows: {}", path.display());
+ assert_eq!(parent.as_deref(), Some(Path::new("/tmp")));
+ assert!(
+ filename.starts_with("herdr-r-"),
+ "expected hashed fallback, got {filename}"
+ );
+ }
+
+ #[test]
+ fn install_source_cleanup_removes_temporary_directory() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-install-source-cleanup-test-{}",
+ std::process::id()
+ ));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir(&dir).expect("create temp dir");
+ let path = dir.join("herdr.tmp");
+ fs::write(&path, b"test").expect("write temp file");
+
+ InstallSource::temporary(path, dir.clone()).cleanup();
+
+ assert!(!dir.exists());
+ }
+}
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;
diff --git a/src/session.rs b/src/session.rs
index 67b77dc0..34d202a5 100644
--- a/src/session.rs
+++ b/src/session.rs
@@ -1,9 +1,12 @@
use std::io::{BufRead, BufReader, Write};
-use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
+use interprocess::local_socket::traits::Stream as _;
+
+use crate::ipc::LocalStream;
+
pub const SESSION_ENV_VAR: &str = "HERDR_SESSION";
pub const DEFAULT_SESSION_NAME: &str = "default";
@@ -238,7 +241,7 @@ fn stop_session_with_timeout(name: Option<&str>, timeout: Duration) -> Result<Se
"method": "server.stop",
"params": {}
});
- let stream = UnixStream::connect(&socket_path).map_err(|err| {
+ let stream = crate::ipc::connect_local_stream(&socket_path).map_err(|err| {
format!(
"session {} is not running or cannot be reached at {}: {err}",
name.unwrap_or(DEFAULT_SESSION_NAME),
@@ -283,14 +286,14 @@ pub fn delete_session(name: &str) -> Result<SessionInfo, String> {
}
fn send_stop_request(
- mut stream: UnixStream,
+ mut stream: LocalStream,
request: &serde_json::Value,
deadline: Instant,
) -> Result<Option<serde_json::Value>, String> {
let Some(write_timeout) = socket_timeout_until(deadline) else {
return Ok(None);
};
- if let Err(err) = stream.set_write_timeout(Some(write_timeout)) {
+ if let Err(err) = stream.set_send_timeout(Some(write_timeout)) {
if err.kind() != std::io::ErrorKind::InvalidInput {
return Err(err.to_string());
}
@@ -308,7 +311,7 @@ fn send_stop_request(
}
fn send_stop_request_inner(
- stream: &mut UnixStream,
+ stream: &mut LocalStream,
request: &serde_json::Value,
deadline: Instant,
) -> std::io::Result<Option<String>> {
@@ -319,7 +322,7 @@ fn send_stop_request_inner(
let Some(read_timeout) = socket_timeout_until(deadline) else {
return Ok(None);
};
- if let Err(err) = stream.set_read_timeout(Some(read_timeout)) {
+ if let Err(err) = stream.set_recv_timeout(Some(read_timeout)) {
if err.kind() == std::io::ErrorKind::InvalidInput {
return Ok(None);
}
@@ -347,7 +350,7 @@ fn stop_request_error_allows_wait(err: &std::io::Error) -> bool {
}
fn is_running_at(socket_path: &Path) -> bool {
- socket_path.exists() && UnixStream::connect(socket_path).is_ok()
+ socket_path.exists() && crate::ipc::connect_local_stream(socket_path).is_ok()
}
fn wait_until_stopped_until(socket_path: &Path, deadline: Instant) -> bool {
@@ -420,6 +423,8 @@ fn normalize_name(name: &str) -> Result<Option<String>, String> {
#[cfg(test)]
mod tests {
use super::*;
+ #[cfg(unix)]
+ use interprocess::local_socket::traits::Listener as _;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
@@ -427,6 +432,24 @@ mod tests {
LOCK.get_or_init(|| Mutex::new(()))
}
+ #[cfg(unix)]
+ 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()))
+ }
+
+ #[cfg(unix)]
+ 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 stop_request_errors_wait_for_socket_state() {
for kind in [
@@ -455,9 +478,10 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn stop_request_empty_response_waits_for_socket_state() {
- let (client, server) = UnixStream::pair().unwrap();
+ let (client, server, _path) = local_stream_pair("stop-empty-response");
let handle = std::thread::spawn(move || {
let mut request = String::new();
let _ = BufReader::new(server).read_line(&mut request);
@@ -481,6 +505,7 @@ mod tests {
assert!(handle.join().unwrap().contains("server.stop"));
}
+ #[cfg(unix)]
#[test]
fn stop_session_times_out_when_socket_stays_open_without_response() {
let _guard = env_lock().lock().unwrap();
@@ -874,6 +899,7 @@ mod tests {
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
}
+ #[cfg(unix)]
#[test]
fn stop_session_fails_when_socket_remains_reachable_after_timeout() {
let _guard = env_lock().lock().unwrap();
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index 6d761b11..c74301ad 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -201,6 +201,7 @@ impl TerminalRuntime {
self.0.resize(rows, cols, cell_width_px, cell_height_px);
}
+ #[cfg(unix)]
pub fn nudge_child_redraw_after_handoff(&self) {
self.0.nudge_child_redraw_after_handoff();
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index ec113b14..bb2642d9 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -838,6 +838,14 @@ mod tests {
TerminalState::new(TerminalId::alloc(), "/tmp".into())
}
+ fn test_session_path(name: &str) -> String {
+ std::env::current_dir()
+ .unwrap()
+ .join(name)
+ .display()
+ .to_string()
+ }
+
#[test]
fn claude_working_is_sticky_for_short_gap() {
let now = std::time::Instant::now();
@@ -1805,6 +1813,7 @@ mod tests {
#[test]
fn accepted_hook_report_stores_session_ref() {
let mut terminal = test_terminal();
+ let session_path = test_session_path("pi.jsonl");
let mutation = terminal
.set_hook_authority_with_session_ref(
"herdr:pi".into(),
@@ -1812,7 +1821,7 @@ mod tests {
AgentState::Working,
None,
None,
- crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
)
.expect("accepted report");
@@ -1826,7 +1835,7 @@ mod tests {
.map(|session_ref| (&session_ref.kind, session_ref.value.as_str())),
Some((
&crate::agent_resume::AgentSessionRefKind::Path,
- "/tmp/pi.jsonl"
+ session_path.as_str()
))
);
}
@@ -1834,13 +1843,15 @@ mod tests {
#[test]
fn stale_hook_report_cannot_overwrite_session_ref() {
let mut terminal = test_terminal();
+ let session_path = test_session_path("pi.jsonl");
+ let new_session_path = test_session_path("new.jsonl");
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
);
@@ -1850,7 +1861,7 @@ mod tests {
AgentState::Working,
None,
None,
- crate::agent_resume::AgentSessionRef::path("/tmp/new.jsonl"),
+ crate::agent_resume::AgentSessionRef::path(new_session_path),
Some(19),
);
@@ -1861,20 +1872,21 @@ mod tests {
.as_ref()
.and_then(|authority| authority.session_ref.as_ref())
.map(|session_ref| session_ref.value.as_str()),
- Some("/tmp/pi.jsonl")
+ Some(session_path.as_str())
);
}
#[test]
fn accepted_hook_report_without_session_ref_clears_previous_ref() {
let mut terminal = test_terminal();
+ let session_path = test_session_path("pi.jsonl");
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
+ crate::agent_resume::AgentSessionRef::path(session_path),
Some(20),
);
@@ -1927,13 +1939,14 @@ mod tests {
#[test]
fn clearing_hook_authority_clears_session_ref() {
let mut terminal = test_terminal();
+ let session_path = test_session_path("pi.jsonl");
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
+ crate::agent_resume::AgentSessionRef::path(session_path),
Some(20),
);
@@ -1948,13 +1961,14 @@ mod tests {
#[test]
fn release_agent_clears_session_ref() {
let mut terminal = test_terminal();
+ let session_path = test_session_path("pi.jsonl");
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
+ crate::agent_resume::AgentSessionRef::path(session_path),
Some(20),
);
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index 9b400f51..3d6911ca 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -965,6 +965,7 @@ mod tests {
assert_eq!(mobile_agent_detail(&entry), " idle ยท pi");
}
+ #[cfg(unix)]
#[tokio::test]
async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() {
let unique = format!(
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 1be5782b..472dd4b1 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -289,7 +289,10 @@ pub(super) fn render_panes(
frame.render_widget(block, info.rect);
}
- let show_cursor = info.is_focused && terminal_active && !pane_is_scrolled_back(rt);
+ let show_cursor = info.is_focused
+ && terminal_active
+ && !pane_is_scrolled_back(rt)
+ && app.pane_exposes_host_cursor(ws_idx, info.id);
rt.render(frame, info.inner_rect, show_cursor);
render_pane_scrollbar(app, frame, info, rt);
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 7d1227df..085f4371 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -1286,6 +1286,7 @@ mod tests {
assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
}
+ #[cfg(unix)]
#[tokio::test]
async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() {
let unique = format!(
diff --git a/src/update.rs b/src/update.rs
index c5dab475..4f192e3a 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -10,11 +10,11 @@ use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::io::{self, BufRead, BufReader, IsTerminal, Write};
-use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
+use interprocess::local_socket::traits::Stream as _;
use serde::{Deserialize, Deserializer};
const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
@@ -216,7 +216,7 @@ struct HomebrewFormulaVersions {
}
impl UpdateManifest {
- #[cfg(test)]
+ #[cfg(all(test, unix))]
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
self.assets
.get(&format!("{os}-{arch}"))
@@ -626,7 +626,7 @@ fn client_protocol_server_is_running_at(socket_path: &Path) -> bool {
return false;
}
- UnixStream::connect(socket_path).is_ok()
+ crate::ipc::connect_local_stream(socket_path).is_ok()
}
fn client_protocol_server_is_running() -> bool {
@@ -1309,13 +1309,13 @@ fn send_server_update_method_at(
method,
};
- let mut stream = UnixStream::connect(socket_path)
+ let mut stream = crate::ipc::connect_local_stream(socket_path)
.map_err(|e| format!("failed to connect to running server: {e}"))?;
stream
- .set_write_timeout(Some(timeout))
+ .set_send_timeout(Some(timeout))
.map_err(|e| format!("failed to set {error_prefix} write timeout: {e}"))?;
stream
- .set_read_timeout(Some(timeout))
+ .set_recv_timeout(Some(timeout))
.map_err(|e| format!("failed to set {error_prefix} read timeout: {e}"))?;
stream
.write_all(
@@ -1348,7 +1348,7 @@ fn send_server_update_method_at(
Ok(())
}
-#[cfg(test)]
+#[cfg(all(test, unix))]
fn live_handoff_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
use crate::api::schema::{Method, ServerLiveHandoffParams};
@@ -1404,7 +1404,7 @@ fn server_shutdown_confirmed_at(socket_path: &Path) -> Result<bool, String> {
return Ok(true);
}
- match UnixStream::connect(socket_path) {
+ match crate::ipc::connect_local_stream(socket_path) {
Ok(_) => Ok(false),
Err(err)
if matches!(
@@ -1715,6 +1715,7 @@ fn preview_channel_rejection_for_exe_path(path: &Path) -> Option<&'static str> {
}
}
+#[cfg(unix)]
pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool {
is_homebrew_managed_exe_path_following_links(path)
|| is_mise_managed_exe_path_following_links(path)
@@ -2095,7 +2096,7 @@ fn platform_target() -> (&'static str, &'static str) {
// Tests
// ---------------------------------------------------------------------------
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::net::UnixListener;