aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 02:51:59 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 02:51:59 +0300
commited478be9835e984c42bc1e30fe84b5fb01a8c5d4 (patch)
tree353b6ea5c7368a9f3b477bc8f1bbcd248e377577 /src
parent2b97799b6715fd6c355db9c225029765971915e6 (diff)
fix: bridge remote clipboard image paste
refs #205
Diffstat (limited to 'src')
-rw-r--r--src/client/mod.rs58
-rw-r--r--src/platform/fallback.rs7
-rw-r--r--src/platform/linux.rs45
-rw-r--r--src/platform/macos.rs43
-rw-r--r--src/platform/mod.rs6
-rw-r--r--src/remote.rs184
-rw-r--r--src/server/client_transport.rs34
-rw-r--r--src/server/clipboard_image.rs128
-rw-r--r--src/server/headless.rs111
-rw-r--r--src/server/mod.rs1
-rw-r--r--src/server/protocol.rs39
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,