diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/clipboard_images.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/client/clipboard_images.rs')
| -rw-r--r-- | src/client/clipboard_images.rs | 248 |
1 files changed, 248 insertions, 0 deletions
diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs new file mode 100644 index 00000000..c1c49cbc --- /dev/null +++ b/src/client/clipboard_images.rs @@ -0,0 +1,248 @@ +use std::path::PathBuf; + +use tracing::{info, warn}; + +use crate::ipc::LocalStream; +#[cfg(windows)] +use crate::protocol::ClientInputEvent; +use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; +use crate::protocol::{ClientClipboardImageTarget, ClientMessage}; + +use super::{is_remote_client_process, write_to_server, ClientError}; + +pub(super) fn write_remote_image_to_server( + stream: &mut LocalStream, + target: ClientClipboardImageTarget, + image: crate::platform::ClipboardImage, + source: &'static str, +) -> Result<(), ClientError> { + if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { + warn!( + bytes = image.bytes.len(), + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + source, + "local image is too large to bridge" + ); + return Ok(()); + } + + info!( + bytes = image.bytes.len(), + extension = image.extension, + source, + "bridging local image to remote server" + ); + write_to_server( + stream, + &ClientMessage::ClipboardImage { + target, + extension: image.extension.to_owned(), + data: image.bytes, + }, + ) + .map_err(ClientError::ConnectionLost) +} + +pub(super) fn client_remote_image_paste_key( + config: &crate::config::Config, +) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { + if !is_remote_client_process() { + return None; + } + + match config.remote_image_paste_key() { + Ok(key) => key, + Err(diagnostic) => { + warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); + None + } + } +} + +#[cfg(unix)] +pub(super) fn should_bridge_clipboard_image_paste( + data: &[u8], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if data == b"\x1b[200~\x1b[201~" { + return is_remote_client; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + + 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 + && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) + ) +} + +#[cfg(windows)] +pub(super) fn should_bridge_clipboard_image_events( + events: &[ClientInputEvent], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if !is_remote_client { + return false; + } + if matches!(events, [ClientInputEvent::Paste { text }] if text.is_empty()) { + return true; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + matches!( + events, + [event] + if matches!( + event.to_raw_input_event(), + crate::raw_input::RawInputEvent::Key(key) + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo( + &key, + remote_image_paste_key, + ) + ) + ) +} + +#[cfg(unix)] +pub(super) fn read_image_file_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option<crate::platform::ClipboardImage> { + let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; + read_image_file(path, extension) +} + +#[cfg(windows)] +pub(super) fn read_image_file_from_client_events( + events: &[ClientInputEvent], + is_remote_client: bool, +) -> Option<crate::platform::ClipboardImage> { + let [ClientInputEvent::Paste { text }] = events else { + return None; + }; + let text = normalized_terminal_drop_text(text)?; + let (path, extension) = + image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; + read_image_file(path, extension) +} + +fn read_image_file( + path: PathBuf, + extension: &'static str, +) -> Option<crate::platform::ClipboardImage> { + let metadata = std::fs::metadata(&path).ok()?; + if !metadata.is_file() { + return None; + } + + let file = std::fs::File::open(&path).ok()?; + let bytes = + match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { + crate::platform::LimitedRead::Complete(bytes) => bytes, + crate::platform::LimitedRead::Empty => return None, + crate::platform::LimitedRead::Oversized => { + warn!( + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + "local image file drop is too large to bridge" + ); + return None; + } + }; + + Some(crate::platform::ClipboardImage { bytes, extension }) +} + +#[cfg(unix)] +pub(super) fn image_path_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + let bytes = bracketed_paste_payload(data).unwrap_or(data); + let text = std::str::from_utf8(bytes).ok()?; + let text = normalized_terminal_drop_text(text)?; + let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); + image_path_from_drop_text(&text, is_remote_client) +} + +fn normalized_terminal_drop_text(text: &str) -> Option<&str> { + let text = text.trim_end_matches(['\r', '\n']); + (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) +} + +fn image_path_from_drop_text( + text: &str, + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + if !is_remote_client { + return None; + } + let path = PathBuf::from(text); + if !path.is_absolute() { + return None; + } + let extension = recognized_image_extension(path.extension()?.to_str()?)?; + Some((path, extension)) +} + +#[cfg(unix)] +fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { + const START: &[u8] = b"\x1b[200~"; + const END: &[u8] = b"\x1b[201~"; + data.strip_prefix(START)?.strip_suffix(END) +} + +fn strip_matching_path_quotes(text: &str) -> &str { + if text.len() < 2 { + return text; + } + + let bytes = text.as_bytes(); + match (bytes.first(), bytes.last()) { + (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], + _ => text, + } +} + +#[cfg(unix)] +fn unescape_terminal_drop_path(text: &str) -> String { + let mut unescaped = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(escaped) = chars.next() { + unescaped.push(escaped); + } else { + unescaped.push(ch); + } + } else { + unescaped.push(ch); + } + } + unescaped +} + +fn recognized_image_extension(extension: &str) -> Option<&'static str> { + if extension.eq_ignore_ascii_case("png") { + Some("png") + } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { + Some("jpg") + } else if extension.eq_ignore_ascii_case("gif") { + Some("gif") + } else if extension.eq_ignore_ascii_case("webp") { + Some("webp") + } else if extension.eq_ignore_ascii_case("bmp") { + Some("bmp") + } else { + None + } +} |
