diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 02:51:59 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 02:51:59 +0300 |
| commit | ed478be9835e984c42bc1e30fe84b5fb01a8c5d4 (patch) | |
| tree | 353b6ea5c7368a9f3b477bc8f1bbcd248e377577 /src | |
| parent | 2b97799b6715fd6c355db9c225029765971915e6 (diff) | |
fix: bridge remote clipboard image paste
refs #205
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/mod.rs | 58 | ||||
| -rw-r--r-- | src/platform/fallback.rs | 7 | ||||
| -rw-r--r-- | src/platform/linux.rs | 45 | ||||
| -rw-r--r-- | src/platform/macos.rs | 43 | ||||
| -rw-r--r-- | src/platform/mod.rs | 6 | ||||
| -rw-r--r-- | src/remote.rs | 184 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 34 | ||||
| -rw-r--r-- | src/server/clipboard_image.rs | 128 | ||||
| -rw-r--r-- | src/server/headless.rs | 111 | ||||
| -rw-r--r-- | src/server/mod.rs | 1 | ||||
| -rw-r--r-- | src/server/protocol.rs | 39 |
11 files changed, 619 insertions, 37 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs index 101e168c..ff5c144f 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -32,8 +32,8 @@ use tracing::{debug, info, warn}; use crate::server::headless::client_socket_path; use crate::server::protocol::{ - self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, + MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); @@ -640,6 +640,34 @@ async fn run_client_loop( } data }; + if should_bridge_clipboard_image_paste(&data) { + 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)); + } + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } let msg = ClientMessage::Input { data }; if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); @@ -875,6 +903,21 @@ fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> { } } +fn should_bridge_clipboard_image_paste(data: &[u8]) -> bool { + if data == b"\x1b[200~\x1b[201~" { + return true; + } + + let events = crate::raw_input::parse_raw_input_bytes_sync(data); + matches!( + events.as_slice(), + [crate::raw_input::RawInputEvent::Key(key)] + if key.kind == crossterm::event::KeyEventKind::Press + && key.modifiers == crossterm::event::KeyModifiers::CONTROL + && matches!(key.code, crossterm::event::KeyCode::Char('v' | 'V')) + ) +} + // --------------------------------------------------------------------------- // Clipboard forwarding // --------------------------------------------------------------------------- @@ -1069,6 +1112,17 @@ mod tests { use super::*; #[test] + fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() { + assert!(should_bridge_clipboard_image_paste(&[0x16])); + assert!(should_bridge_clipboard_image_paste(b"\x1b[118;5u")); + assert!(should_bridge_clipboard_image_paste(b"\x1b[200~\x1b[201~")); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~text\x1b[201~" + )); + assert!(!should_bridge_clipboard_image_paste(b"v")); + } + + #[test] fn graphics_bytes_are_written_after_blit_with_saved_cursor() { let mut output = Vec::new(); write_encoded_frame_with_graphics( diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index bf3ac42b..825aaccd 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use super::{ForegroundJob, Signal}; +use super::{ClipboardImage, ForegroundJob, Signal}; /// Unsupported platform stub. pub fn foreground_job(_child_pid: u32) -> Option<ForegroundJob> { @@ -36,6 +36,11 @@ pub fn write_clipboard(_bytes: &[u8]) -> bool { } /// Unsupported platform stub. +pub fn read_clipboard_image() -> Option<ClipboardImage> { + None +} + +/// Unsupported platform stub. pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result<bool> { Ok(false) } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 6823cca8..4d2ed049 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -4,7 +4,7 @@ use std::{ process::{Command, Stdio}, }; -use super::{ClipboardCommand, ForegroundJob, ForegroundProcess, Signal}; +use super::{ClipboardCommand, ClipboardImage, ForegroundJob, ForegroundProcess, Signal}; /// Collect the foreground terminal job for a given child PID. pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { @@ -155,6 +155,34 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { false } +pub fn read_clipboard_image() -> Option<ClipboardImage> { + for (mime, extension) in [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/jpg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), + ("image/bmp", "bmp"), + ] { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + if let Some(bytes) = read_clipboard_image_with_command("wl-paste", &["--type", mime]) { + return Some(ClipboardImage { bytes, extension }); + } + } + + if std::env::var_os("DISPLAY").is_some() { + if let Some(bytes) = read_clipboard_image_with_command( + "xclip", + &["-selection", "clipboard", "-t", mime, "-o"], + ) { + return Some(ClipboardImage { bytes, extension }); + } + } + } + + None +} + /// Show a native desktop notification through libnotify's command-line helper. pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> { show_desktop_notification_with_command(title, body, |program| Command::new(program)) @@ -192,6 +220,21 @@ fn run_notification_command(mut command: Command) -> std::io::Result<bool> { Ok(status.success()) } +fn read_clipboard_image_with_command(program: &str, args: &[&str]) -> Option<Vec<u8>> { + let output = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + + if output.status.success() && !output.stdout.is_empty() { + Some(output.stdout) + } else { + None + } +} + fn clipboard_commands() -> Vec<ClipboardCommand> { let mut commands = Vec::new(); diff --git a/src/platform/macos.rs b/src/platform/macos.rs index f085ee91..d0cf43c8 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::OnceLock; -use super::{ClipboardCommand, ForegroundJob, ForegroundProcess, Signal}; +use super::{ClipboardCommand, ClipboardImage, ForegroundJob, ForegroundProcess, Signal}; const PROC_PGRP_ONLY: u32 = 2; @@ -202,6 +202,47 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { ) } +pub fn read_clipboard_image() -> Option<ClipboardImage> { + let path = std::env::temp_dir().join(format!( + "herdr-clipboard-image-{}-{}.png", + std::process::id(), + unique_timestamp_nanos() + )); + let script = format!( + "set png_data to (the clipboard as «class PNGf»)\nset fp to open for access POSIX file \"{}\" with write permission\nwrite png_data to fp\nclose access fp", + path.display() + ); + + let status = Command::new("osascript") + .arg("-e") + .arg(script) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()?; + + if !status.success() { + let _ = std::fs::remove_file(&path); + return None; + } + + let bytes = std::fs::read(&path).ok(); + let _ = std::fs::remove_file(&path); + let bytes = bytes?; + (!bytes.is_empty()).then_some(ClipboardImage { + bytes, + extension: "png", + }) +} + +fn unique_timestamp_nanos() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) +} + /// Show a native macOS notification. /// /// Prefer `terminal-notifier` when it is installed because it can activate the diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 58675d33..00fa8833 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -30,6 +30,12 @@ pub struct ClipboardCommand { pub args: &'static [&'static str], } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardImage { + pub bytes: Vec<u8>, + pub extension: &'static str, +} + #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] diff --git a/src/remote.rs b/src/remote.rs index 22f6ba74..5ab7e6fd 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -18,6 +18,7 @@ use std::time::Duration; const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const CURRENT_PROTOCOL: u32 = crate::server::protocol::PROTOCOL_VERSION; const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; @@ -89,6 +90,7 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { .unwrap_or_else(|| "herdr".to_string()); let reattach_command = reattach_command(&program, &remote.target, &session_name); let remote_herdr = prepare_remote_herdr(&remote.target)?; + ensure_remote_server_compatible(&remote.target, &remote_herdr)?; let _bridge = SshStdioBridge::start( remote.target, @@ -130,7 +132,18 @@ pub(crate) fn run_remote_client_bridge() -> io::Result<()> { fn ensure_remote_server_running() -> io::Result<()> { let socket_path = crate::server::headless::client_socket_path(); if crate::server::autodetect::is_server_listening() { - return Ok(()); + 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(format!( + "remote herdr server is running with protocol {}, but this bridge needs protocol {CURRENT_PROTOCOL}; rerun `herdr --remote` from an interactive terminal to approve replacing it", + protocol_label(status.protocol) + ))); } crate::server::autodetect::spawn_server_daemon()?; @@ -311,7 +324,8 @@ fn remote_path_probe_command() -> &'static str { r#"path=$(command -v herdr) || exit 1 test -n "$path" || exit 1 version=$("$path" --version) || exit 1 -printf '%s\n%s\n' "$path" "$version" +status=$("$path" status client) || exit 1 +printf '%s\n%s\n%s\n' "$path" "$version" "$status" "# } @@ -319,7 +333,12 @@ fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Opt let mut lines = stdout.lines(); let path = lines.next()?; let version = lines.next()?.trim(); - if !path.starts_with('/') || version != format!("herdr {CURRENT_VERSION}") { + let status = lines.collect::<Vec<_>>().join("\n"); + let protocol = parse_status_protocol(&status)?; + if !path.starts_with('/') + || version != format!("herdr {CURRENT_VERSION}") + || protocol != CURRENT_PROTOCOL + { return None; } @@ -327,14 +346,21 @@ fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Opt } fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> { - let command = format!("test -x {0} && {0} --version", remote_herdr.shell_path); + let command = format!( + "test -x {0} && {0} --version && {0} status client", + remote_herdr.shell_path + ); let output = ssh_output(target, &command)?; if !output.status.success() { return Ok(false); } - let version = String::from_utf8_lossy(&output.stdout); - Ok(version.trim() == format!("herdr {CURRENT_VERSION}")) + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let version = lines.next().unwrap_or_default().trim(); + let status = lines.collect::<Vec<_>>().join("\n"); + Ok(version == format!("herdr {CURRENT_VERSION}") + && parse_status_protocol(&status) == Some(CURRENT_PROTOCOL)) } fn remote_binary_override_path() -> io::Result<Option<PathBuf>> { @@ -402,6 +428,107 @@ fn resolve_install_source( download_release_asset(platform) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteServerStatus { + Running { protocol: Option<u32> }, + NotRunning, +} + +fn ensure_remote_server_compatible(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let status = remote_server_status(target, remote_herdr)?; + let RemoteServerStatus::Running { protocol } = status else { + return Ok(()); + }; + + if protocol == Some(CURRENT_PROTOCOL) { + return Ok(()); + } + + confirm_remote_server_stop(target, protocol)?; + stop_remote_server(target, remote_herdr) +} + +fn remote_server_status( + target: &str, + remote_herdr: &RemoteHerdr, +) -> io::Result<RemoteServerStatus> { + let command = format!("{} status server", remote_herdr.shell_path); + let output = ssh_output(target, &command)?; + if !output.status.success() { + return Err(command_failed("remote server status failed", &output)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout + .lines() + .any(|line| line.trim() == "status: not running") + { + return Ok(RemoteServerStatus::NotRunning); + } + + if stdout.lines().any(|line| line.trim() == "status: running") { + return Ok(RemoteServerStatus::Running { + protocol: parse_status_protocol(&stdout), + }); + } + + Err(io::Error::other(format!( + "could not parse remote server status from `{}`", + stdout.trim() + ))) +} + +fn parse_status_protocol(status: &str) -> Option<u32> { + status.lines().find_map(|line| { + let (_, value) = line.trim().split_once(':')?; + (line.trim_start().starts_with("protocol:")).then(|| value.trim().parse().ok())? + }) +} + +fn confirm_remote_server_stop(target: &str, protocol: Option<u32>) -> io::Result<()> { + if !io::stdin().is_terminal() { + return Err(io::Error::other(format!( + "remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}; run from an interactive terminal to approve stopping it", + protocol_label(protocol) + ))); + } + + eprintln!( + "remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}.", + protocol_label(protocol) + ); + eprint!("Install/replace the remote binary if needed and stop the running remote server now? This will kill the remote Herdr server. [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 == "n" || answer == "no" { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr server replacement cancelled", + )); + } + + Ok(()) +} + +fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let command = format!("{} server stop", remote_herdr.shell_path); + let output = ssh_output(target, &command)?; + if output.status.success() { + Ok(()) + } else { + Err(command_failed("remote server stop failed", &output)) + } +} + +fn protocol_label(protocol: Option<u32>) -> String { + protocol + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + fn warn_if_remote_bin_not_on_path(target: &str) -> io::Result<()> { let output = ssh_output( target, @@ -986,7 +1113,7 @@ mod tests { os: "linux", arch: "x86_64", }); - let stdout = format!("/usr/bin/herdr\nherdr {CURRENT_VERSION}\n"); + let stdout = matching_path_probe_stdout("/usr/bin/herdr"); let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary"); @@ -1002,7 +1129,7 @@ mod tests { os: "linux", arch: "x86_64", }); - let stdout = format!("/opt/herdr bin/herdr\nherdr {CURRENT_VERSION}\n"); + let stdout = matching_path_probe_stdout("/opt/herdr bin/herdr"); let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary"); @@ -1018,7 +1145,7 @@ mod tests { os: "macos", arch: "aarch64", }); - let stdout = format!("/opt/homebrew/bin/herdr\nherdr {CURRENT_VERSION}\n"); + let stdout = matching_path_probe_stdout("/opt/homebrew/bin/herdr"); let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary"); @@ -1035,7 +1162,7 @@ mod tests { os: "linux", arch: "x86_64", }); - let stdout = format!("/opt/herdr's/bin/herdr\nherdr {CURRENT_VERSION}\n"); + let stdout = matching_path_probe_stdout("/opt/herdr's/bin/herdr"); let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary"); @@ -1051,8 +1178,10 @@ mod tests { os: "linux", arch: "x86_64", }); - let remote_herdr = - remote_herdr_from_path_probe(&remote_herdr, "/usr/bin/herdr\nherdr 0.0.0\n"); + let remote_herdr = remote_herdr_from_path_probe( + &remote_herdr, + &format!("/usr/bin/herdr\nherdr 0.0.0\nprotocol: {CURRENT_PROTOCOL}\n"), + ); assert!(remote_herdr.is_none()); } @@ -1063,13 +1192,38 @@ mod tests { os: "linux", arch: "x86_64", }); - let stdout = format!("bin/herdr\nherdr {CURRENT_VERSION}\n"); + let stdout = matching_path_probe_stdout("bin/herdr"); let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout); assert!(remote_herdr.is_none()); } #[test] + fn remote_path_probe_ignores_protocol_mismatch() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let stdout = format!("/usr/bin/herdr\nherdr {CURRENT_VERSION}\nprotocol: 0\n"); + let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout); + + assert!(remote_herdr.is_none()); + } + + #[test] + fn parse_status_protocol_reads_protocol_line() { + assert_eq!( + parse_status_protocol("version: x\nprotocol: 8\nbinary: y"), + Some(8) + ); + assert_eq!( + parse_status_protocol("status: running\nprotocol: unknown"), + None + ); + assert_eq!(parse_status_protocol("status: not running"), None); + } + + #[test] fn install_source_description_uses_override_binary() { let platform = RemotePlatform { os: "linux", @@ -1093,6 +1247,10 @@ mod tests { assert!(source.temporary_dir.is_none()); } + fn matching_path_probe_stdout(path: &str) -> String { + format!("{path}\nherdr {CURRENT_VERSION}\nprotocol: {CURRENT_PROTOCOL}\n") + } + 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(())) diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 971c879f..fc6ab0be 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -14,7 +14,8 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use crate::server::protocol::{ - self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, + MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -58,6 +59,12 @@ pub(crate) enum ServerEvent { }, /// A client sent an input message. ClientInput { client_id: u64, data: Vec<u8> }, + /// A client sent local clipboard image bytes to paste into a remote pane. + ClientClipboardImage { + client_id: u64, + extension: String, + data: Vec<u8>, + }, /// A client requested direct attach to one terminal. ClientAttachTerminal { client_id: u64, @@ -305,7 +312,8 @@ fn client_read_loop( should_quit: &Arc<AtomicBool>, ) -> io::Result<()> { while !should_quit.load(Ordering::Acquire) { - let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) { + let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) + { Ok(msg) => msg, Err(protocol::FramingError::UnexpectedEof) => { // Client disconnected. @@ -339,11 +347,31 @@ fn client_read_loop( size = data.len(), "oversized input from client, closing" ); - ServerEvent::ClientDisconnected { client_id } + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; } else { ServerEvent::ClientInput { client_id, data } } } + ClientMessage::ClipboardImage { extension, data } => { + if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { + warn!( + client_id, + size = data.len(), + "oversized clipboard image from client, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } else { + ServerEvent::ClientClipboardImage { + client_id, + extension, + data, + } + } + } ClientMessage::Resize { cols, rows, diff --git a/src/server/clipboard_image.rs b/src/server/clipboard_image.rs new file mode 100644 index 00000000..299267d3 --- /dev/null +++ b/src/server/clipboard_image.rs @@ -0,0 +1,128 @@ +use std::fs; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const STAGED_CLIPBOARD_IMAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); + +pub(crate) struct StagedClipboardImage { + pub(crate) path: PathBuf, + pub(crate) paste_text: String, +} + +pub(crate) fn stage( + client_id: u64, + 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); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + for attempt in 0..100 { + 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) + { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + }; + file.write_all(data)?; + return Ok(StagedClipboardImage { + paste_text: path.to_string_lossy().into_owned(), + path, + }); + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to allocate unique clipboard image staging path", + )) +} + +pub(crate) fn remove_files(paths: Vec<PathBuf>) { + for path in paths { + let _ = fs::remove_file(path); + } +} + +fn sanitize_extension(extension: &str) -> &'static str { + if extension.eq_ignore_ascii_case("png") { + "png" + } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { + "jpg" + } else if extension.eq_ignore_ascii_case("gif") { + "gif" + } else if extension.eq_ignore_ascii_case("webp") { + "webp" + } else if extension.eq_ignore_ascii_case("bmp") { + "bmp" + } else { + "png" + } +} + +fn staging_dir() -> PathBuf { + let user_id = unsafe { libc::geteuid() }; + 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)?; + if !metadata.is_dir() { + return Err(io::Error::other(format!( + "clipboard image staging path is not a directory: {}", + dir.display() + ))); + } + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + Ok(dir) +} + +fn cleanup_stale(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + if modified.elapsed().unwrap_or_default() > STAGED_CLIPBOARD_IMAGE_MAX_AGE { + let _ = fs::remove_file(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_extension_accepts_known_image_extensions() { + assert_eq!(sanitize_extension("PNG"), "png"); + assert_eq!(sanitize_extension("jpeg"), "jpg"); + assert_eq!(sanitize_extension("webp"), "webp"); + assert_eq!(sanitize_extension("sh"), "png"); + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 56164cfa..ad7ea03a 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -64,6 +64,18 @@ enum LoopEvent { const MIN_COLS: u16 = 80; const MIN_ROWS: u16 = 24; +fn paste_payload_for_runtime(runtime: &crate::terminal::TerminalRuntime, text: &str) -> String { + if runtime + .input_state() + .map(|state| state.bracketed_paste) + .unwrap_or(false) + { + format!("\x1b[200~{text}\x1b[201~") + } else { + text.to_owned() + } +} + /// Legacy environment variable for overriding the client socket path. /// /// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`. @@ -237,6 +249,8 @@ struct ClientConnection { render_pending: bool, /// Last host mouse capture mode sent to this client. host_mouse_capture_active: Option<bool>, + /// Temporary files staged from this client's local clipboard image pastes. + staged_clipboard_files: Vec<PathBuf>, /// Channels for sending framed ServerMessage data to the client writer thread. writer: Option<ClientWriter>, } @@ -285,6 +299,7 @@ impl ClientConnection { graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), render_pending: false, host_mouse_capture_active: None, + staged_clipboard_files: Vec::new(), writer, } } @@ -653,17 +668,16 @@ impl HeadlessServer { let was_foreground = self.foreground_client_id == Some(client_id); self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); - if let Some(ClientConnection { - mode: ClientConnectionMode::TerminalAttach { terminal_id }, - .. - }) = removed - { - self.terminal_attach_owners.remove(&terminal_id); - if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) { - self.app - .state - .direct_attach_resize_locks - .remove(&terminal_id); + if let Some(removed) = removed { + crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); + if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { + self.terminal_attach_owners.remove(&terminal_id); + if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) { + self.app + .state + .direct_attach_resize_locks + .remove(&terminal_id); + } } } if was_foreground { @@ -834,6 +848,49 @@ impl HeadlessServer { self.app.state.terminal_runtimes.get(&terminal_id) } + fn write_client_clipboard_image( + &mut self, + client_id: u64, + extension: &str, + data: &[u8], + ) -> std::io::Result<String> { + let staged = crate::server::clipboard_image::stage(client_id, extension, data)?; + if let Some(client) = self.clients.get_mut(&client_id) { + client.staged_clipboard_files.push(staged.path); + } + info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image"); + Ok(staged.paste_text) + } + + fn paste_client_clipboard_image_path(&mut self, client_id: u64, path: String) -> bool { + if let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = self.clients.get(&client_id) + { + if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { + let payload = paste_payload_for_runtime(runtime, &path); + if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed"); + } + } + return true; + } + + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size(); + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.request_semantic_redraw_after_input(); + } + self.app.route_client_events( + vec![crate::raw_input::RawInputEvent::Paste(path)], + self.foreground_client_id == Some(client_id), + ); + true + } + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { self.app .state @@ -1451,6 +1508,25 @@ impl HeadlessServer { foreground_changed || theme_changed || interaction } } + ServerEvent::ClientClipboardImage { + client_id, + extension, + data, + } => { + debug!( + client_id, + len = data.len(), + extension = %extension, + "client clipboard image received" + ); + match self.write_client_clipboard_image(client_id, &extension, &data) { + Ok(path) => self.paste_client_clipboard_image_path(client_id, path), + Err(err) => { + warn!(client_id, err = %err, "failed to stage client clipboard image"); + true + } + } + } ServerEvent::ClientResize { client_id, cols, @@ -2101,7 +2177,12 @@ impl HeadlessServer { self.drain_api_requests_with_shutdown_check(); // Close all client connections. - self.clients.clear(); + let staged_files = self + .clients + .drain() + .flat_map(|(_, client)| client.staged_clipboard_files) + .collect::<Vec<_>>(); + crate::server::clipboard_image::remove_files(staged_files); // Remove socket files. self.cleanup_sockets()?; @@ -2126,6 +2207,12 @@ impl HeadlessServer { impl Drop for HeadlessServer { fn drop(&mut self) { + let staged_files = self + .clients + .drain() + .flat_map(|(_, client)| client.staged_clipboard_files) + .collect::<Vec<_>>(); + crate::server::clipboard_image::remove_files(staged_files); let _ = self.cleanup_sockets(); } } diff --git a/src/server/mod.rs b/src/server/mod.rs index eab079ee..b41e2a67 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,5 +1,6 @@ pub mod autodetect; pub(crate) mod client_transport; +pub(crate) mod clipboard_image; pub mod headless; pub mod protocol; pub(crate) mod render_stream; diff --git a/src/server/protocol.rs b/src/server/protocol.rs index abc3abec..6710bc80 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 7; +pub const PROTOCOL_VERSION: u32 = 8; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -24,6 +24,9 @@ pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024; /// image payloads that are naturally much larger after base64 encoding. pub const MAX_GRAPHICS_FRAME_SIZE: usize = 32 * 1024 * 1024; +/// Maximum clipboard image payload size for remote paste bridging. +pub const MAX_CLIPBOARD_IMAGE_PAYLOAD: usize = 16 * 1024 * 1024; + /// Length of the u32 little-endian length prefix in bytes. const LENGTH_PREFIX_BYTES: usize = 4; @@ -65,6 +68,14 @@ pub enum ClientMessage { data: Vec<u8>, }, + /// Image bytes read from the client's local clipboard for remote paste bridging. + ClipboardImage { + /// Image file extension without a leading dot. + extension: String, + /// Raw image bytes. + data: Vec<u8>, + }, + /// Terminal resize notification from the client. Resize { /// New terminal width in columns. @@ -616,6 +627,18 @@ mod tests { } #[test] + fn client_clipboard_image_roundtrip() { + let msg = ClientMessage::ClipboardImage { + extension: "png".to_owned(), + data: vec![0x89, b'P', b'N', b'G'], + }; + 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_large_multilingual_payload_roundtrip() { let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024); assert!(text.len() > 64 * 1024); @@ -933,7 +956,7 @@ mod tests { let mut expected = Vec::new(); for i in 0..150u32 { - let msg = match i % 4 { + let msg = match i % 5 { 0 => ClientMessage::Hello { version: PROTOCOL_VERSION, cols: (80 + (i % 40) as u16), @@ -945,13 +968,17 @@ mod tests { 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], }, - 2 => ClientMessage::Resize { + 2 => ClientMessage::ClipboardImage { + extension: "png".to_owned(), + data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8], + }, + 3 => ClientMessage::Resize { cols: (100 + (i % 30) as u16), rows: (30 + (i % 10) as u16), cell_width_px: 8, cell_height_px: 16, }, - 3 => ClientMessage::Detach, + 4 => ClientMessage::Detach, _ => unreachable!(), }; write_message(&mut buf, &msg).unwrap(); @@ -1408,6 +1435,10 @@ mod tests { ClientMessage::Input { data: b"hello world".to_vec(), }, + ClientMessage::ClipboardImage { + extension: "png".to_owned(), + data: vec![0x89, b'P', b'N', b'G'], + }, ClientMessage::Resize { cols: 100, rows: 30, |
