返回 CodeWhale
clipboard.rs
根目录 / crates / tui / src / tui / clipboard.rs
1 //! Clipboard handling for paste support in TUI
2 //!
3 //! Supports text and image paste operations. Images on the clipboard are
4 //! encoded as PNG and persisted under `~/.codewhale/clipboard-images/` so the
5 //! model can reach them via the existing `@`-mention / file tools (DeepSeek
6 //! V4 does not currently accept inline image input on its Chat Completions
7 //! endpoint, so we materialize the bytes to disk instead of base64-embedding
8 //! them in the request).
9 //!
10 //! OpenHarmony deliberately excludes native desktop/Wayland clipboard APIs.
11 //! Copy falls back to OSC 52 (or tmux `load-buffer -w`), paste arrives through
12 //! terminal input, and image clipboard reads are unavailable.
13
14 use std::ffi::OsStr;
15 #[cfg(any(not(test), all(test, unix)))]
16 use std::io::Write;
17 #[cfg(not(test))]
18 use std::io::{self, IsTerminal};
19 use std::path::{Path, PathBuf};
20 #[cfg(any(not(test), all(test, unix)))]
21 use std::process::{Command, Stdio};
22 #[cfg(any(
23 target_os = "macos",
24 target_os = "windows",
25 all(target_os = "linux", not(target_env = "ohos"))
26 ))]
27 use std::time::{SystemTime, UNIX_EPOCH};
28
29 use anyhow::{Context, Result, bail};
30 #[cfg(any(
31 target_os = "macos",
32 target_os = "windows",
33 all(target_os = "linux", not(target_env = "ohos"))
34 ))]
35 use arboard::{Clipboard, ImageData};
36 use base64::Engine as _;
37 #[cfg(any(
38 target_os = "macos",
39 target_os = "windows",
40 all(target_os = "linux", not(target_env = "ohos"))
41 ))]
42 use image::{ImageBuffer, Rgba};
43
44 const OSC52_MAX_BYTES: usize = 100 * 1024;
45
46 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
47 enum ClipboardEndpoint {
48 /// The TUI and desktop clipboard live on the same host.
49 NativeHost,
50 /// SSH exported a graphical display (X11 or Wayland), so the native
51 /// clipboard intentionally addresses that forwarded display.
52 ForwardedDisplay,
53 /// No graphical endpoint is available over SSH. Clipboard transfer must
54 /// be requested from the terminal client instead.
55 TerminalClient,
56 }
57
58 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59 enum ClipboardWriteOrder {
60 /// An SSH TUI without an exported graphical display must target the
61 /// terminal client. A native clipboard on the remote host can succeed
62 /// while writing to the wrong machine.
63 TerminalClientOnly,
64 /// A local TUI should prefer the native clipboard (including images) and
65 /// retain OSC 52 as the terminal fallback.
66 NativeHostThenTerminal,
67 }
68
69 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
70 struct TerminalClipboardContext {
71 endpoint: ClipboardEndpoint,
72 in_tmux: bool,
73 }
74
75 impl TerminalClipboardContext {
76 fn detect() -> Self {
77 let ssh_client = std::env::var_os("SSH_CLIENT");
78 let ssh_connection = std::env::var_os("SSH_CONNECTION");
79 let ssh_tty = std::env::var_os("SSH_TTY");
80 let display = std::env::var_os("DISPLAY");
81 let wayland_display = std::env::var_os("WAYLAND_DISPLAY");
82 let ssh_clipboard = std::env::var_os("CODEWHALE_SSH_CLIPBOARD");
83 let tmux = std::env::var_os("TMUX");
84 Self::from_env_values(
85 ssh_client.as_deref(),
86 ssh_connection.as_deref(),
87 ssh_tty.as_deref(),
88 display.as_deref(),
89 wayland_display.as_deref(),
90 ssh_clipboard.as_deref(),
91 tmux.as_deref(),
92 )
93 }
94
95 fn from_env_values(
96 ssh_client: Option<&OsStr>,
97 ssh_connection: Option<&OsStr>,
98 ssh_tty: Option<&OsStr>,
99 display: Option<&OsStr>,
100 wayland_display: Option<&OsStr>,
101 ssh_clipboard: Option<&OsStr>,
102 tmux: Option<&OsStr>,
103 ) -> Self {
104 let in_ssh_session = [ssh_client, ssh_connection, ssh_tty]
105 .into_iter()
106 .flatten()
107 .any(|value| !value.is_empty());
108 let has_graphical_display = [display, wayland_display]
109 .into_iter()
110 .flatten()
111 .any(|value| !value.is_empty());
112 let forwarded_x11 = display.and_then(OsStr::to_str).is_some_and(|value| {
113 ["localhost:", "127.0.0.1:", "[::1]:", "::1:"]
114 .iter()
115 .any(|prefix| value.starts_with(prefix))
116 });
117 let use_graphical_display = match ssh_clipboard.and_then(OsStr::to_str) {
118 Some("graphical") => has_graphical_display,
119 Some("terminal") => false,
120 _ => forwarded_x11,
121 };
122
123 Self {
124 // OpenSSH normally exports SSH_CLIENT and SSH_CONNECTION.
125 // SSH_TTY is an additional PTY-only marker and is independently
126 // sufficient when wrappers preserve it without the other two.
127 endpoint: match (in_ssh_session, use_graphical_display) {
128 (false, _) => ClipboardEndpoint::NativeHost,
129 (true, true) => ClipboardEndpoint::ForwardedDisplay,
130 (true, false) => ClipboardEndpoint::TerminalClient,
131 },
132 in_tmux: tmux.is_some_and(|value| !value.is_empty()),
133 }
134 }
135
136 fn write_order(self) -> ClipboardWriteOrder {
137 if self.endpoint == ClipboardEndpoint::TerminalClient {
138 ClipboardWriteOrder::TerminalClientOnly
139 } else {
140 ClipboardWriteOrder::NativeHostThenTerminal
141 }
142 }
143
144 fn permits_native_read(self) -> bool {
145 self.endpoint != ClipboardEndpoint::TerminalClient
146 }
147
148 fn requires_terminal_paste(self) -> bool {
149 self.endpoint == ClipboardEndpoint::TerminalClient
150 }
151 }
152
153 // === Types ===
154
155 /// Metadata captured for a pasted clipboard image. Used by the composer to
156 /// render a status hint like `Pasted 1024x768 image (235KB) → <path>`.
157 #[derive(Clone)]
158 pub struct PastedImage {
159 pub path: PathBuf,
160 pub width: u32,
161 pub height: u32,
162 pub byte_len: usize,
163 }
164
165 impl PastedImage {
166 /// Short human-readable summary, e.g. `1024x768 PNG`.
167 pub fn short_label(&self) -> String {
168 format!("{}x{} PNG", self.width, self.height)
169 }
170
171 /// Approximate file size suffix, e.g. `235KB`.
172 pub fn size_label(&self) -> String {
173 let kb = (self.byte_len as f64 / 1024.0).round() as u64;
174 format!("{kb}KB")
175 }
176 }
177
178 /// Clipboard payloads supported by the TUI.
179 #[cfg_attr(
180 all(
181 any(target_env = "ohos", target_os = "android", target_os = "netbsd"),
182 not(test)
183 ),
184 allow(dead_code)
185 )]
186 pub enum ClipboardContent {
187 Text(String),
188 Image(PastedImage),
189 }
190
191 struct TerminalClipboardWriteRequest {
192 text: String,
193 in_tmux: bool,
194 }
195
196 type TerminalClipboardWriteCompletion = std::result::Result<(), String>;
197
198 /// Serializes terminal-client clipboard writes on a bounded background lane.
199 ///
200 /// OSC 52 ultimately writes to the terminal output stream, which can block
201 /// indefinitely under backpressure. tmux transport can likewise wait on a
202 /// stalled server. Keeping both operations on this worker means copy actions
203 /// never park the TUI input/render loop, while the single request slot bounds
204 /// memory and preserves copy order.
205 struct TerminalClipboardWriter {
206 request_tx: std::sync::mpsc::SyncSender<TerminalClipboardWriteRequest>,
207 completion_rx: std::sync::mpsc::Receiver<TerminalClipboardWriteCompletion>,
208 }
209
210 impl TerminalClipboardWriter {
211 #[cfg(not(test))]
212 fn spawn() -> Result<Self> {
213 Self::spawn_with(|request| write_text_to_terminal_client(&request.text, request.in_tmux))
214 }
215
216 fn spawn_with<F>(write: F) -> Result<Self>
217 where
218 F: Fn(TerminalClipboardWriteRequest) -> Result<()> + Send + 'static,
219 {
220 let (request_tx, request_rx) = std::sync::mpsc::sync_channel(1);
221 let (completion_tx, completion_rx) = std::sync::mpsc::channel();
222 std::thread::Builder::new()
223 .name("terminal-clipboard-writer".to_string())
224 .spawn(move || {
225 while let Ok(request) = request_rx.recv() {
226 let completion = write(request).map_err(|err| format!("{err:#}"));
227 if completion_tx.send(completion).is_err() {
228 break;
229 }
230 }
231 })
232 .context("spawn terminal clipboard writer")?;
233 Ok(Self {
234 request_tx,
235 completion_rx,
236 })
237 }
238
239 fn enqueue(&self, text: &str, in_tmux: bool) -> Result<()> {
240 let request = TerminalClipboardWriteRequest {
241 text: text.to_string(),
242 in_tmux,
243 };
244 self.request_tx.try_send(request).map_err(|err| match err {
245 std::sync::mpsc::TrySendError::Full(_) => {
246 anyhow::anyhow!("another terminal clipboard write is still queued")
247 }
248 std::sync::mpsc::TrySendError::Disconnected(_) => {
249 anyhow::anyhow!("terminal clipboard writer stopped")
250 }
251 })
252 }
253
254 fn poll_completion(&self) -> Option<TerminalClipboardWriteCompletion> {
255 self.completion_rx.try_recv().ok()
256 }
257 }
258
259 /// Clipboard reader/writer helper.
260 pub struct ClipboardHandler {
261 terminal_context: TerminalClipboardContext,
262 terminal_writer: Option<TerminalClipboardWriter>,
263 #[cfg(any(
264 target_os = "macos",
265 target_os = "windows",
266 all(target_os = "linux", not(target_env = "ohos"))
267 ))]
268 clipboard: Option<Clipboard>,
269 #[cfg(any(
270 target_os = "macos",
271 target_os = "windows",
272 all(target_os = "linux", not(target_env = "ohos"))
273 ))]
274 clipboard_init_attempted: bool,
275 #[cfg(test)]
276 written_text: Vec<String>,
277 #[cfg(test)]
278 fail_text_writes: bool,
279 }
280
281 impl ClipboardHandler {
282 /// Create a new clipboard handler without connecting.
283 ///
284 /// The actual clipboard connection is deferred to first use
285 /// (`ensure_clipboard`) so that startup on hosts without an X11/Wayland
286 /// server (headless, WSL2) never blocks the TUI event loop.
287 pub fn new() -> Self {
288 Self::with_terminal_context(TerminalClipboardContext::detect())
289 }
290
291 fn with_terminal_context(terminal_context: TerminalClipboardContext) -> Self {
292 Self {
293 terminal_context,
294 terminal_writer: None,
295 #[cfg(any(
296 target_os = "macos",
297 target_os = "windows",
298 all(target_os = "linux", not(target_env = "ohos"))
299 ))]
300 clipboard: None,
301 #[cfg(any(
302 target_os = "macos",
303 target_os = "windows",
304 all(target_os = "linux", not(target_env = "ohos"))
305 ))]
306 clipboard_init_attempted: false,
307 #[cfg(test)]
308 written_text: Vec::new(),
309 #[cfg(test)]
310 fail_text_writes: false,
311 }
312 }
313
314 #[cfg(test)]
315 pub(crate) fn for_test(in_ssh_session: bool, in_tmux: bool) -> Self {
316 Self::with_terminal_context(TerminalClipboardContext {
317 endpoint: if in_ssh_session {
318 ClipboardEndpoint::TerminalClient
319 } else {
320 ClipboardEndpoint::NativeHost
321 },
322 in_tmux,
323 })
324 }
325
326 /// Construct a deterministic unavailable clipboard for command tests.
327 #[cfg(test)]
328 pub(crate) fn unavailable_for_test(in_ssh_session: bool) -> Self {
329 let mut handler = Self::for_test(in_ssh_session, false);
330 handler.fail_text_writes = true;
331 handler
332 }
333
334 /// SSH without a forwarded graphical display cannot synchronously read
335 /// the terminal client's clipboard. Paste must be initiated by the local
336 /// terminal so it arrives as bracketed paste (or a raw paste burst on
337 /// older terminals).
338 pub(crate) fn requires_terminal_paste(&self) -> bool {
339 self.terminal_context.requires_terminal_paste()
340 }
341
342 /// Try to connect to the system clipboard, bounded by a short timeout.
343 ///
344 /// On Linux, `arboard::Clipboard::new()` opens a blocking X11 connection.
345 /// When no X server is running (headless, WSL2 without WSLg), the connect
346 /// call can hang indefinitely. We spawn the connection attempt on a
347 /// temporary thread and give it 500 ms; if it doesn't return in time the
348 /// handler stays in fallback/no-op mode and `read`/`write_text` fall
349 /// through to their OSC 52 and pbcopy/powershell fallbacks.
350 #[cfg(any(
351 target_os = "macos",
352 target_os = "windows",
353 all(target_os = "linux", not(target_env = "ohos"))
354 ))]
355 fn ensure_clipboard(&mut self) {
356 if self.clipboard_init_attempted {
357 return;
358 }
359 self.clipboard_init_attempted = true;
360
361 let (tx, rx) = std::sync::mpsc::channel();
362 std::thread::spawn(move || {
363 let _ = tx.send(Clipboard::new().ok());
364 });
365 self.clipboard = rx
366 .recv_timeout(std::time::Duration::from_millis(500))
367 .ok()
368 .flatten();
369 }
370
371 /// Read the clipboard and return the parsed content.
372 ///
373 /// `workspace` is used as a fallback location when `~/.codewhale/` cannot
374 /// be resolved (e.g. running with a stripped HOME in CI sandboxes).
375 pub fn read(&mut self, workspace: &Path) -> Option<ClipboardContent> {
376 // With no display exported over SSH there is no synchronously readable
377 // clipboard endpoint. A forwarded X11/Wayland display is explicit and
378 // remains readable, including its image clipboard.
379 if !self.terminal_context.permits_native_read() {
380 return None;
381 }
382
383 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
384 if let Ok(text) = read_text_with_wlpaste() {
385 return Some(ClipboardContent::Text(text));
386 }
387
388 #[cfg(any(
389 target_os = "macos",
390 target_os = "windows",
391 all(target_os = "linux", not(target_env = "ohos"))
392 ))]
393 {
394 self.ensure_clipboard();
395 let clipboard = self.clipboard.as_mut()?;
396 if let Ok(text) = clipboard.get_text() {
397 return Some(ClipboardContent::Text(text));
398 }
399
400 if let Ok(image) = clipboard.get_image()
401 && let Ok(pasted) = save_image_as_png(workspace, &image)
402 {
403 return Some(ClipboardContent::Image(pasted));
404 }
405 }
406
407 let _ = workspace;
408 None
409 }
410
411 /// Write text to the clipboard.
412 ///
413 /// Native clipboard transports complete before this method returns. OSC 52
414 /// and tmux terminal-client writes are validated and admitted to a bounded
415 /// background worker; asynchronous transport failures are exposed through
416 /// [`Self::poll_write_completion`].
417 pub fn write_text(&mut self, text: &str) -> Result<()> {
418 #[cfg(test)]
419 {
420 if let Some(writer) = self.terminal_writer.as_ref() {
421 return writer.enqueue(text, self.terminal_context.in_tmux);
422 }
423 if self.fail_text_writes {
424 bail!("test clipboard unavailable");
425 }
426 self.written_text.push(text.to_string());
427 Ok(())
428 }
429
430 #[cfg(not(test))]
431 {
432 if self.terminal_context.write_order() == ClipboardWriteOrder::TerminalClientOnly {
433 return self
434 .enqueue_terminal_write(text)
435 .map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}"));
436 }
437
438 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
439 if write_text_with_wlcopy(text).is_ok() {
440 return Ok(());
441 }
442
443 #[cfg(any(
444 target_os = "macos",
445 target_os = "windows",
446 all(target_os = "linux", not(target_env = "ohos"))
447 ))]
448 {
449 self.ensure_clipboard();
450 if let Some(clipboard) = self.clipboard.as_mut()
451 && clipboard.set_text(text.to_string()).is_ok()
452 {
453 return Ok(());
454 }
455 }
456
457 #[cfg(target_os = "macos")]
458 if write_text_with_pbcopy(text).is_ok() {
459 return Ok(());
460 }
461
462 #[cfg(target_os = "windows")]
463 if write_text_with_set_clipboard(text).is_ok() {
464 return Ok(());
465 }
466
467 self.enqueue_terminal_write(text)
468 .map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}"))
469 }
470 }
471
472 #[cfg(not(test))]
473 fn enqueue_terminal_write(&mut self, text: &str) -> Result<()> {
474 if !self.terminal_context.in_tmux {
475 if text.len() > OSC52_MAX_BYTES {
476 bail!("selection is too large for OSC 52 clipboard fallback");
477 }
478 if !io::stdout().is_terminal() {
479 bail!("OSC 52 clipboard fallback requires a terminal");
480 }
481 }
482
483 if self.terminal_writer.is_none() {
484 self.terminal_writer = Some(TerminalClipboardWriter::spawn()?);
485 }
486 self.terminal_writer
487 .as_ref()
488 .expect("terminal clipboard writer initialized")
489 .enqueue(text, self.terminal_context.in_tmux)
490 }
491
492 /// Return one completed background terminal clipboard write, if available.
493 ///
494 /// Successes are intentionally quiet because callers already show their
495 /// normal copy receipt. Failures are drained by the event loop and replace
496 /// that optimistic receipt with an actionable error.
497 pub(crate) fn poll_write_completion(&self) -> Option<TerminalClipboardWriteCompletion> {
498 self.terminal_writer
499 .as_ref()
500 .and_then(TerminalClipboardWriter::poll_completion)
501 }
502
503 #[cfg(test)]
504 pub fn last_written_text(&self) -> Option<&str> {
505 self.written_text.last().map(String::as_str)
506 }
507 }
508
509 #[cfg(all(target_os = "macos", not(test)))]
510 fn write_text_with_pbcopy(text: &str) -> Result<()> {
511 write_text_with_stdin_command("pbcopy", &[], text, "pbcopy")
512 }
513
514 #[cfg(all(target_os = "windows", not(test)))]
515 fn write_text_with_set_clipboard(text: &str) -> Result<()> {
516 write_text_with_stdin_command(
517 "powershell.exe",
518 &["-NoProfile", "-Command", "Set-Clipboard -Value $input"],
519 text,
520 "Set-Clipboard",
521 )
522 }
523
524 #[cfg(all(any(target_os = "macos", target_os = "windows"), not(test)))]
525 fn write_text_with_stdin_command(
526 program: &str,
527 args: &[&str],
528 text: &str,
529 label: &str,
530 ) -> Result<()> {
531 let mut child = Command::new(program)
532 .args(args)
533 .stdin(Stdio::piped())
534 .stdout(Stdio::null())
535 .stderr(Stdio::null())
536 .spawn()
537 .map_err(|e| anyhow::anyhow!("Failed to run {label}: {e}"))?;
538 if let Some(mut stdin) = child.stdin.take() {
539 stdin
540 .write_all(text.as_bytes())
541 .map_err(|e| anyhow::anyhow!("Failed to write to {label}: {e}"))?;
542 }
543 let _ = std::thread::Builder::new()
544 .name("clipboard-wait".to_string())
545 .spawn(move || {
546 let _ = child.wait();
547 });
548 Ok(())
549 }
550
551 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
552 fn write_text_with_wlcopy(text: &str) -> Result<()> {
553 write_text_with_wlcopy_using_argv("wl-copy", text)
554 }
555
556 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
557 fn read_text_with_wlpaste() -> Result<String> {
558 read_text_with_wlpaste_using_argv("wl-paste")
559 }
560
561 #[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))]
562 fn read_text_with_wlpaste_using_argv(program: &str) -> Result<String> {
563 let output = Command::new(program)
564 .arg("--no-newline")
565 .arg("--type")
566 .arg("text/plain")
567 .stdout(Stdio::piped())
568 .stderr(Stdio::null())
569 .output()
570 .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
571 if !output.status.success() {
572 bail!("{program} exited with {}", output.status);
573 }
574 String::from_utf8(output.stdout).context("wl-paste returned non-UTF-8 text")
575 }
576
577 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
578 fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> {
579 let mut child = Command::new(program)
580 .stdin(Stdio::piped())
581 .stdout(Stdio::null())
582 .stderr(Stdio::null())
583 .spawn()
584 .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
585 if let Some(mut stdin) = child.stdin.take() {
586 stdin
587 .write_all(text.as_bytes())
588 .map_err(|e| anyhow::anyhow!("Failed to write to {program}: {e}"))?;
589 }
590 // stdin is dropped here, closing the pipe so wl-copy flushes.
591 let status = child
592 .wait()
593 .map_err(|e| anyhow::anyhow!("Failed to wait on {program}: {e}"))?;
594 if !status.success() {
595 bail!("{program} exited with {status}");
596 }
597 Ok(())
598 }
599
600 #[cfg(not(test))]
601 fn write_text_to_terminal_client(text: &str, in_tmux: bool) -> Result<()> {
602 if in_tmux {
603 return write_text_with_tmux(text);
604 }
605 write_text_with_osc52(text)
606 }
607
608 #[cfg(not(test))]
609 fn write_text_with_tmux(text: &str) -> Result<()> {
610 write_text_with_tmux_using_argv("tmux", &[], text)
611 }
612
613 /// Ask tmux to set both its paste buffer and the attached client's clipboard.
614 /// Unlike DCS passthrough, `load-buffer -w` works with tmux's default
615 /// `allow-passthrough off` policy and returns a non-zero status when tmux
616 /// cannot honor the command.
617 #[cfg(any(not(test), all(test, unix)))]
618 fn write_text_with_tmux_using_argv(program: &str, prefix_args: &[&str], text: &str) -> Result<()> {
619 let mut child = Command::new(program)
620 .args(prefix_args)
621 .args(["load-buffer", "-w", "-"])
622 .stdin(Stdio::piped())
623 .stdout(Stdio::null())
624 .stderr(Stdio::piped())
625 .spawn()
626 .map_err(|e| anyhow::anyhow!("Failed to run tmux load-buffer -w: {e}"))?;
627
628 let write_result = child
629 .stdin
630 .take()
631 .context("open tmux clipboard input")
632 .and_then(|mut stdin| {
633 stdin
634 .write_all(text.as_bytes())
635 .context("write tmux clipboard input")
636 });
637 let output = child
638 .wait_with_output()
639 .context("wait for tmux load-buffer -w")?;
640 write_result?;
641 if !output.status.success() {
642 let detail = String::from_utf8_lossy(&output.stderr);
643 let detail = detail.trim();
644 if detail.is_empty() {
645 bail!("tmux load-buffer -w exited with {}", output.status);
646 }
647 bail!(
648 "tmux load-buffer -w exited with {}: {detail}",
649 output.status
650 );
651 }
652 Ok(())
653 }
654
655 #[cfg(not(test))]
656 fn write_text_with_osc52(text: &str) -> Result<()> {
657 let mut stdout = io::stdout();
658 if !stdout.is_terminal() {
659 bail!("OSC 52 clipboard fallback requires a terminal");
660 }
661
662 let sequence = osc52_sequence(text)?;
663 stdout
664 .write_all(sequence.as_bytes())
665 .context("write OSC 52 clipboard sequence")?;
666 stdout.flush().context("flush OSC 52 clipboard sequence")
667 }
668
669 fn osc52_sequence(text: &str) -> Result<String> {
670 if text.len() > OSC52_MAX_BYTES {
671 bail!("selection is too large for OSC 52 clipboard fallback");
672 }
673
674 let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
675 Ok(format!("\x1b]52;c;{encoded}\x07"))
676 }
677
678 /// Resolve the directory pasted images should land in. Prefers
679 /// `~/.codewhale/clipboard-images/` so the path is stable across worktrees and
680 /// matches the location described in user-facing docs; falls back to
681 /// `<workspace>/clipboard-images/` if the home dir is unavailable.
682 pub(crate) fn clipboard_images_dir(workspace: &Path) -> PathBuf {
683 let home = crate::config::effective_home_dir();
684 clipboard_images_dir_for_home(workspace, home.as_deref())
685 }
686
687 fn clipboard_images_dir_for_home(workspace: &Path, home: Option<&Path>) -> PathBuf {
688 if let Some(home) = home {
689 return home.join(".codewhale").join("clipboard-images");
690 }
691 workspace.join("clipboard-images")
692 }
693
694 /// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns
695 /// the resulting path along with metadata used to render the paste hint.
696 #[cfg(any(
697 target_os = "macos",
698 target_os = "windows",
699 all(target_os = "linux", not(target_env = "ohos"))
700 ))]
701 fn save_image_as_png(workspace: &Path, image: &ImageData) -> Result<PastedImage> {
702 save_image_as_png_in(&clipboard_images_dir(workspace), image)
703 }
704
705 /// Lower-level variant that writes into an explicit directory. Exposed so the
706 /// unit tests don't have to scribble inside the user's real home directory.
707 #[cfg(any(
708 target_os = "macos",
709 target_os = "windows",
710 all(target_os = "linux", not(target_env = "ohos"))
711 ))]
712 fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result<PastedImage> {
713 std::fs::create_dir_all(dir).context("create clipboard-images dir")?;
714
715 let timestamp = SystemTime::now()
716 .duration_since(UNIX_EPOCH)
717 .unwrap_or_default()
718 .as_nanos();
719 let path = dir.join(format!("clipboard-{timestamp}.png"));
720
721 let width = u32::try_from(image.width).context("clipboard image width too large")?;
722 let height = u32::try_from(image.height).context("clipboard image height too large")?;
723
724 // arboard hands us RGBA8 row-major. Copy into an ImageBuffer so we can
725 // run it through the `image` crate's PNG encoder. We pad / truncate any
726 // mismatched trailing bytes — defensive only, arboard already validates
727 // the buffer length on every supported backend.
728 let expected = (width as usize) * (height as usize) * 4;
729 let mut rgba = image.bytes.as_ref().to_vec();
730 if rgba.len() < expected {
731 rgba.resize(expected, 0);
732 } else if rgba.len() > expected {
733 rgba.truncate(expected);
734 }
735
736 let buffer: ImageBuffer<Rgba<u8>, _> = ImageBuffer::from_raw(width, height, rgba)
737 .context("clipboard image dimensions did not match buffer length")?;
738 buffer
739 .save_with_format(&path, image::ImageFormat::Png)
740 .context("write clipboard PNG")?;
741
742 let byte_len = std::fs::metadata(&path)
743 .map(|m| m.len() as usize)
744 .unwrap_or(0);
745 Ok(PastedImage {
746 path,
747 width,
748 height,
749 byte_len,
750 })
751 }
752
753 #[cfg(test)]
754 mod tests {
755 use super::*;
756 // ImageData from arboard is only available on these platforms.
757 #[cfg(any(
758 target_os = "macos",
759 target_os = "windows",
760 all(target_os = "linux", not(target_env = "ohos"))
761 ))]
762 use std::borrow::Cow;
763 #[cfg(unix)]
764 use std::os::unix::fs::PermissionsExt;
765
766 #[test]
767 fn terminal_clipboard_write_does_not_wait_for_slow_transport() {
768 let (transport_started_tx, transport_started_rx) = std::sync::mpsc::channel();
769 let (release_transport_tx, release_transport_rx) = std::sync::mpsc::channel();
770 let writer = TerminalClipboardWriter::spawn_with(move |request| {
771 assert_eq!(request.text, "copied");
772 assert!(!request.in_tmux);
773 transport_started_tx
774 .send(())
775 .expect("announce transport start");
776 release_transport_rx.recv().expect("release slow transport");
777 Ok(())
778 })
779 .expect("spawn clipboard writer");
780 let mut clipboard = ClipboardHandler::for_test(true, false);
781 clipboard.terminal_writer = Some(writer);
782
783 let (caller_returned_tx, caller_returned_rx) = std::sync::mpsc::channel();
784 let caller = std::thread::spawn(move || {
785 let result = clipboard.write_text("copied");
786 caller_returned_tx
787 .send((clipboard, result))
788 .expect("report caller completion");
789 });
790
791 let (clipboard, result) =
792 match caller_returned_rx.recv_timeout(std::time::Duration::from_millis(250)) {
793 Ok(value) => value,
794 Err(err) => {
795 let _ = release_transport_tx.send(());
796 caller.join().expect("join clipboard caller");
797 panic!("clipboard caller waited for slow transport: {err}");
798 }
799 };
800 result.expect("queue clipboard write");
801 transport_started_rx
802 .recv_timeout(std::time::Duration::from_millis(250))
803 .expect("worker started transport");
804 assert!(
805 clipboard.poll_write_completion().is_none(),
806 "transport must remain pending until explicitly released"
807 );
808
809 release_transport_tx.send(()).expect("release transport");
810 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
811 loop {
812 if let Some(completion) = clipboard.poll_write_completion() {
813 completion.expect("background clipboard completion");
814 break;
815 }
816 assert!(
817 std::time::Instant::now() < deadline,
818 "background clipboard completion timed out"
819 );
820 std::thread::sleep(std::time::Duration::from_millis(10));
821 }
822 caller.join().expect("join clipboard caller");
823 }
824
825 #[test]
826 fn terminal_clipboard_write_reports_background_failure() {
827 let writer =
828 TerminalClipboardWriter::spawn_with(|_| bail!("terminal clipboard transport denied"))
829 .expect("spawn clipboard writer");
830 writer
831 .enqueue("copied", false)
832 .expect("queue clipboard write");
833
834 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
835 loop {
836 if let Some(completion) = writer.poll_completion() {
837 let err = completion.expect_err("transport should fail");
838 assert!(err.contains("transport denied"), "{err}");
839 break;
840 }
841 assert!(
842 std::time::Instant::now() < deadline,
843 "background clipboard failure timed out"
844 );
845 std::thread::sleep(std::time::Duration::from_millis(10));
846 }
847 }
848
849 #[cfg(any(
850 target_os = "macos",
851 target_os = "windows",
852 all(target_os = "linux", not(target_env = "ohos"))
853 ))]
854 fn solid_rgba(width: u16, height: u16, rgba: [u8; 4]) -> ImageData<'static> {
855 let mut bytes = Vec::with_capacity((width as usize) * (height as usize) * 4);
856 for _ in 0..(width as usize * height as usize) {
857 bytes.extend_from_slice(&rgba);
858 }
859 ImageData {
860 width: width as usize,
861 height: height as usize,
862 bytes: Cow::Owned(bytes),
863 }
864 }
865
866 #[test]
867 #[cfg(any(
868 target_os = "macos",
869 target_os = "windows",
870 all(target_os = "linux", not(target_env = "ohos"))
871 ))]
872 fn save_image_as_png_writes_valid_png() {
873 let dir = tempfile::tempdir().unwrap();
874 let img = solid_rgba(8, 4, [255, 0, 0, 255]);
875 let pasted = save_image_as_png_in(dir.path(), &img).expect("encode png");
876
877 assert_eq!(pasted.width, 8);
878 assert_eq!(pasted.height, 4);
879 assert!(pasted.byte_len > 0);
880 assert_eq!(
881 pasted.path.extension().and_then(|s| s.to_str()),
882 Some("png")
883 );
884
885 // The first eight bytes of any PNG file are the magic signature; if
886 // we ever regress to PPM or another format this will catch it.
887 let header = std::fs::read(&pasted.path).unwrap();
888 assert_eq!(&header[..8], b"\x89PNG\r\n\x1a\n");
889 }
890
891 #[test]
892 fn clipboard_images_dir_uses_codewhale_home_directory() {
893 let home = tempfile::tempdir().unwrap();
894 let workspace = tempfile::tempdir().unwrap();
895
896 assert_eq!(
897 clipboard_images_dir_for_home(workspace.path(), Some(home.path())),
898 home.path().join(".codewhale").join("clipboard-images")
899 );
900 }
901
902 #[test]
903 fn clipboard_images_dir_falls_back_to_workspace_without_home() {
904 let workspace = tempfile::tempdir().unwrap();
905
906 assert_eq!(
907 clipboard_images_dir_for_home(workspace.path(), None),
908 workspace.path().join("clipboard-images")
909 );
910 }
911
912 #[test]
913 fn pasted_image_labels_format_correctly() {
914 let p = PastedImage {
915 path: PathBuf::from("/tmp/x.png"),
916 width: 1024,
917 height: 768,
918 byte_len: 235 * 1024,
919 };
920 assert_eq!(p.short_label(), "1024x768 PNG");
921 assert_eq!(p.size_label(), "235KB");
922 }
923
924 #[test]
925 fn ssh_detection_covers_openssh_markers_and_ignores_empty_values() {
926 let client = TerminalClipboardContext::from_env_values(
927 Some(OsStr::new("192.0.2.10 51234 22")),
928 None,
929 None,
930 None,
931 None,
932 None,
933 None,
934 );
935 let connection = TerminalClipboardContext::from_env_values(
936 None,
937 Some(OsStr::new("192.0.2.10 51234 192.0.2.20 22")),
938 None,
939 None,
940 None,
941 None,
942 None,
943 );
944 let tty = TerminalClipboardContext::from_env_values(
945 None,
946 None,
947 Some(OsStr::new("/dev/pts/4")),
948 None,
949 None,
950 None,
951 None,
952 );
953 let empty = TerminalClipboardContext::from_env_values(
954 Some(OsStr::new("")),
955 Some(OsStr::new("")),
956 Some(OsStr::new("")),
957 Some(OsStr::new("")),
958 Some(OsStr::new("")),
959 Some(OsStr::new("")),
960 Some(OsStr::new("")),
961 );
962
963 assert_eq!(client.endpoint, ClipboardEndpoint::TerminalClient);
964 assert_eq!(connection.endpoint, ClipboardEndpoint::TerminalClient);
965 assert_eq!(tty.endpoint, ClipboardEndpoint::TerminalClient);
966 assert_eq!(empty.endpoint, ClipboardEndpoint::NativeHost);
967 assert!(!empty.in_tmux);
968 }
969
970 #[test]
971 fn ssh_without_display_targets_terminal_client() {
972 let remote_tmux = TerminalClipboardContext::from_env_values(
973 Some(OsStr::new("192.0.2.10 51234 22")),
974 None,
975 None,
976 None,
977 None,
978 None,
979 Some(OsStr::new("/tmp/tmux-1000/default,1,0")),
980 );
981 let local =
982 TerminalClipboardContext::from_env_values(None, None, None, None, None, None, None);
983
984 assert_eq!(
985 remote_tmux.write_order(),
986 ClipboardWriteOrder::TerminalClientOnly
987 );
988 assert!(!remote_tmux.permits_native_read());
989 assert!(remote_tmux.requires_terminal_paste());
990 assert!(remote_tmux.in_tmux);
991 assert_eq!(
992 local.write_order(),
993 ClipboardWriteOrder::NativeHostThenTerminal
994 );
995 assert!(local.permits_native_read());
996 assert!(!local.requires_terminal_paste());
997 }
998
999 #[test]
1000 fn ssh_uses_forwarded_x11_or_explicit_graphical_clipboard_endpoint() {
1001 let x11 = TerminalClipboardContext::from_env_values(
1002 None,
1003 Some(OsStr::new("192.0.2.10 51234 192.0.2.20 22")),
1004 None,
1005 Some(OsStr::new("localhost:10.0")),
1006 None,
1007 None,
1008 None,
1009 );
1010 let wayland = TerminalClipboardContext::from_env_values(
1011 Some(OsStr::new("192.0.2.10 51234 22")),
1012 None,
1013 None,
1014 None,
1015 Some(OsStr::new("wayland-1")),
1016 Some(OsStr::new("graphical")),
1017 None,
1018 );
1019
1020 for context in [x11, wayland] {
1021 assert_eq!(context.endpoint, ClipboardEndpoint::ForwardedDisplay);
1022 assert_eq!(
1023 context.write_order(),
1024 ClipboardWriteOrder::NativeHostThenTerminal
1025 );
1026 assert!(context.permits_native_read());
1027 assert!(!context.requires_terminal_paste());
1028 }
1029
1030 let ambient_remote = TerminalClipboardContext::from_env_values(
1031 Some(OsStr::new("192.0.2.10 51234 22")),
1032 None,
1033 None,
1034 Some(OsStr::new(":0")),
1035 Some(OsStr::new("wayland-0")),
1036 None,
1037 None,
1038 );
1039 assert_eq!(ambient_remote.endpoint, ClipboardEndpoint::TerminalClient);
1040
1041 let forced_terminal = TerminalClipboardContext::from_env_values(
1042 Some(OsStr::new("192.0.2.10 51234 22")),
1043 None,
1044 None,
1045 Some(OsStr::new("localhost:10.0")),
1046 None,
1047 Some(OsStr::new("terminal")),
1048 None,
1049 );
1050 assert_eq!(forced_terminal.endpoint, ClipboardEndpoint::TerminalClient);
1051 }
1052
1053 #[test]
1054 fn osc52_sequence_encodes_text_clipboard_write() {
1055 let sequence = osc52_sequence("hello").expect("sequence");
1056 assert_eq!(sequence, "\x1b]52;c;aGVsbG8=\x07");
1057 }
1058
1059 #[test]
1060 fn osc52_sequence_rejects_oversized_selection() {
1061 let text = "x".repeat(OSC52_MAX_BYTES + 1);
1062 let err = osc52_sequence(&text).expect_err("oversized should fail");
1063 assert!(
1064 err.to_string().contains("too large"),
1065 "unexpected error: {err}"
1066 );
1067 }
1068
1069 #[cfg(unix)]
1070 #[test]
1071 fn tmux_helper_reports_command_failure() {
1072 let dir = tempfile::tempdir().unwrap();
1073 let script = dir.path().join("tmux");
1074 std::fs::write(
1075 &script,
1076 r#"#!/bin/sh
1077 cat >/dev/null
1078 echo 'clipboard denied' >&2
1079 exit 42
1080 "#,
1081 )
1082 .unwrap();
1083 let mut perms = std::fs::metadata(&script).unwrap().permissions();
1084 perms.set_mode(0o755);
1085 std::fs::set_permissions(&script, perms).unwrap();
1086
1087 let err = write_text_with_tmux_using_argv(script.to_str().unwrap(), &[], "copy")
1088 .expect_err("non-zero tmux status should fail");
1089
1090 assert!(err.to_string().contains("exited with"));
1091 assert!(err.to_string().contains("clipboard denied"));
1092 }
1093
1094 #[cfg(all(unix, not(target_env = "ohos")))]
1095 #[test]
1096 fn tmux_load_buffer_w_reaches_attached_client_with_default_passthrough_disabled() {
1097 use std::io::Read as _;
1098
1099 let version = match Command::new("tmux").arg("-V").output() {
1100 Ok(output) if output.status.success() => output,
1101 _ => return,
1102 };
1103 assert!(
1104 String::from_utf8_lossy(&version.stdout).starts_with("tmux "),
1105 "unexpected tmux version output"
1106 );
1107
1108 let nonce = std::time::SystemTime::now()
1109 .duration_since(std::time::UNIX_EPOCH)
1110 .expect("clock after epoch")
1111 .as_nanos();
1112 let socket = format!("codewhale-clipboard-{}-{nonce}", std::process::id());
1113
1114 struct TmuxServer(String);
1115 impl Drop for TmuxServer {
1116 fn drop(&mut self) {
1117 let _ = Command::new("tmux")
1118 .args(["-L", self.0.as_str(), "kill-server"])
1119 .status();
1120 }
1121 }
1122 let server = TmuxServer(socket);
1123 let started = Command::new("tmux")
1124 .args([
1125 "-L",
1126 server.0.as_str(),
1127 "-f",
1128 "/dev/null",
1129 "new-session",
1130 "-d",
1131 ])
1132 .status()
1133 .expect("start isolated tmux server");
1134 assert!(started.success(), "isolated tmux server should start");
1135
1136 let option = |name: &str| {
1137 let output = Command::new("tmux")
1138 .args(["-L", server.0.as_str(), "show-options", "-gv", name])
1139 .output()
1140 .expect("read tmux option");
1141 assert!(output.status.success(), "read tmux option {name}");
1142 String::from_utf8(output.stdout)
1143 .expect("tmux option should be utf-8")
1144 .trim()
1145 .to_string()
1146 };
1147 assert_eq!(option("allow-passthrough"), "off");
1148 assert_eq!(option("set-clipboard"), "external");
1149
1150 let pty_system = portable_pty::native_pty_system();
1151 let pair = pty_system
1152 .openpty(portable_pty::PtySize {
1153 rows: 24,
1154 cols: 80,
1155 pixel_width: 0,
1156 pixel_height: 0,
1157 })
1158 .expect("open attached-client PTY");
1159 let mut attach = portable_pty::CommandBuilder::new("tmux");
1160 for arg in ["-L", server.0.as_str(), "attach-session", "-t", "0"] {
1161 attach.arg(arg);
1162 }
1163 attach.env("TERM", "xterm-256color");
1164 let mut attached_client = pair
1165 .slave
1166 .spawn_command(attach)
1167 .expect("attach tmux client to PTY");
1168 drop(pair.slave);
1169
1170 let mut reader = pair
1171 .master
1172 .try_clone_reader()
1173 .expect("clone attached-client PTY reader");
1174 let (output_tx, output_rx) = std::sync::mpsc::channel();
1175 let reader_thread = std::thread::spawn(move || {
1176 let mut chunk = [0_u8; 4096];
1177 loop {
1178 match reader.read(&mut chunk) {
1179 Ok(0) | Err(_) => break,
1180 Ok(len) => {
1181 if output_tx.send(chunk[..len].to_vec()).is_err() {
1182 break;
1183 }
1184 }
1185 }
1186 }
1187 });
1188
1189 let attach_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1190 loop {
1191 let clients = Command::new("tmux")
1192 .args(["-L", server.0.as_str(), "list-clients"])
1193 .output()
1194 .expect("list attached tmux clients");
1195 if clients.status.success() && !clients.stdout.is_empty() {
1196 break;
1197 }
1198 assert!(
1199 std::time::Instant::now() < attach_deadline,
1200 "tmux client did not attach to the test PTY"
1201 );
1202 std::thread::sleep(std::time::Duration::from_millis(25));
1203 }
1204 while output_rx.try_recv().is_ok() {}
1205
1206 let copied_text = "copy through default tmux";
1207 write_text_with_tmux_using_argv("tmux", &["-L", server.0.as_str()], copied_text)
1208 .expect("tmux-native clipboard request");
1209
1210 let encoded = base64::engine::general_purpose::STANDARD.encode(copied_text.as_bytes());
1211 let expected_receipts = [
1212 format!("\x1b]52;;{encoded}\x07").into_bytes(),
1213 format!("\x1b]52;c;{encoded}\x07").into_bytes(),
1214 format!("\x1b]52;;{encoded}\x1b\\").into_bytes(),
1215 format!("\x1b]52;c;{encoded}\x1b\\").into_bytes(),
1216 ];
1217 let receipt_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1218 let mut attached_output = Vec::new();
1219 let receipt_received = loop {
1220 if expected_receipts.iter().any(|receipt| {
1221 attached_output
1222 .windows(receipt.len())
1223 .any(|window| window == receipt)
1224 }) {
1225 break true;
1226 }
1227 if std::time::Instant::now() >= receipt_deadline {
1228 break false;
1229 }
1230 match output_rx.recv_timeout(std::time::Duration::from_millis(50)) {
1231 Ok(bytes) => attached_output.extend_from_slice(&bytes),
1232 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1233 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break false,
1234 }
1235 };
1236
1237 let buffer = Command::new("tmux")
1238 .args(["-L", server.0.as_str(), "show-buffer"])
1239 .output()
1240 .expect("read tmux buffer");
1241 assert!(buffer.status.success(), "tmux buffer should be readable");
1242 assert_eq!(buffer.stdout, copied_text.as_bytes());
1243
1244 let _ = attached_client.kill();
1245 let _ = attached_client.wait();
1246 drop(pair.master);
1247 drop(output_rx);
1248 let _ = reader_thread.join();
1249
1250 assert!(
1251 receipt_received,
1252 "attached tmux client did not receive the OSC 52 clipboard request: {attached_output:?}"
1253 );
1254 }
1255
1256 #[cfg(unix)]
1257 #[test]
1258 fn wl_paste_helper_reads_text_from_stdout() {
1259 let dir = tempfile::tempdir().unwrap();
1260 let script = dir.path().join("wl-paste");
1261 std::fs::write(
1262 &script,
1263 r#"#!/bin/sh
1264 seen_no_newline=0
1265 seen_text_plain=0
1266 while [ "$#" -gt 0 ]; do
1267 case "$1" in
1268 --no-newline) seen_no_newline=1 ;;
1269 --type)
1270 shift
1271 [ "${1:-}" = "text/plain" ] && seen_text_plain=1
1272 ;;
1273 esac
1274 shift
1275 done
1276 [ "$seen_text_plain" -eq 1 ] || exit 40
1277 if [ "$seen_no_newline" -eq 1 ]; then
1278 printf 'from-wayland'
1279 else
1280 printf 'from-wayland\n'
1281 fi
1282 "#,
1283 )
1284 .unwrap();
1285 let mut perms = std::fs::metadata(&script).unwrap().permissions();
1286 perms.set_mode(0o755);
1287 std::fs::set_permissions(&script, perms).unwrap();
1288
1289 let text = read_text_with_wlpaste_using_argv(script.to_str().unwrap())
1290 .expect("read text through wl-paste helper");
1291
1292 assert_eq!(text, "from-wayland");
1293 }
1294 }
1295
1295 lines RUST