diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 00:07:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 16:29:44 +0300 |
| commit | 3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch) | |
| tree | a52280bccc824ca656f8bc986a756f30901974e4 /src | |
| parent | 5d7e56737ef30d9de5802b0684a9428c40131dda (diff) | |
feat: add native windows beta support
Diffstat (limited to 'src')
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); @@ -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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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; |
