aboutsummaryrefslogtreecommitdiff
path: root/src/client/clipboard_images.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/clipboard_images.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs248
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
+ }
+}