diff options
Diffstat (limited to 'src/server')
| -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 |
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, |
