aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-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
5 files changed, 294 insertions, 19 deletions
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,