返回 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 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
15 mod primary;
16
17 use std::ffi::OsStr;
18 #[cfg(any(not(test), all(test, unix)))]
19 use std::io::Write;
20 #[cfg(not(test))]
21 use std::io::{self, IsTerminal};
22 use std::path::{Path, PathBuf};
23 #[cfg(any(not(test), all(test, unix)))]
24 use std::process::{Command, Stdio};
25 #[cfg(any(
26 target_os = "macos",
27 target_os = "windows",
28 all(target_os = "linux", not(target_env = "ohos"))
29 ))]
30 use std::time::{SystemTime, UNIX_EPOCH};
31
32 use anyhow::{Context, Result, bail};
33 #[cfg(any(
34 target_os = "macos",
35 target_os = "windows",
36 all(target_os = "linux", not(target_env = "ohos"))
37 ))]
38 use arboard::{Clipboard, ImageData};
39 use base64::Engine as _;
40 #[cfg(any(
41 target_os = "macos",
42 target_os = "windows",
43 all(target_os = "linux", not(target_env = "ohos"))
44 ))]
45 use image::{ImageBuffer, Rgba};
46
47 const OSC52_MAX_BYTES: usize = 100 * 1024;
48 const PRIMARY_MAX_BYTES: usize = 1024 * 1024;
49 #[cfg(any(
50 test,
51 target_os = "macos",
52 target_os = "windows",
53 all(target_os = "linux", not(target_env = "ohos"))
54 ))]
55 const MAX_CLIPBOARD_HTML_BYTES: usize = 1024 * 1024;
56
57 /// Convert rich clipboard content without loading its linked resources. Keep
58 /// the plain representation as a lossless fallback for empty/oversized HTML.
59 #[cfg(any(
60 test,
61 target_os = "macos",
62 target_os = "windows",
63 all(target_os = "linux", not(target_env = "ohos"))
64 ))]
65 fn clipboard_markdown(html: &str) -> Option<String> {
66 if html.len() > MAX_CLIPBOARD_HTML_BYTES {
67 return None;
68 }
69 let mut markdown = htmd::HtmlToMarkdown::builder()
70 .options(htmd::options::Options {
71 preformatted_code: true,
72 ..Default::default()
73 })
74 .skip_tags(vec!["script", "style", "head", "iframe", "object"])
75 .build()
76 .convert(html)
77 .ok()?;
78 // A standalone H1 must not become the `# note` memory shortcut. Setext
79 // is equivalent Markdown and remains multi-line after composer trimming.
80 if let Some(heading) = markdown.trim().strip_prefix("# ")
81 && !heading.contains('\n')
82 {
83 markdown = format!("{heading}\n===");
84 }
85 (!markdown.trim().is_empty() && markdown.len() <= MAX_CLIPBOARD_HTML_BYTES).then_some(markdown)
86 }
87
88 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
89 enum ClipboardEndpoint {
90 /// The TUI and desktop clipboard live on the same host.
91 NativeHost,
92 /// SSH exported a graphical display (X11 or Wayland), so the native
93 /// clipboard intentionally addresses that forwarded display.
94 ForwardedDisplay,
95 /// No graphical endpoint is available over SSH. Clipboard transfer must
96 /// be requested from the terminal client instead.
97 TerminalClient,
98 }
99
100 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
101 enum ClipboardWriteOrder {
102 /// An SSH TUI without an exported graphical display must target the
103 /// terminal client. A native clipboard on the remote host can succeed
104 /// while writing to the wrong machine.
105 TerminalClientOnly,
106 /// A local TUI should prefer the native clipboard (including images) and
107 /// retain OSC 52 as the terminal fallback.
108 NativeHostThenTerminal,
109 }
110
111 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
112 struct TerminalClipboardContext {
113 endpoint: ClipboardEndpoint,
114 in_tmux: bool,
115 }
116
117 impl TerminalClipboardContext {
118 fn detect() -> Self {
119 let ssh_client = std::env::var_os("SSH_CLIENT");
120 let ssh_connection = std::env::var_os("SSH_CONNECTION");
121 let ssh_tty = std::env::var_os("SSH_TTY");
122 let display = std::env::var_os("DISPLAY");
123 let wayland_display = std::env::var_os("WAYLAND_DISPLAY");
124 let ssh_clipboard = std::env::var_os("CODEWHALE_SSH_CLIPBOARD");
125 let tmux = std::env::var_os("TMUX");
126 Self::from_env_values(
127 ssh_client.as_deref(),
128 ssh_connection.as_deref(),
129 ssh_tty.as_deref(),
130 display.as_deref(),
131 wayland_display.as_deref(),
132 ssh_clipboard.as_deref(),
133 tmux.as_deref(),
134 )
135 }
136
137 fn from_env_values(
138 ssh_client: Option<&OsStr>,
139 ssh_connection: Option<&OsStr>,
140 ssh_tty: Option<&OsStr>,
141 display: Option<&OsStr>,
142 wayland_display: Option<&OsStr>,
143 ssh_clipboard: Option<&OsStr>,
144 tmux: Option<&OsStr>,
145 ) -> Self {
146 let in_ssh_session = [ssh_client, ssh_connection, ssh_tty]
147 .into_iter()
148 .flatten()
149 .any(|value| !value.is_empty());
150 let has_graphical_display = [display, wayland_display]
151 .into_iter()
152 .flatten()
153 .any(|value| !value.is_empty());
154 let forwarded_x11 = display.and_then(OsStr::to_str).is_some_and(|value| {
155 ["localhost:", "127.0.0.1:", "[::1]:", "::1:"]
156 .iter()
157 .any(|prefix| value.starts_with(prefix))
158 });
159 let use_graphical_display = match ssh_clipboard.and_then(OsStr::to_str) {
160 Some("graphical") => has_graphical_display,
161 Some("terminal") => false,
162 _ => forwarded_x11,
163 };
164
165 Self {
166 // OpenSSH normally exports SSH_CLIENT and SSH_CONNECTION.
167 // SSH_TTY is an additional PTY-only marker and is independently
168 // sufficient when wrappers preserve it without the other two.
169 endpoint: match (in_ssh_session, use_graphical_display) {
170 (false, _) => ClipboardEndpoint::NativeHost,
171 (true, true) => ClipboardEndpoint::ForwardedDisplay,
172 (true, false) => ClipboardEndpoint::TerminalClient,
173 },
174 in_tmux: tmux.is_some_and(|value| !value.is_empty()),
175 }
176 }
177
178 fn write_order(self) -> ClipboardWriteOrder {
179 if self.endpoint == ClipboardEndpoint::TerminalClient {
180 ClipboardWriteOrder::TerminalClientOnly
181 } else {
182 ClipboardWriteOrder::NativeHostThenTerminal
183 }
184 }
185
186 fn permits_native_read(self) -> bool {
187 self.endpoint != ClipboardEndpoint::TerminalClient
188 }
189
190 fn requires_terminal_paste(self) -> bool {
191 self.endpoint == ClipboardEndpoint::TerminalClient
192 }
193 }
194
195 // === Types ===
196
197 /// Metadata captured for a pasted clipboard image. Used by the composer to
198 /// render a status hint like `Pasted 1024x768 image (235KB) → <path>`.
199 #[derive(Clone)]
200 pub struct PastedImage {
201 pub path: PathBuf,
202 pub width: u32,
203 pub height: u32,
204 pub byte_len: usize,
205 }
206
207 impl PastedImage {
208 /// Short human-readable summary, e.g. `1024x768 PNG`.
209 pub fn short_label(&self) -> String {
210 format!("{}x{} PNG", self.width, self.height)
211 }
212
213 /// Approximate file size suffix, e.g. `235KB`.
214 pub fn size_label(&self) -> String {
215 let kb = (self.byte_len as f64 / 1024.0).round() as u64;
216 format!("{kb}KB")
217 }
218 }
219
220 /// Clipboard payloads supported by the TUI.
221 #[cfg_attr(
222 all(
223 any(target_env = "ohos", target_os = "android", target_os = "netbsd"),
224 not(test)
225 ),
226 allow(dead_code)
227 )]
228 pub enum ClipboardContent {
229 Text(String),
230 Image(PastedImage),
231 }
232
233 struct TerminalClipboardWriteRequest {
234 text: String,
235 in_tmux: bool,
236 }
237
238 type TerminalClipboardWriteCompletion = std::result::Result<(), String>;
239
240 /// Serializes terminal-client clipboard writes on a bounded background lane.
241 ///
242 /// OSC 52 ultimately writes to the terminal output stream, which can block
243 /// indefinitely under backpressure. tmux transport can likewise wait on a
244 /// stalled server. Keeping both operations on this worker means copy actions
245 /// never park the TUI input/render loop, while the single request slot bounds
246 /// memory and preserves copy order.
247 struct TerminalClipboardWriter {
248 request_tx: std::sync::mpsc::SyncSender<TerminalClipboardWriteRequest>,
249 completion_rx: std::sync::mpsc::Receiver<TerminalClipboardWriteCompletion>,
250 }
251
252 impl TerminalClipboardWriter {
253 #[cfg(not(test))]
254 fn spawn() -> Result<Self> {
255 Self::spawn_with(|request| write_text_to_terminal_client(&request.text, request.in_tmux))
256 }
257
258 fn spawn_with<F>(write: F) -> Result<Self>
259 where
260 F: Fn(TerminalClipboardWriteRequest) -> Result<()> + Send + 'static,
261 {
262 let (request_tx, request_rx) = std::sync::mpsc::sync_channel(1);
263 let (completion_tx, completion_rx) = std::sync::mpsc::channel();
264 std::thread::Builder::new()
265 .name("terminal-clipboard-writer".to_string())
266 .spawn(move || {
267 while let Ok(request) = request_rx.recv() {
268 let completion = write(request).map_err(|err| format!("{err:#}"));
269 if completion_tx.send(completion).is_err() {
270 break;
271 }
272 }
273 })
274 .context("spawn terminal clipboard writer")?;
275 Ok(Self {
276 request_tx,
277 completion_rx,
278 })
279 }
280
281 fn enqueue(&self, text: &str, in_tmux: bool) -> Result<()> {
282 let request = TerminalClipboardWriteRequest {
283 text: text.to_string(),
284 in_tmux,
285 };
286 self.request_tx.try_send(request).map_err(|err| match err {
287 std::sync::mpsc::TrySendError::Full(_) => {
288 anyhow::anyhow!("another terminal clipboard write is still queued")
289 }
290 std::sync::mpsc::TrySendError::Disconnected(_) => {
291 anyhow::anyhow!("terminal clipboard writer stopped")
292 }
293 })
294 }
295
296 fn poll_completion(&self) -> Option<TerminalClipboardWriteCompletion> {
297 self.completion_rx.try_recv().ok()
298 }
299 }
300
301 /// Which transport took a clipboard write.
302 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
303 pub enum CopyTransport {
304 /// A native clipboard accepted the text before the write returned.
305 /// Targets with no native clipboard (e.g. OpenHarmony) never build it.
306 #[cfg_attr(
307 all(
308 not(test),
309 not(any(
310 target_os = "macos",
311 target_os = "windows",
312 all(target_os = "linux", not(target_env = "ohos"))
313 ))
314 ),
315 allow(dead_code)
316 )]
317 Native,
318 /// Handed to the terminal (OSC 52, or tmux's buffer). Terminals never
319 /// acknowledge these, so success cannot be confirmed; a failure surfaces
320 /// later through `poll_write_completion`.
321 Terminal,
322 }
323
324 /// Clipboard reader/writer helper.
325 pub struct ClipboardHandler {
326 terminal_context: TerminalClipboardContext,
327 terminal_writer: Option<TerminalClipboardWriter>,
328 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
329 primary: Option<primary::PrimarySelection>,
330 #[cfg(test)]
331 primary_enabled: bool,
332 #[cfg(test)]
333 primary_text: Option<String>,
334 #[cfg(any(
335 target_os = "macos",
336 target_os = "windows",
337 all(target_os = "linux", not(target_env = "ohos"))
338 ))]
339 clipboard: Option<Clipboard>,
340 #[cfg(any(
341 target_os = "macos",
342 target_os = "windows",
343 all(target_os = "linux", not(target_env = "ohos"))
344 ))]
345 clipboard_init_attempted: bool,
346 #[cfg(test)]
347 written_text: Vec<String>,
348 #[cfg(test)]
349 fail_text_writes: bool,
350 }
351
352 impl ClipboardHandler {
353 /// Create a new clipboard handler without connecting.
354 ///
355 /// The actual clipboard connection is deferred to first use
356 /// (`ensure_clipboard`) so that startup on hosts without an X11/Wayland
357 /// server (headless, WSL2) never blocks the TUI event loop.
358 pub fn new() -> Self {
359 Self::with_terminal_context(TerminalClipboardContext::detect())
360 }
361
362 fn with_terminal_context(terminal_context: TerminalClipboardContext) -> Self {
363 Self {
364 terminal_context,
365 terminal_writer: None,
366 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
367 primary: None,
368 #[cfg(test)]
369 primary_enabled: false,
370 #[cfg(test)]
371 primary_text: None,
372 #[cfg(any(
373 target_os = "macos",
374 target_os = "windows",
375 all(target_os = "linux", not(target_env = "ohos"))
376 ))]
377 clipboard: None,
378 #[cfg(any(
379 target_os = "macos",
380 target_os = "windows",
381 all(target_os = "linux", not(target_env = "ohos"))
382 ))]
383 clipboard_init_attempted: false,
384 #[cfg(test)]
385 written_text: Vec::new(),
386 #[cfg(test)]
387 fail_text_writes: false,
388 }
389 }
390
391 #[cfg(test)]
392 pub(crate) fn for_test(in_ssh_session: bool, in_tmux: bool) -> Self {
393 Self::with_terminal_context(TerminalClipboardContext {
394 endpoint: if in_ssh_session {
395 ClipboardEndpoint::TerminalClient
396 } else {
397 ClipboardEndpoint::NativeHost
398 },
399 in_tmux,
400 })
401 }
402
403 /// A clipboard whose writes go to the terminal (OSC 52) and always
404 /// succeed, for receipt tests.
405 #[cfg(test)]
406 pub(crate) fn terminal_only_for_test() -> Self {
407 let mut handler = Self::for_test(true, false);
408 handler.terminal_writer =
409 Some(TerminalClipboardWriter::spawn_with(|_| Ok(())).expect("terminal writer"));
410 handler
411 }
412
413 /// Construct a deterministic unavailable clipboard for command tests.
414 #[cfg(test)]
415 pub(crate) fn unavailable_for_test(in_ssh_session: bool) -> Self {
416 let mut handler = Self::for_test(in_ssh_session, false);
417 handler.fail_text_writes = true;
418 // Reads are unavailable too: never fall through to the host's real
419 // clipboard from a test.
420 #[cfg(any(
421 target_os = "macos",
422 target_os = "windows",
423 all(target_os = "linux", not(target_env = "ohos"))
424 ))]
425 {
426 handler.clipboard_init_attempted = true;
427 }
428 handler
429 }
430
431 /// SSH without a forwarded graphical display cannot synchronously read
432 /// the terminal client's clipboard. Paste must be initiated by the local
433 /// terminal so it arrives as bracketed paste (or a raw paste burst on
434 /// older terminals).
435 pub(crate) fn requires_terminal_paste(&self) -> bool {
436 self.terminal_context.requires_terminal_paste()
437 }
438
439 pub(crate) fn uses_primary_selection(&self) -> bool {
440 #[cfg(test)]
441 {
442 self.primary_enabled
443 }
444 #[cfg(not(test))]
445 {
446 cfg!(all(target_os = "linux", not(target_env = "ohos")))
447 }
448 }
449
450 /// Automatic selection never writes CLIPBOARD or sends OSC 52. A remote
451 /// terminal without a forwarded display owns its own selection and paste.
452 pub(crate) fn write_primary_text(&mut self, text: &str) -> Result<()> {
453 if !self.uses_primary_selection()
454 || !self.terminal_context.permits_native_read()
455 || text.is_empty()
456 || text.len() > PRIMARY_MAX_BYTES
457 {
458 bail!("PRIMARY selection unavailable");
459 }
460 #[cfg(test)]
461 {
462 if self.fail_text_writes {
463 bail!("test PRIMARY unavailable");
464 }
465 self.primary_text = Some(text.to_string());
466 Ok(())
467 }
468 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
469 {
470 self.primary_selection()?.write(text)
471 }
472 #[cfg(all(not(test), not(all(target_os = "linux", not(target_env = "ohos")))))]
473 {
474 bail!("PRIMARY selection unavailable")
475 }
476 }
477
478 pub(crate) fn read_primary_text(&mut self) -> Option<String> {
479 if !self.uses_primary_selection() || !self.terminal_context.permits_native_read() {
480 return None;
481 }
482 #[cfg(test)]
483 {
484 if self.fail_text_writes {
485 None
486 } else {
487 self.primary_text.clone()
488 }
489 }
490 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
491 {
492 self.primary_selection().ok()?.read()
493 }
494 #[cfg(all(not(test), not(all(target_os = "linux", not(target_env = "ohos")))))]
495 {
496 None
497 }
498 }
499
500 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
501 fn primary_selection(&mut self) -> Result<&primary::PrimarySelection> {
502 if self.primary.is_none() {
503 self.primary = Some(primary::PrimarySelection::spawn()?);
504 }
505 Ok(self.primary.as_ref().expect("PRIMARY worker initialized"))
506 }
507
508 #[cfg(test)]
509 pub(crate) fn enable_primary_for_test(&mut self) {
510 self.primary_enabled = true;
511 }
512
513 /// Try to connect to the system clipboard, bounded by a short timeout.
514 ///
515 /// On Linux, `arboard::Clipboard::new()` opens a blocking X11 connection.
516 /// When no X server is running (headless, WSL2 without WSLg), the connect
517 /// call can hang indefinitely. We spawn the connection attempt on a
518 /// temporary thread and give it 500 ms; if it doesn't return in time the
519 /// handler stays in fallback/no-op mode and `read`/`write_text` fall
520 /// through to their OSC 52 and pbcopy/powershell fallbacks.
521 #[cfg(any(
522 target_os = "macos",
523 target_os = "windows",
524 all(target_os = "linux", not(target_env = "ohos"))
525 ))]
526 fn ensure_clipboard(&mut self) {
527 if self.clipboard_init_attempted {
528 return;
529 }
530 self.clipboard_init_attempted = true;
531
532 let (tx, rx) = std::sync::mpsc::channel();
533 std::thread::spawn(move || {
534 let _ = tx.send(Clipboard::new().ok());
535 });
536 self.clipboard = rx
537 .recv_timeout(std::time::Duration::from_millis(500))
538 .ok()
539 .flatten();
540 }
541
542 /// Read the clipboard and return the parsed content.
543 ///
544 /// `workspace` is used as a fallback location when `~/.codewhale/` cannot
545 /// be resolved (e.g. running with a stripped HOME in CI sandboxes).
546 pub fn read(&mut self, workspace: &Path) -> Option<ClipboardContent> {
547 self.read_content(workspace, false)
548 }
549
550 /// Composer paste preserves headings, lists, links, tables and code from
551 /// rich applications. Credentials and configuration fields use `read` so
552 /// their literal text is never interpreted as Markdown.
553 pub fn read_markdown(&mut self, workspace: &Path) -> Option<ClipboardContent> {
554 self.read_content(workspace, true)
555 }
556
557 fn read_content(
558 &mut self,
559 workspace: &Path,
560 prefer_markdown: bool,
561 ) -> Option<ClipboardContent> {
562 // With no display exported over SSH there is no synchronously readable
563 // clipboard endpoint. A forwarded X11/Wayland display is explicit and
564 // remains readable, including its image clipboard.
565 if !self.terminal_context.permits_native_read() {
566 return None;
567 }
568
569 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
570 if !prefer_markdown && let Ok(text) = read_text_with_wlpaste() {
571 return Some(ClipboardContent::Text(text));
572 }
573
574 #[cfg(any(
575 target_os = "macos",
576 target_os = "windows",
577 all(target_os = "linux", not(target_env = "ohos"))
578 ))]
579 {
580 self.ensure_clipboard();
581 if let Some(clipboard) = self.clipboard.as_mut() {
582 if prefer_markdown
583 && let Ok(html) = clipboard.get().html()
584 && let Some(markdown) = clipboard_markdown(&html)
585 {
586 return Some(ClipboardContent::Text(markdown));
587 }
588 if let Ok(text) = clipboard.get_text() {
589 return Some(ClipboardContent::Text(text));
590 }
591
592 if let Ok(image) = clipboard.get_image()
593 && let Ok(pasted) = save_image_as_png(workspace, &image)
594 {
595 return Some(ClipboardContent::Image(pasted));
596 }
597 }
598 }
599
600 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
601 if prefer_markdown && let Ok(text) = read_text_with_wlpaste() {
602 return Some(ClipboardContent::Text(text));
603 }
604
605 let _ = (workspace, prefer_markdown);
606 None
607 }
608
609 /// Write text to the clipboard.
610 ///
611 /// Native clipboard transports complete before this method returns. OSC 52
612 /// and tmux terminal-client writes are validated and admitted to a bounded
613 /// background worker; asynchronous transport failures are exposed through
614 /// [`Self::poll_write_completion`].
615 pub fn write_text(&mut self, text: &str) -> Result<()> {
616 self.write_text_status(text).map(|_| ())
617 }
618
619 /// [`Self::write_text`], reporting which transport took the text, so a
620 /// receipt can say "copied" only when a native clipboard confirmed it.
621 /// The transport is only known here: native is tried first and OSC 52 or
622 /// tmux is the fallback.
623 pub fn write_text_status(&mut self, text: &str) -> Result<CopyTransport> {
624 #[cfg(test)]
625 {
626 if let Some(writer) = self.terminal_writer.as_ref() {
627 return writer
628 .enqueue(text, self.terminal_context.in_tmux)
629 .map(|()| CopyTransport::Terminal);
630 }
631 if self.fail_text_writes {
632 bail!("test clipboard unavailable");
633 }
634 self.written_text.push(text.to_string());
635 Ok(CopyTransport::Native)
636 }
637
638 #[cfg(not(test))]
639 {
640 if self.terminal_context.write_order() == ClipboardWriteOrder::TerminalClientOnly {
641 return self
642 .enqueue_terminal_write(text)
643 .map(|()| CopyTransport::Terminal)
644 .map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}"));
645 }
646
647 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
648 if write_text_with_wlcopy(text).is_ok() {
649 return Ok(CopyTransport::Native);
650 }
651
652 #[cfg(any(
653 target_os = "macos",
654 target_os = "windows",
655 all(target_os = "linux", not(target_env = "ohos"))
656 ))]
657 {
658 self.ensure_clipboard();
659 if let Some(clipboard) = self.clipboard.as_mut()
660 && clipboard.set_text(text.to_string()).is_ok()
661 {
662 return Ok(CopyTransport::Native);
663 }
664 }
665
666 #[cfg(target_os = "macos")]
667 if write_text_with_pbcopy(text).is_ok() {
668 return Ok(CopyTransport::Native);
669 }
670
671 #[cfg(target_os = "windows")]
672 if write_text_with_set_clipboard(text).is_ok() {
673 return Ok(CopyTransport::Native);
674 }
675
676 self.enqueue_terminal_write(text)
677 .map(|()| CopyTransport::Terminal)
678 .map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}"))
679 }
680 }
681
682 #[cfg(not(test))]
683 fn enqueue_terminal_write(&mut self, text: &str) -> Result<()> {
684 if !self.terminal_context.in_tmux {
685 if text.len() > OSC52_MAX_BYTES {
686 bail!("selection is too large for OSC 52 clipboard fallback");
687 }
688 if !io::stdout().is_terminal() {
689 bail!("OSC 52 clipboard fallback requires a terminal");
690 }
691 }
692
693 if self.terminal_writer.is_none() {
694 self.terminal_writer = Some(TerminalClipboardWriter::spawn()?);
695 }
696 self.terminal_writer
697 .as_ref()
698 .expect("terminal clipboard writer initialized")
699 .enqueue(text, self.terminal_context.in_tmux)
700 }
701
702 /// Return one completed background terminal clipboard write, if available.
703 ///
704 /// Successes are intentionally quiet because callers already show their
705 /// normal copy receipt. Failures are drained by the event loop and replace
706 /// that optimistic receipt with an actionable error.
707 pub(crate) fn poll_write_completion(&self) -> Option<TerminalClipboardWriteCompletion> {
708 self.terminal_writer
709 .as_ref()
710 .and_then(TerminalClipboardWriter::poll_completion)
711 }
712
713 #[cfg(test)]
714 pub fn last_written_text(&self) -> Option<&str> {
715 self.written_text.last().map(String::as_str)
716 }
717 }
718
719 #[cfg(all(target_os = "macos", not(test)))]
720 fn write_text_with_pbcopy(text: &str) -> Result<()> {
721 write_text_with_stdin_command("pbcopy", &[], text, "pbcopy")
722 }
723
724 #[cfg(all(target_os = "windows", not(test)))]
725 fn write_text_with_set_clipboard(text: &str) -> Result<()> {
726 write_text_with_stdin_command(
727 "powershell.exe",
728 &["-NoProfile", "-Command", "Set-Clipboard -Value $input"],
729 text,
730 "Set-Clipboard",
731 )
732 }
733
734 #[cfg(all(any(target_os = "macos", target_os = "windows"), not(test)))]
735 fn write_text_with_stdin_command(
736 program: &str,
737 args: &[&str],
738 text: &str,
739 label: &str,
740 ) -> Result<()> {
741 let mut child = Command::new(program)
742 .args(args)
743 .stdin(Stdio::piped())
744 .stdout(Stdio::null())
745 .stderr(Stdio::null())
746 .spawn()
747 .map_err(|e| anyhow::anyhow!("Failed to run {label}: {e}"))?;
748 if let Some(mut stdin) = child.stdin.take() {
749 stdin
750 .write_all(text.as_bytes())
751 .map_err(|e| anyhow::anyhow!("Failed to write to {label}: {e}"))?;
752 }
753 let _ = std::thread::Builder::new()
754 .name("clipboard-wait".to_string())
755 .spawn(move || {
756 let _ = child.wait();
757 });
758 Ok(())
759 }
760
761 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
762 fn write_text_with_wlcopy(text: &str) -> Result<()> {
763 write_text_with_wlcopy_using_argv("wl-copy", text)
764 }
765
766 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
767 fn read_text_with_wlpaste() -> Result<String> {
768 read_text_with_wlpaste_using_argv("wl-paste")
769 }
770
771 #[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))]
772 fn read_text_with_wlpaste_using_argv(program: &str) -> Result<String> {
773 let output = Command::new(program)
774 .arg("--no-newline")
775 .arg("--type")
776 .arg("text/plain")
777 .stdout(Stdio::piped())
778 .stderr(Stdio::null())
779 .output()
780 .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
781 if !output.status.success() {
782 bail!("{program} exited with {}", output.status);
783 }
784 String::from_utf8(output.stdout).context("wl-paste returned non-UTF-8 text")
785 }
786
787 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
788 fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> {
789 let mut child = Command::new(program)
790 .stdin(Stdio::piped())
791 .stdout(Stdio::null())
792 .stderr(Stdio::null())
793 .spawn()
794 .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
795 if let Some(mut stdin) = child.stdin.take() {
796 stdin
797 .write_all(text.as_bytes())
798 .map_err(|e| anyhow::anyhow!("Failed to write to {program}: {e}"))?;
799 }
800 // stdin is dropped here, closing the pipe so wl-copy flushes.
801 let status = child
802 .wait()
803 .map_err(|e| anyhow::anyhow!("Failed to wait on {program}: {e}"))?;
804 if !status.success() {
805 bail!("{program} exited with {status}");
806 }
807 Ok(())
808 }
809
810 #[cfg(not(test))]
811 fn write_text_to_terminal_client(text: &str, in_tmux: bool) -> Result<()> {
812 if in_tmux {
813 return write_text_with_tmux(text);
814 }
815 write_text_with_osc52(text)
816 }
817
818 #[cfg(not(test))]
819 fn write_text_with_tmux(text: &str) -> Result<()> {
820 write_text_with_tmux_using_argv("tmux", &[], text)
821 }
822
823 /// Ask tmux to set both its paste buffer and the attached client's clipboard.
824 /// Unlike DCS passthrough, `load-buffer -w` works with tmux's default
825 /// `allow-passthrough off` policy and returns a non-zero status when tmux
826 /// cannot honor the command.
827 #[cfg(any(not(test), all(test, unix)))]
828 fn write_text_with_tmux_using_argv(program: &str, prefix_args: &[&str], text: &str) -> Result<()> {
829 let mut child = Command::new(program)
830 .args(prefix_args)
831 .args(["load-buffer", "-w", "-"])
832 .stdin(Stdio::piped())
833 .stdout(Stdio::null())
834 .stderr(Stdio::piped())
835 .spawn()
836 .map_err(|e| anyhow::anyhow!("Failed to run tmux load-buffer -w: {e}"))?;
837
838 let write_result = child
839 .stdin
840 .take()
841 .context("open tmux clipboard input")
842 .and_then(|mut stdin| {
843 stdin
844 .write_all(text.as_bytes())
845 .context("write tmux clipboard input")
846 });
847 let output = child
848 .wait_with_output()
849 .context("wait for tmux load-buffer -w")?;
850 write_result?;
851 if !output.status.success() {
852 let detail = String::from_utf8_lossy(&output.stderr);
853 let detail = detail.trim();
854 if detail.is_empty() {
855 bail!("tmux load-buffer -w exited with {}", output.status);
856 }
857 bail!(
858 "tmux load-buffer -w exited with {}: {detail}",
859 output.status
860 );
861 }
862 Ok(())
863 }
864
865 #[cfg(not(test))]
866 fn write_text_with_osc52(text: &str) -> Result<()> {
867 let mut stdout = io::stdout();
868 if !stdout.is_terminal() {
869 bail!("OSC 52 clipboard fallback requires a terminal");
870 }
871
872 let sequence = osc52_sequence(text)?;
873 stdout
874 .write_all(sequence.as_bytes())
875 .context("write OSC 52 clipboard sequence")?;
876 stdout.flush().context("flush OSC 52 clipboard sequence")
877 }
878
879 fn osc52_sequence(text: &str) -> Result<String> {
880 if text.len() > OSC52_MAX_BYTES {
881 bail!("selection is too large for OSC 52 clipboard fallback");
882 }
883
884 let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
885 Ok(format!("\x1b]52;c;{encoded}\x07"))
886 }
887
888 /// Resolve the directory pasted images should land in. Prefers
889 /// `~/.codewhale/clipboard-images/` so the path is stable across worktrees and
890 /// matches the location described in user-facing docs; falls back to
891 /// `<workspace>/clipboard-images/` if the home dir is unavailable.
892 pub(crate) fn clipboard_images_dir(workspace: &Path) -> PathBuf {
893 let home = crate::config::effective_home_dir();
894 clipboard_images_dir_for_home(workspace, home.as_deref())
895 }
896
897 fn clipboard_images_dir_for_home(workspace: &Path, home: Option<&Path>) -> PathBuf {
898 if let Some(home) = home {
899 return home.join(".codewhale").join("clipboard-images");
900 }
901 workspace.join("clipboard-images")
902 }
903
904 /// Pinned, no-follow writer for `dir`, which is `<root>/<name>`: `root` may be a
905 /// user-selected link (a relocated `~/.codewhale`), but nothing below it may be.
906 #[cfg(any(
907 target_os = "macos",
908 target_os = "windows",
909 all(target_os = "linux", not(target_env = "ohos"))
910 ))]
911 fn clipboard_image_target(
912 dir: &Path,
913 file_name: &str,
914 ) -> Result<crate::fleet::files::WorkspaceFile> {
915 let root = dir.parent().context("clipboard-images dir has no parent")?;
916 let name = dir
917 .file_name()
918 .context("clipboard-images dir has no name")?;
919 crate::fleet::files::WorkspaceFile::open(root, &Path::new(name).join(file_name), true)
920 .context("open clipboard-images destination (links are not followed)")
921 }
922
923 /// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns
924 /// the resulting path along with metadata used to render the paste hint.
925 #[cfg(any(
926 target_os = "macos",
927 target_os = "windows",
928 all(target_os = "linux", not(target_env = "ohos"))
929 ))]
930 fn save_image_as_png(workspace: &Path, image: &ImageData) -> Result<PastedImage> {
931 save_image_as_png_in(&clipboard_images_dir(workspace), image)
932 }
933
934 /// Lower-level variant that writes into an explicit directory. Exposed so the
935 /// unit tests don't have to scribble inside the user's real home directory.
936 #[cfg(any(
937 target_os = "macos",
938 target_os = "windows",
939 all(target_os = "linux", not(target_env = "ohos"))
940 ))]
941 fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result<PastedImage> {
942 let timestamp = SystemTime::now()
943 .duration_since(UNIX_EPOCH)
944 .unwrap_or_default()
945 .as_nanos();
946 let width = u32::try_from(image.width).context("clipboard image width too large")?;
947 let height = u32::try_from(image.height).context("clipboard image height too large")?;
948
949 // arboard hands us RGBA8 row-major. Copy into an ImageBuffer so we can
950 // run it through the `image` crate's PNG encoder. We pad / truncate any
951 // mismatched trailing bytes — defensive only, arboard already validates
952 // the buffer length on every supported backend.
953 let expected = (width as usize) * (height as usize) * 4;
954 let mut rgba = image.bytes.as_ref().to_vec();
955 if rgba.len() < expected {
956 rgba.resize(expected, 0);
957 } else if rgba.len() > expected {
958 rgba.truncate(expected);
959 }
960
961 let buffer: ImageBuffer<Rgba<u8>, _> = ImageBuffer::from_raw(width, height, rgba)
962 .context("clipboard image dimensions did not match buffer length")?;
963 // Encode in memory and publish through the pinned no-follow writer: the
964 // destination is created exclusively and owner-only, and a linked
965 // directory or file name is refused rather than written through.
966 let mut encoded = Vec::new();
967 buffer
968 .write_to(
969 &mut std::io::Cursor::new(&mut encoded),
970 image::ImageFormat::Png,
971 )
972 .context("encode clipboard PNG")?;
973 let file_name = format!("clipboard-{timestamp}.png");
974 clipboard_image_target(dir, &file_name)?
975 .publish(&encoded)
976 .context("write clipboard PNG")?;
977 let path = dir.join(file_name);
978
979 let byte_len = encoded.len();
980 Ok(PastedImage {
981 path,
982 width,
983 height,
984 byte_len,
985 })
986 }
987
988 #[cfg(test)]
989 mod tests {
990 use super::*;
991
992 #[test]
993 fn primary_transport_is_distinct_bounded_and_never_uses_ssh_host_clipboard() {
994 let mut clipboard = ClipboardHandler::for_test(false, false);
995 clipboard.enable_primary_for_test();
996 clipboard.write_text("regular").unwrap();
997 clipboard.write_primary_text("selected").unwrap();
998 assert_eq!(clipboard.last_written_text(), Some("regular"));
999 assert_eq!(clipboard.read_primary_text().as_deref(), Some("selected"));
1000 assert!(clipboard.write_primary_text("").is_err());
1001 assert!(
1002 clipboard
1003 .write_primary_text(&"x".repeat(PRIMARY_MAX_BYTES + 1))
1004 .is_err()
1005 );
1006 assert_eq!(clipboard.read_primary_text().as_deref(), Some("selected"));
1007 let mut remote = ClipboardHandler::for_test(true, false);
1008 remote.enable_primary_for_test();
1009 assert!(remote.write_primary_text("private").is_err());
1010 assert!(remote.read_primary_text().is_none());
1011 assert!(remote.last_written_text().is_none());
1012 let mut forwarded = ClipboardHandler::with_terminal_context(TerminalClipboardContext {
1013 endpoint: ClipboardEndpoint::ForwardedDisplay,
1014 in_tmux: false,
1015 });
1016 forwarded.enable_primary_for_test();
1017 forwarded.write_primary_text("forwarded").unwrap();
1018 assert_eq!(forwarded.read_primary_text().as_deref(), Some("forwarded"));
1019 }
1020
1021 #[test]
1022 fn clipboard_markdown_preserves_rich_structure_and_code() {
1023 let html = r#"<h1>Release plan</h1><p>Keep <strong>authorship</strong> and
1024 <a href="https://example.com/review">review</a>.</p>
1025 <ul><li>Run gates</li><li>Dogfood</li></ul>
1026 <pre><code>fn main() {
1027 println!("&lt;ready&gt;");
1028 }</code></pre>
1029 <table><tr><th>Gate</th><th>Result</th></tr><tr><td>Tests</td><td>Pass</td></tr></table>"#;
1030 let markdown = clipboard_markdown(html).expect("rich text converts");
1031 assert!(markdown.contains("# Release plan"), "{markdown}");
1032 assert!(markdown.contains("**authorship**"), "{markdown}");
1033 assert!(
1034 markdown.contains("[review](https://example.com/review)"),
1035 "{markdown}"
1036 );
1037 assert!(markdown.contains("Run gates") && markdown.contains("Dogfood"));
1038 assert!(markdown.contains("```"), "{markdown}");
1039 assert!(markdown.contains("println!(\"<ready>\");"), "{markdown}");
1040 assert!(
1041 markdown
1042 .lines()
1043 .any(|line| line.split('|').map(str::trim).collect::<Vec<_>>()
1044 == ["", "Gate", "Result", ""]),
1045 "{markdown}"
1046 );
1047 }
1048
1049 #[test]
1050 fn clipboard_markdown_omits_executable_markup_and_falls_back_losslessly() {
1051 assert_eq!(
1052 clipboard_markdown("<script>secret()</script><style>secret</style>"),
1053 None
1054 );
1055 assert_eq!(
1056 clipboard_markdown(&"x".repeat(MAX_CLIPBOARD_HTML_BYTES + 1)),
1057 None
1058 );
1059 let markdown = clipboard_markdown("<h1>Release plan</h1>").unwrap();
1060 assert_eq!(markdown.trim(), "Release plan\n===");
1061 assert!(markdown.trim().contains('\n'), "not a memory quick-add");
1062 }
1063 // ImageData from arboard is only available on these platforms.
1064 #[cfg(any(
1065 target_os = "macos",
1066 target_os = "windows",
1067 all(target_os = "linux", not(target_env = "ohos"))
1068 ))]
1069 use std::borrow::Cow;
1070 #[cfg(unix)]
1071 use std::os::unix::fs::PermissionsExt;
1072
1073 #[test]
1074 fn terminal_clipboard_write_does_not_wait_for_slow_transport() {
1075 let (transport_started_tx, transport_started_rx) = std::sync::mpsc::channel();
1076 let (release_transport_tx, release_transport_rx) = std::sync::mpsc::channel();
1077 let writer = TerminalClipboardWriter::spawn_with(move |request| {
1078 assert_eq!(request.text, "copied");
1079 assert!(!request.in_tmux);
1080 transport_started_tx
1081 .send(())
1082 .expect("announce transport start");
1083 release_transport_rx.recv().expect("release slow transport");
1084 Ok(())
1085 })
1086 .expect("spawn clipboard writer");
1087 let mut clipboard = ClipboardHandler::for_test(true, false);
1088 clipboard.terminal_writer = Some(writer);
1089
1090 let (caller_returned_tx, caller_returned_rx) = std::sync::mpsc::channel();
1091 let caller = std::thread::spawn(move || {
1092 let result = clipboard.write_text("copied");
1093 caller_returned_tx
1094 .send((clipboard, result))
1095 .expect("report caller completion");
1096 });
1097
1098 let (clipboard, result) =
1099 match caller_returned_rx.recv_timeout(std::time::Duration::from_millis(250)) {
1100 Ok(value) => value,
1101 Err(err) => {
1102 let _ = release_transport_tx.send(());
1103 caller.join().expect("join clipboard caller");
1104 panic!("clipboard caller waited for slow transport: {err}");
1105 }
1106 };
1107 result.expect("queue clipboard write");
1108 transport_started_rx
1109 .recv_timeout(std::time::Duration::from_millis(250))
1110 .expect("worker started transport");
1111 assert!(
1112 clipboard.poll_write_completion().is_none(),
1113 "transport must remain pending until explicitly released"
1114 );
1115
1116 release_transport_tx.send(()).expect("release transport");
1117 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1118 loop {
1119 if let Some(completion) = clipboard.poll_write_completion() {
1120 completion.expect("background clipboard completion");
1121 break;
1122 }
1123 assert!(
1124 std::time::Instant::now() < deadline,
1125 "background clipboard completion timed out"
1126 );
1127 std::thread::sleep(std::time::Duration::from_millis(10));
1128 }
1129 caller.join().expect("join clipboard caller");
1130 }
1131
1132 #[test]
1133 fn terminal_clipboard_write_reports_background_failure() {
1134 let writer =
1135 TerminalClipboardWriter::spawn_with(|_| bail!("terminal clipboard transport denied"))
1136 .expect("spawn clipboard writer");
1137 writer
1138 .enqueue("copied", false)
1139 .expect("queue clipboard write");
1140
1141 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1142 loop {
1143 if let Some(completion) = writer.poll_completion() {
1144 let err = completion.expect_err("transport should fail");
1145 assert!(err.contains("transport denied"), "{err}");
1146 break;
1147 }
1148 assert!(
1149 std::time::Instant::now() < deadline,
1150 "background clipboard failure timed out"
1151 );
1152 std::thread::sleep(std::time::Duration::from_millis(10));
1153 }
1154 }
1155
1156 #[cfg(any(
1157 target_os = "macos",
1158 target_os = "windows",
1159 all(target_os = "linux", not(target_env = "ohos"))
1160 ))]
1161 fn solid_rgba(width: u16, height: u16, rgba: [u8; 4]) -> ImageData<'static> {
1162 let mut bytes = Vec::with_capacity((width as usize) * (height as usize) * 4);
1163 for _ in 0..(width as usize * height as usize) {
1164 bytes.extend_from_slice(&rgba);
1165 }
1166 ImageData {
1167 width: width as usize,
1168 height: height as usize,
1169 bytes: Cow::Owned(bytes),
1170 }
1171 }
1172
1173 #[test]
1174 #[cfg(any(
1175 target_os = "macos",
1176 target_os = "windows",
1177 all(target_os = "linux", not(target_env = "ohos"))
1178 ))]
1179 fn save_image_as_png_writes_valid_png() {
1180 let dir = tempfile::tempdir().unwrap();
1181 let img = solid_rgba(8, 4, [255, 0, 0, 255]);
1182 let pasted = save_image_as_png_in(dir.path(), &img).expect("encode png");
1183
1184 assert_eq!(pasted.width, 8);
1185 assert_eq!(pasted.height, 4);
1186 assert!(pasted.byte_len > 0);
1187 assert_eq!(
1188 pasted.path.extension().and_then(|s| s.to_str()),
1189 Some("png")
1190 );
1191
1192 // The first eight bytes of any PNG file are the magic signature; if
1193 // we ever regress to PPM or another format this will catch it.
1194 let header = std::fs::read(&pasted.path).unwrap();
1195 assert_eq!(&header[..8], b"\x89PNG\r\n\x1a\n");
1196 }
1197
1198 #[test]
1199 #[cfg(all(
1200 unix,
1201 any(
1202 target_os = "macos",
1203 all(target_os = "linux", not(target_env = "ohos"))
1204 )
1205 ))]
1206 fn pasted_images_are_private_and_never_written_through_a_link() {
1207 use std::os::unix::fs::{PermissionsExt, symlink};
1208 let root = tempfile::tempdir().unwrap();
1209 let outside = tempfile::tempdir().unwrap();
1210 let img = solid_rgba(2, 2, [0, 255, 0, 255]);
1211
1212 let real = root.path().join("clipboard-images");
1213 let pasted = save_image_as_png_in(&real, &img).expect("a plain directory works");
1214 let mode = std::fs::metadata(&pasted.path)
1215 .unwrap()
1216 .permissions()
1217 .mode();
1218 assert_eq!(mode & 0o777, 0o600, "pasted images are owner-only");
1219
1220 // A linked destination directory is refused and nothing lands behind it.
1221 let linked = root.path().join("linked-images");
1222 symlink(outside.path(), &linked).unwrap();
1223 assert!(save_image_as_png_in(&linked, &img).is_err());
1224 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
1225 }
1226
1227 #[test]
1228 fn clipboard_images_dir_uses_codewhale_home_directory() {
1229 let home = tempfile::tempdir().unwrap();
1230 let workspace = tempfile::tempdir().unwrap();
1231
1232 assert_eq!(
1233 clipboard_images_dir_for_home(workspace.path(), Some(home.path())),
1234 home.path().join(".codewhale").join("clipboard-images")
1235 );
1236 }
1237
1238 #[test]
1239 fn clipboard_images_dir_falls_back_to_workspace_without_home() {
1240 let workspace = tempfile::tempdir().unwrap();
1241
1242 assert_eq!(
1243 clipboard_images_dir_for_home(workspace.path(), None),
1244 workspace.path().join("clipboard-images")
1245 );
1246 }
1247
1248 #[test]
1249 fn pasted_image_labels_format_correctly() {
1250 let p = PastedImage {
1251 path: PathBuf::from("/tmp/x.png"),
1252 width: 1024,
1253 height: 768,
1254 byte_len: 235 * 1024,
1255 };
1256 assert_eq!(p.short_label(), "1024x768 PNG");
1257 assert_eq!(p.size_label(), "235KB");
1258 }
1259
1260 #[test]
1261 fn ssh_detection_covers_openssh_markers_and_ignores_empty_values() {
1262 let client = TerminalClipboardContext::from_env_values(
1263 Some(OsStr::new("192.0.2.10 51234 22")),
1264 None,
1265 None,
1266 None,
1267 None,
1268 None,
1269 None,
1270 );
1271 let connection = TerminalClipboardContext::from_env_values(
1272 None,
1273 Some(OsStr::new("192.0.2.10 51234 192.0.2.20 22")),
1274 None,
1275 None,
1276 None,
1277 None,
1278 None,
1279 );
1280 let tty = TerminalClipboardContext::from_env_values(
1281 None,
1282 None,
1283 Some(OsStr::new("/dev/pts/4")),
1284 None,
1285 None,
1286 None,
1287 None,
1288 );
1289 let empty = TerminalClipboardContext::from_env_values(
1290 Some(OsStr::new("")),
1291 Some(OsStr::new("")),
1292 Some(OsStr::new("")),
1293 Some(OsStr::new("")),
1294 Some(OsStr::new("")),
1295 Some(OsStr::new("")),
1296 Some(OsStr::new("")),
1297 );
1298
1299 assert_eq!(client.endpoint, ClipboardEndpoint::TerminalClient);
1300 assert_eq!(connection.endpoint, ClipboardEndpoint::TerminalClient);
1301 assert_eq!(tty.endpoint, ClipboardEndpoint::TerminalClient);
1302 assert_eq!(empty.endpoint, ClipboardEndpoint::NativeHost);
1303 assert!(!empty.in_tmux);
1304 }
1305
1306 #[test]
1307 fn ssh_without_display_targets_terminal_client() {
1308 let remote_tmux = TerminalClipboardContext::from_env_values(
1309 Some(OsStr::new("192.0.2.10 51234 22")),
1310 None,
1311 None,
1312 None,
1313 None,
1314 None,
1315 Some(OsStr::new("/tmp/tmux-1000/default,1,0")),
1316 );
1317 let local =
1318 TerminalClipboardContext::from_env_values(None, None, None, None, None, None, None);
1319
1320 assert_eq!(
1321 remote_tmux.write_order(),
1322 ClipboardWriteOrder::TerminalClientOnly
1323 );
1324 assert!(!remote_tmux.permits_native_read());
1325 assert!(remote_tmux.requires_terminal_paste());
1326 assert!(remote_tmux.in_tmux);
1327 assert_eq!(
1328 local.write_order(),
1329 ClipboardWriteOrder::NativeHostThenTerminal
1330 );
1331 assert!(local.permits_native_read());
1332 assert!(!local.requires_terminal_paste());
1333 }
1334
1335 #[test]
1336 fn ssh_uses_forwarded_x11_or_explicit_graphical_clipboard_endpoint() {
1337 let x11 = TerminalClipboardContext::from_env_values(
1338 None,
1339 Some(OsStr::new("192.0.2.10 51234 192.0.2.20 22")),
1340 None,
1341 Some(OsStr::new("localhost:10.0")),
1342 None,
1343 None,
1344 None,
1345 );
1346 let wayland = TerminalClipboardContext::from_env_values(
1347 Some(OsStr::new("192.0.2.10 51234 22")),
1348 None,
1349 None,
1350 None,
1351 Some(OsStr::new("wayland-1")),
1352 Some(OsStr::new("graphical")),
1353 None,
1354 );
1355
1356 for context in [x11, wayland] {
1357 assert_eq!(context.endpoint, ClipboardEndpoint::ForwardedDisplay);
1358 assert_eq!(
1359 context.write_order(),
1360 ClipboardWriteOrder::NativeHostThenTerminal
1361 );
1362 assert!(context.permits_native_read());
1363 assert!(!context.requires_terminal_paste());
1364 }
1365
1366 let ambient_remote = TerminalClipboardContext::from_env_values(
1367 Some(OsStr::new("192.0.2.10 51234 22")),
1368 None,
1369 None,
1370 Some(OsStr::new(":0")),
1371 Some(OsStr::new("wayland-0")),
1372 None,
1373 None,
1374 );
1375 assert_eq!(ambient_remote.endpoint, ClipboardEndpoint::TerminalClient);
1376
1377 let forced_terminal = TerminalClipboardContext::from_env_values(
1378 Some(OsStr::new("192.0.2.10 51234 22")),
1379 None,
1380 None,
1381 Some(OsStr::new("localhost:10.0")),
1382 None,
1383 Some(OsStr::new("terminal")),
1384 None,
1385 );
1386 assert_eq!(forced_terminal.endpoint, ClipboardEndpoint::TerminalClient);
1387 }
1388
1389 #[test]
1390 fn osc52_sequence_encodes_text_clipboard_write() {
1391 let sequence = osc52_sequence("hello").expect("sequence");
1392 assert_eq!(sequence, "\x1b]52;c;aGVsbG8=\x07");
1393 }
1394
1395 #[test]
1396 fn osc52_sequence_rejects_oversized_selection() {
1397 let text = "x".repeat(OSC52_MAX_BYTES + 1);
1398 let err = osc52_sequence(&text).expect_err("oversized should fail");
1399 assert!(
1400 err.to_string().contains("too large"),
1401 "unexpected error: {err}"
1402 );
1403 }
1404
1405 #[cfg(unix)]
1406 #[test]
1407 fn tmux_helper_reports_command_failure() {
1408 let dir = tempfile::tempdir().unwrap();
1409 let script = dir.path().join("tmux");
1410 std::fs::write(
1411 &script,
1412 r#"#!/bin/sh
1413 cat >/dev/null
1414 echo 'clipboard denied' >&2
1415 exit 42
1416 "#,
1417 )
1418 .unwrap();
1419 let mut perms = std::fs::metadata(&script).unwrap().permissions();
1420 perms.set_mode(0o755);
1421 std::fs::set_permissions(&script, perms).unwrap();
1422
1423 // Another test thread may fork while this one still had the script
1424 // open for writing; the child keeps that descriptor until it execs,
1425 // and running the script meanwhile fails with "Text file busy". That
1426 // is the test's own race, not the behavior under test, so wait it out.
1427 let mut attempts = 0;
1428 let err = loop {
1429 let err = write_text_with_tmux_using_argv(script.to_str().unwrap(), &[], "copy")
1430 .expect_err("non-zero tmux status should fail");
1431 attempts += 1;
1432 if attempts >= 100 || !err.to_string().contains("Text file busy") {
1433 break err;
1434 }
1435 std::thread::sleep(std::time::Duration::from_millis(20));
1436 };
1437
1438 assert!(err.to_string().contains("exited with"), "{err}");
1439 assert!(err.to_string().contains("clipboard denied"));
1440 }
1441
1442 #[cfg(all(unix, not(target_env = "ohos")))]
1443 #[test]
1444 fn tmux_load_buffer_w_reaches_attached_client_with_default_passthrough_disabled() {
1445 use std::io::Read as _;
1446
1447 // Every subprocess must inherit the same terminal environment, not
1448 // another fixture's transient PATH/TERM/multiplexer overrides.
1449 let _env = crate::test_support::lock_test_env();
1450
1451 let version = match Command::new("tmux").arg("-V").output() {
1452 Ok(output) if output.status.success() => output,
1453 _ => return,
1454 };
1455 assert!(
1456 String::from_utf8_lossy(&version.stdout).starts_with("tmux "),
1457 "unexpected tmux version output"
1458 );
1459
1460 let nonce = std::time::SystemTime::now()
1461 .duration_since(std::time::UNIX_EPOCH)
1462 .expect("clock after epoch")
1463 .as_nanos();
1464 let socket = format!("codewhale-clipboard-{}-{nonce}", std::process::id());
1465
1466 struct TmuxServer(String);
1467 impl Drop for TmuxServer {
1468 fn drop(&mut self) {
1469 let _ = Command::new("tmux")
1470 .args(["-L", self.0.as_str(), "kill-server"])
1471 .status();
1472 }
1473 }
1474 let server = TmuxServer(socket);
1475 let started = Command::new("tmux")
1476 .args([
1477 "-L",
1478 server.0.as_str(),
1479 "-f",
1480 "/dev/null",
1481 "new-session",
1482 "-d",
1483 ])
1484 .status()
1485 .expect("start isolated tmux server");
1486 assert!(started.success(), "isolated tmux server should start");
1487
1488 let option = |name: &str| {
1489 let output = Command::new("tmux")
1490 .args(["-L", server.0.as_str(), "show-options", "-gv", name])
1491 .output()
1492 .expect("read tmux option");
1493 assert!(output.status.success(), "read tmux option {name}");
1494 String::from_utf8(output.stdout)
1495 .expect("tmux option should be utf-8")
1496 .trim()
1497 .to_string()
1498 };
1499 assert_eq!(option("allow-passthrough"), "off");
1500 assert_eq!(option("set-clipboard"), "external");
1501
1502 let pty_system = portable_pty::native_pty_system();
1503 let pair = pty_system
1504 .openpty(portable_pty::PtySize {
1505 rows: 24,
1506 cols: 80,
1507 pixel_width: 0,
1508 pixel_height: 0,
1509 })
1510 .expect("open attached-client PTY");
1511 let mut attach = portable_pty::CommandBuilder::new("tmux");
1512 for arg in ["-L", server.0.as_str(), "attach-session", "-t", "0"] {
1513 attach.arg(arg);
1514 }
1515 attach.env("TERM", "xterm-256color");
1516 let mut attached_client = pair
1517 .slave
1518 .spawn_command(attach)
1519 .expect("attach tmux client to PTY");
1520 drop(pair.slave);
1521
1522 let mut reader = pair
1523 .master
1524 .try_clone_reader()
1525 .expect("clone attached-client PTY reader");
1526 let (output_tx, output_rx) = std::sync::mpsc::channel();
1527 let reader_thread = std::thread::spawn(move || {
1528 let mut chunk = [0_u8; 4096];
1529 loop {
1530 match reader.read(&mut chunk) {
1531 Ok(0) | Err(_) => break,
1532 Ok(len) => {
1533 if output_tx.send(chunk[..len].to_vec()).is_err() {
1534 break;
1535 }
1536 }
1537 }
1538 }
1539 });
1540
1541 // Load-tolerant bounds, not the contract under test: on a machine
1542 // running a full parallel suite, tmux server startup and OSC 52
1543 // forwarding can both exceed a tight 3s wall clock (#5929). The test
1544 // still verifies the *content* of what reaches the attached client;
1545 // only how long it is willing to wait for a loaded machine changed.
1546 let attach_deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1547 loop {
1548 let clients = Command::new("tmux")
1549 .args(["-L", server.0.as_str(), "list-clients"])
1550 .output()
1551 .expect("list attached tmux clients");
1552 if clients.status.success() && !clients.stdout.is_empty() {
1553 break;
1554 }
1555 assert!(
1556 std::time::Instant::now() < attach_deadline,
1557 "tmux client did not attach to the test PTY"
1558 );
1559 std::thread::sleep(std::time::Duration::from_millis(25));
1560 }
1561 // A listed client can precede its terminal startup. tmux discards
1562 // clipboard requests before TTY_STARTED; actual PTY output establishes
1563 // that startup reached the terminal before the one request we verify.
1564 output_rx
1565 .recv_timeout(attach_deadline.saturating_duration_since(std::time::Instant::now()))
1566 .expect("attached tmux client should produce terminal startup output");
1567 while output_rx.try_recv().is_ok() {}
1568
1569 let copied_text = "copy through default tmux";
1570 write_text_with_tmux_using_argv("tmux", &["-L", server.0.as_str()], copied_text)
1571 .expect("tmux-native clipboard request");
1572
1573 let encoded = base64::engine::general_purpose::STANDARD.encode(copied_text.as_bytes());
1574 let expected_receipts = [
1575 format!("\x1b]52;;{encoded}\x07").into_bytes(),
1576 format!("\x1b]52;c;{encoded}\x07").into_bytes(),
1577 format!("\x1b]52;;{encoded}\x1b\\").into_bytes(),
1578 format!("\x1b]52;c;{encoded}\x1b\\").into_bytes(),
1579 ];
1580 let receipt_deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1581 let mut attached_output = Vec::new();
1582 let receipt_received = loop {
1583 if expected_receipts.iter().any(|receipt| {
1584 attached_output
1585 .windows(receipt.len())
1586 .any(|window| window == receipt)
1587 }) {
1588 break true;
1589 }
1590 if std::time::Instant::now() >= receipt_deadline {
1591 break false;
1592 }
1593 match output_rx.recv_timeout(std::time::Duration::from_millis(50)) {
1594 Ok(bytes) => attached_output.extend_from_slice(&bytes),
1595 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1596 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break false,
1597 }
1598 };
1599
1600 let buffer = Command::new("tmux")
1601 .args(["-L", server.0.as_str(), "show-buffer"])
1602 .output()
1603 .expect("read tmux buffer");
1604 assert!(buffer.status.success(), "tmux buffer should be readable");
1605 assert_eq!(buffer.stdout, copied_text.as_bytes());
1606
1607 let _ = attached_client.kill();
1608 let _ = attached_client.wait();
1609 drop(pair.master);
1610 drop(output_rx);
1611 let _ = reader_thread.join();
1612
1613 assert!(
1614 receipt_received,
1615 "attached tmux client did not receive the OSC 52 clipboard request: {attached_output:?}"
1616 );
1617 }
1618
1619 #[cfg(unix)]
1620 #[test]
1621 fn wl_paste_helper_reads_text_from_stdout() {
1622 let dir = tempfile::tempdir().unwrap();
1623 let script = dir.path().join("wl-paste");
1624 std::fs::write(
1625 &script,
1626 r#"#!/bin/sh
1627 seen_no_newline=0
1628 seen_text_plain=0
1629 while [ "$#" -gt 0 ]; do
1630 case "$1" in
1631 --no-newline) seen_no_newline=1 ;;
1632 --type)
1633 shift
1634 [ "${1:-}" = "text/plain" ] && seen_text_plain=1
1635 ;;
1636 esac
1637 shift
1638 done
1639 [ "$seen_text_plain" -eq 1 ] || exit 40
1640 if [ "$seen_no_newline" -eq 1 ]; then
1641 printf 'from-wayland'
1642 else
1643 printf 'from-wayland\n'
1644 fi
1645 "#,
1646 )
1647 .unwrap();
1648 let mut perms = std::fs::metadata(&script).unwrap().permissions();
1649 perms.set_mode(0o755);
1650 std::fs::set_permissions(&script, perms).unwrap();
1651
1652 // A freshly written helper script can transiently report ETXTBSY
1653 // ("Text file busy") when a concurrent test in this parallel suite
1654 // forks while the write descriptor is still inherited. Retry the
1655 // exec briefly so this assertion exercises the helper contract
1656 // rather than the fork/exec window; the bound is load tolerance,
1657 // not the behavior under test (same class as #5929).
1658 let mut attempts = 0;
1659 let text = loop {
1660 match read_text_with_wlpaste_using_argv(script.to_str().unwrap()) {
1661 Ok(text) => break text,
1662 Err(error) => {
1663 attempts += 1;
1664 let busy = error.to_string().contains("Text file busy");
1665 assert!(
1666 busy && attempts < 100,
1667 "read text through wl-paste helper: {error:#}"
1668 );
1669 std::thread::sleep(std::time::Duration::from_millis(10));
1670 }
1671 }
1672 };
1673
1674 assert_eq!(text, "from-wayland");
1675 }
1676 }
1677
1677 lines RUST