aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-05 19:34:24 +0300
committerGitHub <noreply@github.com>2026-08-05 19:34:24 +0300
commit2863b715132fe29e53089e06f105943d1df0b3b4 (patch)
tree6b4af2f6fdec6de2888fd2bff3c0829d6719b727 /src/client
parenteb93ec8ce62b05de776f751f4d2f52ff60f93f16 (diff)
feat(windows): support remote attach to unix hosts (#2329)
* feat(windows): support remote attach to unix hosts * fix(remote): preserve unix bridge behavior
Diffstat (limited to 'src/client')
-rw-r--r--src/client/mod.rs247
1 files changed, 182 insertions, 65 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs
index c0fe73d3..fd92d188 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -37,7 +37,6 @@ use tracing::{debug, info, warn};
use crate::ipc::LocalStream;
use crate::protocol::render_ansi;
-#[cfg(unix)]
use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD;
use crate::protocol::{
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
@@ -59,7 +58,6 @@ struct ClientLoopConfig {
host_cursor: crate::config::HostCursorModeConfig,
kitty_graphics_enabled: bool,
mouse_capture_active: bool,
- #[cfg(unix)]
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
}
@@ -83,7 +81,6 @@ struct ClientState {
#[cfg(unix)]
mouse_scroll_lines: usize,
/// Local-client shortcut that sends a clipboard image to a remote Herdr session.
- #[cfg(unix)]
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
/// Whether outer focus gain should force a full host-terminal redraw.
redraw_on_focus_gained: bool,
@@ -667,7 +664,6 @@ fn requested_render_encoding() -> RenderEncoding {
}
}
-#[cfg(unix)]
fn is_remote_client_process() -> bool {
std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok()
}
@@ -680,11 +676,9 @@ fn is_remote_client_process() -> bool {
/// window; on a high-latency link that easily exceeds 5s, so it gets a far
/// larger budget. See issue #753.
const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5);
-#[cfg(unix)]
const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60);
fn handshake_read_timeout() -> Duration {
- #[cfg(unix)]
if is_remote_client_process() {
return REMOTE_HANDSHAKE_READ_TIMEOUT;
}
@@ -1126,7 +1120,6 @@ fn run_client_with_mode(
let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
let host_cursor = loaded_config.config.ui.host_cursor;
let direct_attach_requested = attach_request.is_some();
- #[cfg(unix)]
let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config);
let kitty_graphics_enabled =
loaded_config.config.experimental.kitty_graphics && !direct_attach_requested;
@@ -1137,7 +1130,6 @@ fn run_client_with_mode(
host_cursor,
kitty_graphics_enabled,
mouse_capture_active: mouse_capture,
- #[cfg(unix)]
remote_image_paste_key,
};
@@ -1295,7 +1287,6 @@ async fn run_client_loop(
#[cfg(windows)]
let _ = config.mouse_scroll_lines;
let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor);
- #[cfg(unix)]
let is_remote_client = is_remote_client_process();
let mut state = ClientState {
@@ -1308,7 +1299,6 @@ async fn run_client_loop(
attach_escape,
#[cfg(unix)]
mouse_scroll_lines: config.mouse_scroll_lines,
- #[cfg(unix)]
remote_image_paste_key: config.remote_image_paste_key,
redraw_on_focus_gained: config.redraw_on_focus_gained,
repaint_pending: false,
@@ -1466,26 +1456,7 @@ async fn run_client_loop(
state.remote_image_paste_key,
) {
if let Some(image) = crate::platform::read_clipboard_image() {
- if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
- warn!(
- bytes = image.bytes.len(),
- max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
- "local clipboard image is too large to bridge"
- );
- continue;
- }
- info!(
- bytes = image.bytes.len(),
- extension = image.extension,
- "bridging local clipboard image paste to remote server"
- );
- let msg = ClientMessage::ClipboardImage {
- extension: image.extension.to_owned(),
- data: image.bytes,
- };
- if let Err(e) = write_to_server(&mut write_stream, &msg) {
- return Err(ClientError::ConnectionLost(e));
- }
+ write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?;
continue;
}
info!(
@@ -1493,18 +1464,7 @@ async fn run_client_loop(
);
}
if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) {
- info!(
- bytes = image.bytes.len(),
- extension = image.extension,
- "bridging local image file drop to remote server"
- );
- let msg = ClientMessage::ClipboardImage {
- extension: image.extension.to_owned(),
- data: image.bytes,
- };
- if let Err(e) = write_to_server(&mut write_stream, &msg) {
- return Err(ClientError::ConnectionLost(e));
- }
+ write_remote_image_to_server(&mut write_stream, image, "file drop")?;
continue;
}
let msg = ClientMessage::Input { data };
@@ -1517,6 +1477,23 @@ async fn run_client_loop(
if state.attach_escape.is_some() {
continue;
}
+ if should_bridge_clipboard_image_events(
+ &events,
+ is_remote_client,
+ state.remote_image_paste_key,
+ ) {
+ if let Some(image) = crate::platform::read_clipboard_image() {
+ write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?;
+ continue;
+ }
+ info!(
+ "clipboard image paste trigger received, but local clipboard has no image"
+ );
+ }
+ if let Some(image) = read_image_file_from_client_events(&events, is_remote_client) {
+ write_remote_image_to_server(&mut write_stream, image, "file drop")?;
+ continue;
+ }
let raw_events = events
.iter()
.map(crate::protocol::ClientInputEvent::to_raw_input_event)
@@ -1614,7 +1591,6 @@ async fn run_client_loop(
&mut state.sound_config,
&mut state.redraw_on_focus_gained,
&mut state.draw_host_cursor,
- #[cfg(unix)]
&mut state.remote_image_paste_key,
);
}
@@ -1733,11 +1709,41 @@ 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()))
}
+fn write_remote_image_to_server(
+ stream: &mut LocalStream,
+ image: crate::platform::ClipboardImage,
+ source: &'static str,
+) -> Result<(), ClientError> {
+ if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
+ warn!(
+ bytes = image.bytes.len(),
+ max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ source,
+ "local image is too large to bridge"
+ );
+ return Ok(());
+ }
+
+ info!(
+ bytes = image.bytes.len(),
+ extension = image.extension,
+ source,
+ "bridging local image to remote server"
+ );
+ write_to_server(
+ stream,
+ &ClientMessage::ClipboardImage {
+ extension: image.extension.to_owned(),
+ data: image.bytes,
+ },
+ )
+ .map_err(ClientError::ConnectionLost)
+}
+
// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
-#[cfg(unix)]
fn client_remote_image_paste_key(
config: &crate::config::Config,
) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
@@ -1758,7 +1764,7 @@ fn reload_local_client_config(
sound_config: &mut crate::config::SoundConfig,
redraw_on_focus_gained: &mut bool,
draw_host_cursor: &mut bool,
- #[cfg(unix)] remote_image_paste_key: &mut Option<(
+ remote_image_paste_key: &mut Option<(
crossterm::event::KeyCode,
crossterm::event::KeyModifiers,
)>,
@@ -1768,15 +1774,11 @@ fn reload_local_client_config(
for diagnostic in loaded.config.ui.sound.diagnostics() {
warn!(diagnostic = %diagnostic, "local sound config diagnostic");
}
- #[cfg(unix)]
let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config);
*sound_config = loaded.config.ui.sound;
*redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained;
*draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor);
- #[cfg(unix)]
- {
- *remote_image_paste_key = loaded_remote_image_paste_key;
- }
+ *remote_image_paste_key = loaded_remote_image_paste_key;
debug!("reloaded local client config");
}
Err(diagnostics) => {
@@ -1874,12 +1876,68 @@ fn should_bridge_clipboard_image_paste(
)
}
+#[cfg(any(windows, test))]
+fn should_bridge_clipboard_image_events(
+ events: &[crate::protocol::ClientInputEvent],
+ is_remote_client: bool,
+ remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
+) -> bool {
+ if !is_remote_client {
+ return false;
+ }
+ if matches!(
+ events,
+ [crate::protocol::ClientInputEvent::Paste { text }] if text.is_empty()
+ ) {
+ return true;
+ }
+
+ let Some(remote_image_paste_key) = remote_image_paste_key else {
+ return false;
+ };
+ matches!(
+ events,
+ [event]
+ if matches!(
+ event.to_raw_input_event(),
+ crate::raw_input::RawInputEvent::Key(key)
+ if key.kind == crossterm::event::KeyEventKind::Press
+ && crate::config::terminal_key_matches_combo(
+ &key,
+ remote_image_paste_key,
+ )
+ )
+ )
+}
+
#[cfg(unix)]
fn read_image_file_from_terminal_drop(
data: &[u8],
is_remote_client: bool,
) -> Option<crate::platform::ClipboardImage> {
let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?;
+ read_image_file(path, extension)
+}
+
+#[cfg(any(windows, test))]
+fn read_image_file_from_client_events(
+ events: &[crate::protocol::ClientInputEvent],
+ is_remote_client: bool,
+) -> Option<crate::platform::ClipboardImage> {
+ let [crate::protocol::ClientInputEvent::Paste { text }] = events else {
+ return None;
+ };
+ let text = normalized_terminal_drop_text(text)?;
+ // Windows events already carry native paths; Unix backslash unescaping would corrupt them.
+ let (path, extension) =
+ image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?;
+ read_image_file(path, extension)
+}
+
+fn read_image_file(
+ path: std::path::PathBuf,
+ extension: &'static str,
+) -> Option<crate::platform::ClipboardImage> {
let metadata = std::fs::metadata(&path).ok()?;
if !metadata.is_file() {
return None;
@@ -1907,23 +1965,29 @@ fn image_path_from_terminal_drop(
data: &[u8],
is_remote_client: bool,
) -> Option<(std::path::PathBuf, &'static str)> {
- if !is_remote_client {
- return None;
- }
-
let bytes = bracketed_paste_payload(data).unwrap_or(data);
let text = std::str::from_utf8(bytes).ok()?;
+ let text = normalized_terminal_drop_text(text)?;
+ let text = unescape_terminal_drop_path(strip_matching_path_quotes(text));
+ image_path_from_drop_text(&text, is_remote_client)
+}
+
+fn normalized_terminal_drop_text(text: &str) -> Option<&str> {
let text = text.trim_end_matches(['\r', '\n']);
- if text.is_empty() || text.contains(['\r', '\n']) {
+ (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text)
+}
+
+fn image_path_from_drop_text(
+ text: &str,
+ is_remote_client: bool,
+) -> Option<(std::path::PathBuf, &'static str)> {
+ if !is_remote_client {
return None;
}
-
- let text = unescape_terminal_drop_path(strip_matching_path_quotes(text));
let path = std::path::PathBuf::from(text);
if !path.is_absolute() {
return None;
}
-
let extension = recognized_image_extension(path.extension()?.to_str()?)?;
Some((path, extension))
}
@@ -1935,7 +1999,6 @@ fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> {
data.strip_prefix(START)?.strip_suffix(END)
}
-#[cfg(unix)]
fn strip_matching_path_quotes(text: &str) -> &str {
if text.len() < 2 {
return text;
@@ -1966,7 +2029,6 @@ fn unescape_terminal_drop_path(text: &str) -> String {
unescaped
}
-#[cfg(unix)]
fn recognized_image_extension(extension: &str) -> Option<&'static str> {
if extension.eq_ignore_ascii_case("png") {
Some("png")
@@ -2364,6 +2426,14 @@ mod tests {
}
#[test]
+ fn remote_client_uses_extended_handshake_timeout() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local");
+
+ assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT);
+ }
+
+ #[test]
fn host_cursor_policy_auto_uses_platform_default() {
assert_eq!(
should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto),
@@ -2421,12 +2491,10 @@ mod tests {
));
}
- #[cfg(unix)]
struct TempImageFile {
path: std::path::PathBuf,
}
- #[cfg(unix)]
impl TempImageFile {
fn new(extension: &str, bytes: &[u8]) -> Self {
Self::with_name_fragment("test", extension, bytes)
@@ -2446,13 +2514,64 @@ mod tests {
}
}
- #[cfg(unix)]
impl Drop for TempImageFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
+ #[test]
+ fn clipboard_image_event_bridge_matches_remote_key_and_empty_paste() {
+ let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap();
+ let key = crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('v'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ };
+ let empty_paste = crate::protocol::ClientInputEvent::Paste {
+ text: String::new(),
+ };
+
+ assert!(should_bridge_clipboard_image_events(
+ std::slice::from_ref(&key),
+ true,
+ Some(ctrl_v),
+ ));
+ assert!(should_bridge_clipboard_image_events(
+ std::slice::from_ref(&empty_paste),
+ true,
+ None,
+ ));
+ assert!(!should_bridge_clipboard_image_events(
+ &[key],
+ false,
+ Some(ctrl_v),
+ ));
+ assert!(!should_bridge_clipboard_image_events(
+ &[crate::protocol::ClientInputEvent::Paste {
+ text: "text".to_string(),
+ }],
+ true,
+ Some(ctrl_v),
+ ));
+ }
+
+ #[test]
+ fn remote_image_file_drop_bridge_reads_semantic_paste_path() {
+ let file = TempImageFile::new("PNG", b"image-bytes");
+ let events = [crate::protocol::ClientInputEvent::Paste {
+ text: format!("\"{}\"", file.path.display()),
+ }];
+
+ let image = read_image_file_from_client_events(&events, true).unwrap();
+
+ assert_eq!(image.extension, "png");
+ assert_eq!(image.bytes, b"image-bytes");
+ }
+
#[cfg(unix)]
#[test]
fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() {
@@ -2963,14 +3082,12 @@ mod tests {
let mut sound_config = crate::config::SoundConfig::default();
let mut redraw_on_focus_gained = true;
let mut draw_host_cursor = false;
- #[cfg(unix)]
let mut remote_image_paste_key = None;
reload_local_client_config(
&mut sound_config,
&mut redraw_on_focus_gained,
&mut draw_host_cursor,
- #[cfg(unix)]
&mut remote_image_paste_key,
);