diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index a12dbef1..6d2348ae 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -19,6 +19,7 @@ ### Fixed - `herdr --remote` now prints clean remote attach failures and SSH authentication guidance instead of Rust Debug-formatted I/O errors when SSH authentication is denied. (#1034) - `herdr server stop` now waits until both server sockets are unreachable before returning, avoiding an immediate first-start failure when restarting right after replacing the binary. +- macOS `herdr --remote` clients now bridge Finder-dropped image files to the remote pane instead of forwarding the local file path as typed text. (#828) - Grok Build agent detection now tracks the current Grok Build UI: panes report working while responses, tools, and subagents run, and blocked on permission prompts and question dialogs, instead of falling back to idle mid-turn. (#1017) - Unix local Herdr clients no longer treat empty bracketed paste as a clipboard-image bridge; `herdr --remote` keeps using it for local-desktop image paste over SSH. (#986) - Custom command keybindings now run through `cmd.exe /d /c` on Windows instead of `/bin/sh`, so `type = "pane"` and `type = "shell"` bindings can launch native Windows commands. (#1041) diff --git a/src/client/mod.rs b/src/client/mod.rs index da0e4e53..bdc67880 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1447,6 +1447,21 @@ async fn run_client_loop( "clipboard image paste trigger received, but local clipboard has no image" ); } + 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)); + } + continue; + } let msg = ClientMessage::Input { data }; if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); @@ -1798,6 +1813,115 @@ fn should_bridge_clipboard_image_paste( ) } +#[cfg(unix)] +fn read_image_file_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option { + let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; + let metadata = std::fs::metadata(&path).ok()?; + if !metadata.is_file() { + return None; + } + + let file = std::fs::File::open(&path).ok()?; + let bytes = + match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { + crate::platform::LimitedRead::Complete(bytes) => bytes, + crate::platform::LimitedRead::Empty => return None, + crate::platform::LimitedRead::Oversized => { + warn!( + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + "local image file drop is too large to bridge" + ); + return None; + } + }; + + Some(crate::platform::ClipboardImage { bytes, extension }) +} + +#[cfg(unix)] +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 = text.trim_end_matches(['\r', '\n']); + if text.is_empty() || text.contains(['\r', '\n']) { + 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)) +} + +#[cfg(unix)] +fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { + const START: &[u8] = b"\x1b[200~"; + const END: &[u8] = b"\x1b[201~"; + data.strip_prefix(START)?.strip_suffix(END) +} + +#[cfg(unix)] +fn strip_matching_path_quotes(text: &str) -> &str { + if text.len() < 2 { + return text; + } + + let bytes = text.as_bytes(); + match (bytes.first(), bytes.last()) { + (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], + _ => text, + } +} + +#[cfg(unix)] +fn unescape_terminal_drop_path(text: &str) -> String { + let mut unescaped = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(escaped) = chars.next() { + unescaped.push(escaped); + } else { + unescaped.push(ch); + } + } else { + unescaped.push(ch); + } + } + unescaped +} + +#[cfg(unix)] +fn recognized_image_extension(extension: &str) -> Option<&'static str> { + if extension.eq_ignore_ascii_case("png") { + Some("png") + } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { + Some("jpg") + } else if extension.eq_ignore_ascii_case("gif") { + Some("gif") + } else if extension.eq_ignore_ascii_case("webp") { + Some("webp") + } else if extension.eq_ignore_ascii_case("bmp") { + Some("bmp") + } else { + None + } +} + // --------------------------------------------------------------------------- // Clipboard forwarding // --------------------------------------------------------------------------- @@ -2131,6 +2255,90 @@ 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) + } + + fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", + std::process::id() + )); + std::fs::write(&path, bytes).unwrap(); + Self { path } + } + } + + #[cfg(unix)] + impl Drop for TempImageFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + + #[cfg(unix)] + #[test] + fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { + let file = TempImageFile::new("PNG", b"image-bytes"); + let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); + } + + #[cfg(unix)] + #[test] + fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { + let file = TempImageFile::new("jpeg", b"jpeg-bytes"); + let input = format!("'{}'\n", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "jpg"); + assert_eq!(image.bytes, b"jpeg-bytes"); + } + + #[cfg(unix)] + #[test] + fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { + let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); + let escaped_path = file.path.display().to_string().replace(' ', "\\ "); + + let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); + } + + #[cfg(unix)] + #[test] + fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { + let file = TempImageFile::new("png", b"image-bytes"); + let path = file.path.display().to_string(); + + assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); + assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); + assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); + assert!(read_image_file_from_terminal_drop( + format!("{}\nextra", file.path.display()).as_bytes(), + true + ) + .is_none()); + } + #[test] fn graphics_bytes_are_written_after_blit_with_saved_cursor() { let mut output = Vec::new();