返回 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 /// Clipboard reader/writer helper.
302 pub struct ClipboardHandler {
303 terminal_context: TerminalClipboardContext,
304 terminal_writer: Option<TerminalClipboardWriter>,
305 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
306 primary: Option<primary::PrimarySelection>,
307 #[cfg(test)]
308 primary_enabled: bool,
309 #[cfg(test)]
310 primary_text: Option<String>,
311 #[cfg(any(
312 target_os = "macos",
313 target_os = "windows",
314 all(target_os = "linux", not(target_env = "ohos"))
315 ))]
316 clipboard: Option<Clipboard>,
317 #[cfg(any(
318 target_os = "macos",
319 target_os = "windows",
320 all(target_os = "linux", not(target_env = "ohos"))
321 ))]
322 clipboard_init_attempted: bool,
323 #[cfg(test)]
324 written_text: Vec<String>,
325 #[cfg(test)]
326 fail_text_writes: bool,
327 }
328
329 impl ClipboardHandler {
330 /// Create a new clipboard handler without connecting.
331 ///
332 /// The actual clipboard connection is deferred to first use
333 /// (`ensure_clipboard`) so that startup on hosts without an X11/Wayland
334 /// server (headless, WSL2) never blocks the TUI event loop.
335 pub fn new() -> Self {
336 Self::with_terminal_context(TerminalClipboardContext::detect())
337 }
338
339 fn with_terminal_context(terminal_context: TerminalClipboardContext) -> Self {
340 Self {
341 terminal_context,
342 terminal_writer: None,
343 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
344 primary: None,
345 #[cfg(test)]
346 primary_enabled: false,
347 #[cfg(test)]
348 primary_text: None,
349 #[cfg(any(
350 target_os = "macos",
351 target_os = "windows",
352 all(target_os = "linux", not(target_env = "ohos"))
353 ))]
354 clipboard: None,
355 #[cfg(any(
356 target_os = "macos",
357 target_os = "windows",
358 all(target_os = "linux", not(target_env = "ohos"))
359 ))]
360 clipboard_init_attempted: false,
361 #[cfg(test)]
362 written_text: Vec::new(),
363 #[cfg(test)]
364 fail_text_writes: false,
365 }
366 }
367
368 #[cfg(test)]
369 pub(crate) fn for_test(in_ssh_session: bool, in_tmux: bool) -> Self {
370 Self::with_terminal_context(TerminalClipboardContext {
371 endpoint: if in_ssh_session {
372 ClipboardEndpoint::TerminalClient
373 } else {
374 ClipboardEndpoint::NativeHost
375 },
376 in_tmux,
377 })
378 }
379
380 /// Construct a deterministic unavailable clipboard for command tests.
381 #[cfg(test)]
382 pub(crate) fn unavailable_for_test(in_ssh_session: bool) -> Self {
383 let mut handler = Self::for_test(in_ssh_session, false);
384 handler.fail_text_writes = true;
385 handler
386 }
387
388 /// SSH without a forwarded graphical display cannot synchronously read
389 /// the terminal client's clipboard. Paste must be initiated by the local
390 /// terminal so it arrives as bracketed paste (or a raw paste burst on
391 /// older terminals).
392 pub(crate) fn requires_terminal_paste(&self) -> bool {
393 self.terminal_context.requires_terminal_paste()
394 }
395
396 pub(crate) fn uses_primary_selection(&self) -> bool {
397 #[cfg(test)]
398 {
399 self.primary_enabled
400 }
401 #[cfg(not(test))]
402 {
403 cfg!(all(target_os = "linux", not(target_env = "ohos")))
404 }
405 }
406
407 /// Automatic selection never writes CLIPBOARD or sends OSC 52. A remote
408 /// terminal without a forwarded display owns its own selection and paste.
409 pub(crate) fn write_primary_text(&mut self, text: &str) -> Result<()> {
410 if !self.uses_primary_selection()
411 || !self.terminal_context.permits_native_read()
412 || text.is_empty()
413 || text.len() > PRIMARY_MAX_BYTES
414 {
415 bail!("PRIMARY selection unavailable");
416 }
417 #[cfg(test)]
418 {
419 if self.fail_text_writes {
420 bail!("test PRIMARY unavailable");
421 }
422 self.primary_text = Some(text.to_string());
423 Ok(())
424 }
425 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
426 {
427 self.primary_selection()?.write(text)
428 }
429 #[cfg(all(not(test), not(all(target_os = "linux", not(target_env = "ohos")))))]
430 {
431 bail!("PRIMARY selection unavailable")
432 }
433 }
434
435 pub(crate) fn read_primary_text(&mut self) -> Option<String> {
436 if !self.uses_primary_selection() || !self.terminal_context.permits_native_read() {
437 return None;
438 }
439 #[cfg(test)]
440 {
441 if self.fail_text_writes {
442 None
443 } else {
444 self.primary_text.clone()
445 }
446 }
447 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
448 {
449 self.primary_selection().ok()?.read()
450 }
451 #[cfg(all(not(test), not(all(target_os = "linux", not(target_env = "ohos")))))]
452 {
453 None
454 }
455 }
456
457 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
458 fn primary_selection(&mut self) -> Result<&primary::PrimarySelection> {
459 if self.primary.is_none() {
460 self.primary = Some(primary::PrimarySelection::spawn()?);
461 }
462 Ok(self.primary.as_ref().expect("PRIMARY worker initialized"))
463 }
464
465 #[cfg(test)]
466 pub(crate) fn enable_primary_for_test(&mut self) {
467 self.primary_enabled = true;
468 }
469
470 /// Try to connect to the system clipboard, bounded by a short timeout.
471 ///
472 /// On Linux, `arboard::Clipboard::new()` opens a blocking X11 connection.
473 /// When no X server is running (headless, WSL2 without WSLg), the connect
474 /// call can hang indefinitely. We spawn the connection attempt on a
475 /// temporary thread and give it 500 ms; if it doesn't return in time the
476 /// handler stays in fallback/no-op mode and `read`/`write_text` fall
477 /// through to their OSC 52 and pbcopy/powershell fallbacks.
478 #[cfg(any(
479 target_os = "macos",
480 target_os = "windows",
481 all(target_os = "linux", not(target_env = "ohos"))
482 ))]
483 fn ensure_clipboard(&mut self) {
484 if self.clipboard_init_attempted {
485 return;
486 }
487 self.clipboard_init_attempted = true;
488
489 let (tx, rx) = std::sync::mpsc::channel();
490 std::thread::spawn(move || {
491 let _ = tx.send(Clipboard::new().ok());
492 });
493 self.clipboard = rx
494 .recv_timeout(std::time::Duration::from_millis(500))
495 .ok()
496 .flatten();
497 }
498
499 /// Read the clipboard and return the parsed content.
500 ///
501 /// `workspace` is used as a fallback location when `~/.codewhale/` cannot
502 /// be resolved (e.g. running with a stripped HOME in CI sandboxes).
503 pub fn read(&mut self, workspace: &Path) -> Option<ClipboardContent> {
504 self.read_content(workspace, false)
505 }
506
507 /// Composer paste preserves headings, lists, links, tables and code from
508 /// rich applications. Credentials and configuration fields use `read` so
509 /// their literal text is never interpreted as Markdown.
510 pub fn read_markdown(&mut self, workspace: &Path) -> Option<ClipboardContent> {
511 self.read_content(workspace, true)
512 }
513
514 fn read_content(
515 &mut self,
516 workspace: &Path,
517 prefer_markdown: bool,
518 ) -> Option<ClipboardContent> {
519 // With no display exported over SSH there is no synchronously readable
520 // clipboard endpoint. A forwarded X11/Wayland display is explicit and
521 // remains readable, including its image clipboard.
522 if !self.terminal_context.permits_native_read() {
523 return None;
524 }
525
526 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
527 if !prefer_markdown && let Ok(text) = read_text_with_wlpaste() {
528 return Some(ClipboardContent::Text(text));
529 }
530
531 #[cfg(any(
532 target_os = "macos",
533 target_os = "windows",
534 all(target_os = "linux", not(target_env = "ohos"))
535 ))]
536 {
537 self.ensure_clipboard();
538 if let Some(clipboard) = self.clipboard.as_mut() {
539 if prefer_markdown
540 && let Ok(html) = clipboard.get().html()
541 && let Some(markdown) = clipboard_markdown(&html)
542 {
543 return Some(ClipboardContent::Text(markdown));
544 }
545 if let Ok(text) = clipboard.get_text() {
546 return Some(ClipboardContent::Text(text));
547 }
548
549 if let Ok(image) = clipboard.get_image()
550 && let Ok(pasted) = save_image_as_png(workspace, &image)
551 {
552 return Some(ClipboardContent::Image(pasted));
553 }
554 }
555 }
556
557 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
558 if prefer_markdown && let Ok(text) = read_text_with_wlpaste() {
559 return Some(ClipboardContent::Text(text));
560 }
561
562 let _ = (workspace, prefer_markdown);
563 None
564 }
565
566 /// Write text to the clipboard.
567 ///
568 /// Native clipboard transports complete before this method returns. OSC 52
569 /// and tmux terminal-client writes are validated and admitted to a bounded
570 /// background worker; asynchronous transport failures are exposed through
571 /// [`Self::poll_write_completion`].
572 pub fn write_text(&mut self, text: &str) -> Result<()> {
573 #[cfg(test)]
574 {
575 if let Some(writer) = self.terminal_writer.as_ref() {
576 return writer.enqueue(text, self.terminal_context.in_tmux);
577 }
578 if self.fail_text_writes {
579 bail!("test clipboard unavailable");
580 }
581 self.written_text.push(text.to_string());
582 Ok(())
583 }
584
585 #[cfg(not(test))]
586 {
587 if self.terminal_context.write_order() == ClipboardWriteOrder::TerminalClientOnly {
588 return self
589 .enqueue_terminal_write(text)
590 .map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}"));
591 }
592
593 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
594 if write_text_with_wlcopy(text).is_ok() {
595 return Ok(());
596 }
597
598 #[cfg(any(
599 target_os = "macos",
600 target_os = "windows",
601 all(target_os = "linux", not(target_env = "ohos"))
602 ))]
603 {
604 self.ensure_clipboard();
605 if let Some(clipboard) = self.clipboard.as_mut()
606 && clipboard.set_text(text.to_string()).is_ok()
607 {
608 return Ok(());
609 }
610 }
611
612 #[cfg(target_os = "macos")]
613 if write_text_with_pbcopy(text).is_ok() {
614 return Ok(());
615 }
616
617 #[cfg(target_os = "windows")]
618 if write_text_with_set_clipboard(text).is_ok() {
619 return Ok(());
620 }
621
622 self.enqueue_terminal_write(text)
623 .map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}"))
624 }
625 }
626
627 #[cfg(not(test))]
628 fn enqueue_terminal_write(&mut self, text: &str) -> Result<()> {
629 if !self.terminal_context.in_tmux {
630 if text.len() > OSC52_MAX_BYTES {
631 bail!("selection is too large for OSC 52 clipboard fallback");
632 }
633 if !io::stdout().is_terminal() {
634 bail!("OSC 52 clipboard fallback requires a terminal");
635 }
636 }
637
638 if self.terminal_writer.is_none() {
639 self.terminal_writer = Some(TerminalClipboardWriter::spawn()?);
640 }
641 self.terminal_writer
642 .as_ref()
643 .expect("terminal clipboard writer initialized")
644 .enqueue(text, self.terminal_context.in_tmux)
645 }
646
647 /// Return one completed background terminal clipboard write, if available.
648 ///
649 /// Successes are intentionally quiet because callers already show their
650 /// normal copy receipt. Failures are drained by the event loop and replace
651 /// that optimistic receipt with an actionable error.
652 pub(crate) fn poll_write_completion(&self) -> Option<TerminalClipboardWriteCompletion> {
653 self.terminal_writer
654 .as_ref()
655 .and_then(TerminalClipboardWriter::poll_completion)
656 }
657
658 #[cfg(test)]
659 pub fn last_written_text(&self) -> Option<&str> {
660 self.written_text.last().map(String::as_str)
661 }
662 }
663
664 #[cfg(all(target_os = "macos", not(test)))]
665 fn write_text_with_pbcopy(text: &str) -> Result<()> {
666 write_text_with_stdin_command("pbcopy", &[], text, "pbcopy")
667 }
668
669 #[cfg(all(target_os = "windows", not(test)))]
670 fn write_text_with_set_clipboard(text: &str) -> Result<()> {
671 write_text_with_stdin_command(
672 "powershell.exe",
673 &["-NoProfile", "-Command", "Set-Clipboard -Value $input"],
674 text,
675 "Set-Clipboard",
676 )
677 }
678
679 #[cfg(all(any(target_os = "macos", target_os = "windows"), not(test)))]
680 fn write_text_with_stdin_command(
681 program: &str,
682 args: &[&str],
683 text: &str,
684 label: &str,
685 ) -> Result<()> {
686 let mut child = Command::new(program)
687 .args(args)
688 .stdin(Stdio::piped())
689 .stdout(Stdio::null())
690 .stderr(Stdio::null())
691 .spawn()
692 .map_err(|e| anyhow::anyhow!("Failed to run {label}: {e}"))?;
693 if let Some(mut stdin) = child.stdin.take() {
694 stdin
695 .write_all(text.as_bytes())
696 .map_err(|e| anyhow::anyhow!("Failed to write to {label}: {e}"))?;
697 }
698 let _ = std::thread::Builder::new()
699 .name("clipboard-wait".to_string())
700 .spawn(move || {
701 let _ = child.wait();
702 });
703 Ok(())
704 }
705
706 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
707 fn write_text_with_wlcopy(text: &str) -> Result<()> {
708 write_text_with_wlcopy_using_argv("wl-copy", text)
709 }
710
711 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
712 fn read_text_with_wlpaste() -> Result<String> {
713 read_text_with_wlpaste_using_argv("wl-paste")
714 }
715
716 #[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))]
717 fn read_text_with_wlpaste_using_argv(program: &str) -> Result<String> {
718 let output = Command::new(program)
719 .arg("--no-newline")
720 .arg("--type")
721 .arg("text/plain")
722 .stdout(Stdio::piped())
723 .stderr(Stdio::null())
724 .output()
725 .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
726 if !output.status.success() {
727 bail!("{program} exited with {}", output.status);
728 }
729 String::from_utf8(output.stdout).context("wl-paste returned non-UTF-8 text")
730 }
731
732 #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
733 fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> {
734 let mut child = Command::new(program)
735 .stdin(Stdio::piped())
736 .stdout(Stdio::null())
737 .stderr(Stdio::null())
738 .spawn()
739 .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
740 if let Some(mut stdin) = child.stdin.take() {
741 stdin
742 .write_all(text.as_bytes())
743 .map_err(|e| anyhow::anyhow!("Failed to write to {program}: {e}"))?;
744 }
745 // stdin is dropped here, closing the pipe so wl-copy flushes.
746 let status = child
747 .wait()
748 .map_err(|e| anyhow::anyhow!("Failed to wait on {program}: {e}"))?;
749 if !status.success() {
750 bail!("{program} exited with {status}");
751 }
752 Ok(())
753 }
754
755 #[cfg(not(test))]
756 fn write_text_to_terminal_client(text: &str, in_tmux: bool) -> Result<()> {
757 if in_tmux {
758 return write_text_with_tmux(text);
759 }
760 write_text_with_osc52(text)
761 }
762
763 #[cfg(not(test))]
764 fn write_text_with_tmux(text: &str) -> Result<()> {
765 write_text_with_tmux_using_argv("tmux", &[], text)
766 }
767
768 /// Ask tmux to set both its paste buffer and the attached client's clipboard.
769 /// Unlike DCS passthrough, `load-buffer -w` works with tmux's default
770 /// `allow-passthrough off` policy and returns a non-zero status when tmux
771 /// cannot honor the command.
772 #[cfg(any(not(test), all(test, unix)))]
773 fn write_text_with_tmux_using_argv(program: &str, prefix_args: &[&str], text: &str) -> Result<()> {
774 let mut child = Command::new(program)
775 .args(prefix_args)
776 .args(["load-buffer", "-w", "-"])
777 .stdin(Stdio::piped())
778 .stdout(Stdio::null())
779 .stderr(Stdio::piped())
780 .spawn()
781 .map_err(|e| anyhow::anyhow!("Failed to run tmux load-buffer -w: {e}"))?;
782
783 let write_result = child
784 .stdin
785 .take()
786 .context("open tmux clipboard input")
787 .and_then(|mut stdin| {
788 stdin
789 .write_all(text.as_bytes())
790 .context("write tmux clipboard input")
791 });
792 let output = child
793 .wait_with_output()
794 .context("wait for tmux load-buffer -w")?;
795 write_result?;
796 if !output.status.success() {
797 let detail = String::from_utf8_lossy(&output.stderr);
798 let detail = detail.trim();
799 if detail.is_empty() {
800 bail!("tmux load-buffer -w exited with {}", output.status);
801 }
802 bail!(
803 "tmux load-buffer -w exited with {}: {detail}",
804 output.status
805 );
806 }
807 Ok(())
808 }
809
810 #[cfg(not(test))]
811 fn write_text_with_osc52(text: &str) -> Result<()> {
812 let mut stdout = io::stdout();
813 if !stdout.is_terminal() {
814 bail!("OSC 52 clipboard fallback requires a terminal");
815 }
816
817 let sequence = osc52_sequence(text)?;
818 stdout
819 .write_all(sequence.as_bytes())
820 .context("write OSC 52 clipboard sequence")?;
821 stdout.flush().context("flush OSC 52 clipboard sequence")
822 }
823
824 fn osc52_sequence(text: &str) -> Result<String> {
825 if text.len() > OSC52_MAX_BYTES {
826 bail!("selection is too large for OSC 52 clipboard fallback");
827 }
828
829 let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
830 Ok(format!("\x1b]52;c;{encoded}\x07"))
831 }
832
833 /// Resolve the directory pasted images should land in. Prefers
834 /// `~/.codewhale/clipboard-images/` so the path is stable across worktrees and
835 /// matches the location described in user-facing docs; falls back to
836 /// `<workspace>/clipboard-images/` if the home dir is unavailable.
837 pub(crate) fn clipboard_images_dir(workspace: &Path) -> PathBuf {
838 let home = crate::config::effective_home_dir();
839 clipboard_images_dir_for_home(workspace, home.as_deref())
840 }
841
842 fn clipboard_images_dir_for_home(workspace: &Path, home: Option<&Path>) -> PathBuf {
843 if let Some(home) = home {
844 return home.join(".codewhale").join("clipboard-images");
845 }
846 workspace.join("clipboard-images")
847 }
848
849 /// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns
850 /// the resulting path along with metadata used to render the paste hint.
851 #[cfg(any(
852 target_os = "macos",
853 target_os = "windows",
854 all(target_os = "linux", not(target_env = "ohos"))
855 ))]
856 fn save_image_as_png(workspace: &Path, image: &ImageData) -> Result<PastedImage> {
857 save_image_as_png_in(&clipboard_images_dir(workspace), image)
858 }
859
860 /// Lower-level variant that writes into an explicit directory. Exposed so the
861 /// unit tests don't have to scribble inside the user's real home directory.
862 #[cfg(any(
863 target_os = "macos",
864 target_os = "windows",
865 all(target_os = "linux", not(target_env = "ohos"))
866 ))]
867 fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result<PastedImage> {
868 std::fs::create_dir_all(dir).context("create clipboard-images dir")?;
869
870 let timestamp = SystemTime::now()
871 .duration_since(UNIX_EPOCH)
872 .unwrap_or_default()
873 .as_nanos();
874 let path = dir.join(format!("clipboard-{timestamp}.png"));
875
876 let width = u32::try_from(image.width).context("clipboard image width too large")?;
877 let height = u32::try_from(image.height).context("clipboard image height too large")?;
878
879 // arboard hands us RGBA8 row-major. Copy into an ImageBuffer so we can
880 // run it through the `image` crate's PNG encoder. We pad / truncate any
881 // mismatched trailing bytes — defensive only, arboard already validates
882 // the buffer length on every supported backend.
883 let expected = (width as usize) * (height as usize) * 4;
884 let mut rgba = image.bytes.as_ref().to_vec();
885 if rgba.len() < expected {
886 rgba.resize(expected, 0);
887 } else if rgba.len() > expected {
888 rgba.truncate(expected);
889 }
890
891 let buffer: ImageBuffer<Rgba<u8>, _> = ImageBuffer::from_raw(width, height, rgba)
892 .context("clipboard image dimensions did not match buffer length")?;
893 buffer
894 .save_with_format(&path, image::ImageFormat::Png)
895 .context("write clipboard PNG")?;
896
897 let byte_len = std::fs::metadata(&path)
898 .map(|m| m.len() as usize)
899 .unwrap_or(0);
900 Ok(PastedImage {
901 path,
902 width,
903 height,
904 byte_len,
905 })
906 }
907
908 #[cfg(test)]
909 mod tests {
910 use super::*;
911
912 #[test]
913 fn primary_transport_is_distinct_bounded_and_never_uses_ssh_host_clipboard() {
914 let mut clipboard = ClipboardHandler::for_test(false, false);
915 clipboard.enable_primary_for_test();
916 clipboard.write_text("regular").unwrap();
917 clipboard.write_primary_text("selected").unwrap();
918 assert_eq!(clipboard.last_written_text(), Some("regular"));
919 assert_eq!(clipboard.read_primary_text().as_deref(), Some("selected"));
920 assert!(clipboard.write_primary_text("").is_err());
921 assert!(
922 clipboard
923 .write_primary_text(&"x".repeat(PRIMARY_MAX_BYTES + 1))
924 .is_err()
925 );
926 assert_eq!(clipboard.read_primary_text().as_deref(), Some("selected"));
927 let mut remote = ClipboardHandler::for_test(true, false);
928 remote.enable_primary_for_test();
929 assert!(remote.write_primary_text("private").is_err());
930 assert!(remote.read_primary_text().is_none());
931 assert!(remote.last_written_text().is_none());
932 let mut forwarded = ClipboardHandler::with_terminal_context(TerminalClipboardContext {
933 endpoint: ClipboardEndpoint::ForwardedDisplay,
934 in_tmux: false,
935 });
936 forwarded.enable_primary_for_test();
937 forwarded.write_primary_text("forwarded").unwrap();
938 assert_eq!(forwarded.read_primary_text().as_deref(), Some("forwarded"));
939 }
940
941 #[test]
942 fn clipboard_markdown_preserves_rich_structure_and_code() {
943 let html = r#"<h1>Release plan</h1><p>Keep <strong>authorship</strong> and
944 <a href="https://example.com/review">review</a>.</p>
945 <ul><li>Run gates</li><li>Dogfood</li></ul>
946 <pre><code>fn main() {
947 println!("&lt;ready&gt;");
948 }</code></pre>
949 <table><tr><th>Gate</th><th>Result</th></tr><tr><td>Tests</td><td>Pass</td></tr></table>"#;
950 let markdown = clipboard_markdown(html).expect("rich text converts");
951 assert!(markdown.contains("# Release plan"), "{markdown}");
952 assert!(markdown.contains("**authorship**"), "{markdown}");
953 assert!(
954 markdown.contains("[review](https://example.com/review)"),
955 "{markdown}"
956 );
957 assert!(markdown.contains("Run gates") && markdown.contains("Dogfood"));
958 assert!(markdown.contains("```"), "{markdown}");
959 assert!(markdown.contains("println!(\"<ready>\");"), "{markdown}");
960 assert!(
961 markdown
962 .lines()
963 .any(|line| line.split('|').map(str::trim).collect::<Vec<_>>()
964 == ["", "Gate", "Result", ""]),
965 "{markdown}"
966 );
967 }
968
969 #[test]
970 fn clipboard_markdown_omits_executable_markup_and_falls_back_losslessly() {
971 assert_eq!(
972 clipboard_markdown("<script>secret()</script><style>secret</style>"),
973 None
974 );
975 assert_eq!(
976 clipboard_markdown(&"x".repeat(MAX_CLIPBOARD_HTML_BYTES + 1)),
977 None
978 );
979 let markdown = clipboard_markdown("<h1>Release plan</h1>").unwrap();
980 assert_eq!(markdown.trim(), "Release plan\n===");
981 assert!(markdown.trim().contains('\n'), "not a memory quick-add");
982 }
983 // ImageData from arboard is only available on these platforms.
984 #[cfg(any(
985 target_os = "macos",
986 target_os = "windows",
987 all(target_os = "linux", not(target_env = "ohos"))
988 ))]
989 use std::borrow::Cow;
990 #[cfg(unix)]
991 use std::os::unix::fs::PermissionsExt;
992
993 #[test]
994 fn terminal_clipboard_write_does_not_wait_for_slow_transport() {
995 let (transport_started_tx, transport_started_rx) = std::sync::mpsc::channel();
996 let (release_transport_tx, release_transport_rx) = std::sync::mpsc::channel();
997 let writer = TerminalClipboardWriter::spawn_with(move |request| {
998 assert_eq!(request.text, "copied");
999 assert!(!request.in_tmux);
1000 transport_started_tx
1001 .send(())
1002 .expect("announce transport start");
1003 release_transport_rx.recv().expect("release slow transport");
1004 Ok(())
1005 })
1006 .expect("spawn clipboard writer");
1007 let mut clipboard = ClipboardHandler::for_test(true, false);
1008 clipboard.terminal_writer = Some(writer);
1009
1010 let (caller_returned_tx, caller_returned_rx) = std::sync::mpsc::channel();
1011 let caller = std::thread::spawn(move || {
1012 let result = clipboard.write_text("copied");
1013 caller_returned_tx
1014 .send((clipboard, result))
1015 .expect("report caller completion");
1016 });
1017
1018 let (clipboard, result) =
1019 match caller_returned_rx.recv_timeout(std::time::Duration::from_millis(250)) {
1020 Ok(value) => value,
1021 Err(err) => {
1022 let _ = release_transport_tx.send(());
1023 caller.join().expect("join clipboard caller");
1024 panic!("clipboard caller waited for slow transport: {err}");
1025 }
1026 };
1027 result.expect("queue clipboard write");
1028 transport_started_rx
1029 .recv_timeout(std::time::Duration::from_millis(250))
1030 .expect("worker started transport");
1031 assert!(
1032 clipboard.poll_write_completion().is_none(),
1033 "transport must remain pending until explicitly released"
1034 );
1035
1036 release_transport_tx.send(()).expect("release transport");
1037 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1038 loop {
1039 if let Some(completion) = clipboard.poll_write_completion() {
1040 completion.expect("background clipboard completion");
1041 break;
1042 }
1043 assert!(
1044 std::time::Instant::now() < deadline,
1045 "background clipboard completion timed out"
1046 );
1047 std::thread::sleep(std::time::Duration::from_millis(10));
1048 }
1049 caller.join().expect("join clipboard caller");
1050 }
1051
1052 #[test]
1053 fn terminal_clipboard_write_reports_background_failure() {
1054 let writer =
1055 TerminalClipboardWriter::spawn_with(|_| bail!("terminal clipboard transport denied"))
1056 .expect("spawn clipboard writer");
1057 writer
1058 .enqueue("copied", false)
1059 .expect("queue clipboard write");
1060
1061 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1062 loop {
1063 if let Some(completion) = writer.poll_completion() {
1064 let err = completion.expect_err("transport should fail");
1065 assert!(err.contains("transport denied"), "{err}");
1066 break;
1067 }
1068 assert!(
1069 std::time::Instant::now() < deadline,
1070 "background clipboard failure timed out"
1071 );
1072 std::thread::sleep(std::time::Duration::from_millis(10));
1073 }
1074 }
1075
1076 #[cfg(any(
1077 target_os = "macos",
1078 target_os = "windows",
1079 all(target_os = "linux", not(target_env = "ohos"))
1080 ))]
1081 fn solid_rgba(width: u16, height: u16, rgba: [u8; 4]) -> ImageData<'static> {
1082 let mut bytes = Vec::with_capacity((width as usize) * (height as usize) * 4);
1083 for _ in 0..(width as usize * height as usize) {
1084 bytes.extend_from_slice(&rgba);
1085 }
1086 ImageData {
1087 width: width as usize,
1088 height: height as usize,
1089 bytes: Cow::Owned(bytes),
1090 }
1091 }
1092
1093 #[test]
1094 #[cfg(any(
1095 target_os = "macos",
1096 target_os = "windows",
1097 all(target_os = "linux", not(target_env = "ohos"))
1098 ))]
1099 fn save_image_as_png_writes_valid_png() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let img = solid_rgba(8, 4, [255, 0, 0, 255]);
1102 let pasted = save_image_as_png_in(dir.path(), &img).expect("encode png");
1103
1104 assert_eq!(pasted.width, 8);
1105 assert_eq!(pasted.height, 4);
1106 assert!(pasted.byte_len > 0);
1107 assert_eq!(
1108 pasted.path.extension().and_then(|s| s.to_str()),
1109 Some("png")
1110 );
1111
1112 // The first eight bytes of any PNG file are the magic signature; if
1113 // we ever regress to PPM or another format this will catch it.
1114 let header = std::fs::read(&pasted.path).unwrap();
1115 assert_eq!(&header[..8], b"\x89PNG\r\n\x1a\n");
1116 }
1117
1118 #[test]
1119 fn clipboard_images_dir_uses_codewhale_home_directory() {
1120 let home = tempfile::tempdir().unwrap();
1121 let workspace = tempfile::tempdir().unwrap();
1122
1123 assert_eq!(
1124 clipboard_images_dir_for_home(workspace.path(), Some(home.path())),
1125 home.path().join(".codewhale").join("clipboard-images")
1126 );
1127 }
1128
1129 #[test]
1130 fn clipboard_images_dir_falls_back_to_workspace_without_home() {
1131 let workspace = tempfile::tempdir().unwrap();
1132
1133 assert_eq!(
1134 clipboard_images_dir_for_home(workspace.path(), None),
1135 workspace.path().join("clipboard-images")
1136 );
1137 }
1138
1139 #[test]
1140 fn pasted_image_labels_format_correctly() {
1141 let p = PastedImage {
1142 path: PathBuf::from("/tmp/x.png"),
1143 width: 1024,
1144 height: 768,
1145 byte_len: 235 * 1024,
1146 };
1147 assert_eq!(p.short_label(), "1024x768 PNG");
1148 assert_eq!(p.size_label(), "235KB");
1149 }
1150
1151 #[test]
1152 fn ssh_detection_covers_openssh_markers_and_ignores_empty_values() {
1153 let client = TerminalClipboardContext::from_env_values(
1154 Some(OsStr::new("192.0.2.10 51234 22")),
1155 None,
1156 None,
1157 None,
1158 None,
1159 None,
1160 None,
1161 );
1162 let connection = TerminalClipboardContext::from_env_values(
1163 None,
1164 Some(OsStr::new("192.0.2.10 51234 192.0.2.20 22")),
1165 None,
1166 None,
1167 None,
1168 None,
1169 None,
1170 );
1171 let tty = TerminalClipboardContext::from_env_values(
1172 None,
1173 None,
1174 Some(OsStr::new("/dev/pts/4")),
1175 None,
1176 None,
1177 None,
1178 None,
1179 );
1180 let empty = TerminalClipboardContext::from_env_values(
1181 Some(OsStr::new("")),
1182 Some(OsStr::new("")),
1183 Some(OsStr::new("")),
1184 Some(OsStr::new("")),
1185 Some(OsStr::new("")),
1186 Some(OsStr::new("")),
1187 Some(OsStr::new("")),
1188 );
1189
1190 assert_eq!(client.endpoint, ClipboardEndpoint::TerminalClient);
1191 assert_eq!(connection.endpoint, ClipboardEndpoint::TerminalClient);
1192 assert_eq!(tty.endpoint, ClipboardEndpoint::TerminalClient);
1193 assert_eq!(empty.endpoint, ClipboardEndpoint::NativeHost);
1194 assert!(!empty.in_tmux);
1195 }
1196
1197 #[test]
1198 fn ssh_without_display_targets_terminal_client() {
1199 let remote_tmux = TerminalClipboardContext::from_env_values(
1200 Some(OsStr::new("192.0.2.10 51234 22")),
1201 None,
1202 None,
1203 None,
1204 None,
1205 None,
1206 Some(OsStr::new("/tmp/tmux-1000/default,1,0")),
1207 );
1208 let local =
1209 TerminalClipboardContext::from_env_values(None, None, None, None, None, None, None);
1210
1211 assert_eq!(
1212 remote_tmux.write_order(),
1213 ClipboardWriteOrder::TerminalClientOnly
1214 );
1215 assert!(!remote_tmux.permits_native_read());
1216 assert!(remote_tmux.requires_terminal_paste());
1217 assert!(remote_tmux.in_tmux);
1218 assert_eq!(
1219 local.write_order(),
1220 ClipboardWriteOrder::NativeHostThenTerminal
1221 );
1222 assert!(local.permits_native_read());
1223 assert!(!local.requires_terminal_paste());
1224 }
1225
1226 #[test]
1227 fn ssh_uses_forwarded_x11_or_explicit_graphical_clipboard_endpoint() {
1228 let x11 = TerminalClipboardContext::from_env_values(
1229 None,
1230 Some(OsStr::new("192.0.2.10 51234 192.0.2.20 22")),
1231 None,
1232 Some(OsStr::new("localhost:10.0")),
1233 None,
1234 None,
1235 None,
1236 );
1237 let wayland = TerminalClipboardContext::from_env_values(
1238 Some(OsStr::new("192.0.2.10 51234 22")),
1239 None,
1240 None,
1241 None,
1242 Some(OsStr::new("wayland-1")),
1243 Some(OsStr::new("graphical")),
1244 None,
1245 );
1246
1247 for context in [x11, wayland] {
1248 assert_eq!(context.endpoint, ClipboardEndpoint::ForwardedDisplay);
1249 assert_eq!(
1250 context.write_order(),
1251 ClipboardWriteOrder::NativeHostThenTerminal
1252 );
1253 assert!(context.permits_native_read());
1254 assert!(!context.requires_terminal_paste());
1255 }
1256
1257 let ambient_remote = TerminalClipboardContext::from_env_values(
1258 Some(OsStr::new("192.0.2.10 51234 22")),
1259 None,
1260 None,
1261 Some(OsStr::new(":0")),
1262 Some(OsStr::new("wayland-0")),
1263 None,
1264 None,
1265 );
1266 assert_eq!(ambient_remote.endpoint, ClipboardEndpoint::TerminalClient);
1267
1268 let forced_terminal = TerminalClipboardContext::from_env_values(
1269 Some(OsStr::new("192.0.2.10 51234 22")),
1270 None,
1271 None,
1272 Some(OsStr::new("localhost:10.0")),
1273 None,
1274 Some(OsStr::new("terminal")),
1275 None,
1276 );
1277 assert_eq!(forced_terminal.endpoint, ClipboardEndpoint::TerminalClient);
1278 }
1279
1280 #[test]
1281 fn osc52_sequence_encodes_text_clipboard_write() {
1282 let sequence = osc52_sequence("hello").expect("sequence");
1283 assert_eq!(sequence, "\x1b]52;c;aGVsbG8=\x07");
1284 }
1285
1286 #[test]
1287 fn osc52_sequence_rejects_oversized_selection() {
1288 let text = "x".repeat(OSC52_MAX_BYTES + 1);
1289 let err = osc52_sequence(&text).expect_err("oversized should fail");
1290 assert!(
1291 err.to_string().contains("too large"),
1292 "unexpected error: {err}"
1293 );
1294 }
1295
1296 #[cfg(unix)]
1297 #[test]
1298 fn tmux_helper_reports_command_failure() {
1299 let dir = tempfile::tempdir().unwrap();
1300 let script = dir.path().join("tmux");
1301 std::fs::write(
1302 &script,
1303 r#"#!/bin/sh
1304 cat >/dev/null
1305 echo 'clipboard denied' >&2
1306 exit 42
1307 "#,
1308 )
1309 .unwrap();
1310 let mut perms = std::fs::metadata(&script).unwrap().permissions();
1311 perms.set_mode(0o755);
1312 std::fs::set_permissions(&script, perms).unwrap();
1313
1314 let err = write_text_with_tmux_using_argv(script.to_str().unwrap(), &[], "copy")
1315 .expect_err("non-zero tmux status should fail");
1316
1317 assert!(err.to_string().contains("exited with"));
1318 assert!(err.to_string().contains("clipboard denied"));
1319 }
1320
1321 #[cfg(all(unix, not(target_env = "ohos")))]
1322 #[test]
1323 fn tmux_load_buffer_w_reaches_attached_client_with_default_passthrough_disabled() {
1324 use std::io::Read as _;
1325
1326 // Every subprocess must inherit the same terminal environment, not
1327 // another fixture's transient PATH/TERM/multiplexer overrides.
1328 let _env = crate::test_support::lock_test_env();
1329
1330 let version = match Command::new("tmux").arg("-V").output() {
1331 Ok(output) if output.status.success() => output,
1332 _ => return,
1333 };
1334 assert!(
1335 String::from_utf8_lossy(&version.stdout).starts_with("tmux "),
1336 "unexpected tmux version output"
1337 );
1338
1339 let nonce = std::time::SystemTime::now()
1340 .duration_since(std::time::UNIX_EPOCH)
1341 .expect("clock after epoch")
1342 .as_nanos();
1343 let socket = format!("codewhale-clipboard-{}-{nonce}", std::process::id());
1344
1345 struct TmuxServer(String);
1346 impl Drop for TmuxServer {
1347 fn drop(&mut self) {
1348 let _ = Command::new("tmux")
1349 .args(["-L", self.0.as_str(), "kill-server"])
1350 .status();
1351 }
1352 }
1353 let server = TmuxServer(socket);
1354 let started = Command::new("tmux")
1355 .args([
1356 "-L",
1357 server.0.as_str(),
1358 "-f",
1359 "/dev/null",
1360 "new-session",
1361 "-d",
1362 ])
1363 .status()
1364 .expect("start isolated tmux server");
1365 assert!(started.success(), "isolated tmux server should start");
1366
1367 let option = |name: &str| {
1368 let output = Command::new("tmux")
1369 .args(["-L", server.0.as_str(), "show-options", "-gv", name])
1370 .output()
1371 .expect("read tmux option");
1372 assert!(output.status.success(), "read tmux option {name}");
1373 String::from_utf8(output.stdout)
1374 .expect("tmux option should be utf-8")
1375 .trim()
1376 .to_string()
1377 };
1378 assert_eq!(option("allow-passthrough"), "off");
1379 assert_eq!(option("set-clipboard"), "external");
1380
1381 let pty_system = portable_pty::native_pty_system();
1382 let pair = pty_system
1383 .openpty(portable_pty::PtySize {
1384 rows: 24,
1385 cols: 80,
1386 pixel_width: 0,
1387 pixel_height: 0,
1388 })
1389 .expect("open attached-client PTY");
1390 let mut attach = portable_pty::CommandBuilder::new("tmux");
1391 for arg in ["-L", server.0.as_str(), "attach-session", "-t", "0"] {
1392 attach.arg(arg);
1393 }
1394 attach.env("TERM", "xterm-256color");
1395 let mut attached_client = pair
1396 .slave
1397 .spawn_command(attach)
1398 .expect("attach tmux client to PTY");
1399 drop(pair.slave);
1400
1401 let mut reader = pair
1402 .master
1403 .try_clone_reader()
1404 .expect("clone attached-client PTY reader");
1405 let (output_tx, output_rx) = std::sync::mpsc::channel();
1406 let reader_thread = std::thread::spawn(move || {
1407 let mut chunk = [0_u8; 4096];
1408 loop {
1409 match reader.read(&mut chunk) {
1410 Ok(0) | Err(_) => break,
1411 Ok(len) => {
1412 if output_tx.send(chunk[..len].to_vec()).is_err() {
1413 break;
1414 }
1415 }
1416 }
1417 }
1418 });
1419
1420 // Load-tolerant bounds, not the contract under test: on a machine
1421 // running a full parallel suite, tmux server startup and OSC 52
1422 // forwarding can both exceed a tight 3s wall clock (#5929). The test
1423 // still verifies the *content* of what reaches the attached client;
1424 // only how long it is willing to wait for a loaded machine changed.
1425 let attach_deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1426 loop {
1427 let clients = Command::new("tmux")
1428 .args(["-L", server.0.as_str(), "list-clients"])
1429 .output()
1430 .expect("list attached tmux clients");
1431 if clients.status.success() && !clients.stdout.is_empty() {
1432 break;
1433 }
1434 assert!(
1435 std::time::Instant::now() < attach_deadline,
1436 "tmux client did not attach to the test PTY"
1437 );
1438 std::thread::sleep(std::time::Duration::from_millis(25));
1439 }
1440 // A listed client can precede its terminal startup. tmux discards
1441 // clipboard requests before TTY_STARTED; actual PTY output establishes
1442 // that startup reached the terminal before the one request we verify.
1443 output_rx
1444 .recv_timeout(attach_deadline.saturating_duration_since(std::time::Instant::now()))
1445 .expect("attached tmux client should produce terminal startup output");
1446 while output_rx.try_recv().is_ok() {}
1447
1448 let copied_text = "copy through default tmux";
1449 write_text_with_tmux_using_argv("tmux", &["-L", server.0.as_str()], copied_text)
1450 .expect("tmux-native clipboard request");
1451
1452 let encoded = base64::engine::general_purpose::STANDARD.encode(copied_text.as_bytes());
1453 let expected_receipts = [
1454 format!("\x1b]52;;{encoded}\x07").into_bytes(),
1455 format!("\x1b]52;c;{encoded}\x07").into_bytes(),
1456 format!("\x1b]52;;{encoded}\x1b\\").into_bytes(),
1457 format!("\x1b]52;c;{encoded}\x1b\\").into_bytes(),
1458 ];
1459 let receipt_deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1460 let mut attached_output = Vec::new();
1461 let receipt_received = loop {
1462 if expected_receipts.iter().any(|receipt| {
1463 attached_output
1464 .windows(receipt.len())
1465 .any(|window| window == receipt)
1466 }) {
1467 break true;
1468 }
1469 if std::time::Instant::now() >= receipt_deadline {
1470 break false;
1471 }
1472 match output_rx.recv_timeout(std::time::Duration::from_millis(50)) {
1473 Ok(bytes) => attached_output.extend_from_slice(&bytes),
1474 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1475 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break false,
1476 }
1477 };
1478
1479 let buffer = Command::new("tmux")
1480 .args(["-L", server.0.as_str(), "show-buffer"])
1481 .output()
1482 .expect("read tmux buffer");
1483 assert!(buffer.status.success(), "tmux buffer should be readable");
1484 assert_eq!(buffer.stdout, copied_text.as_bytes());
1485
1486 let _ = attached_client.kill();
1487 let _ = attached_client.wait();
1488 drop(pair.master);
1489 drop(output_rx);
1490 let _ = reader_thread.join();
1491
1492 assert!(
1493 receipt_received,
1494 "attached tmux client did not receive the OSC 52 clipboard request: {attached_output:?}"
1495 );
1496 }
1497
1498 #[cfg(unix)]
1499 #[test]
1500 fn wl_paste_helper_reads_text_from_stdout() {
1501 let dir = tempfile::tempdir().unwrap();
1502 let script = dir.path().join("wl-paste");
1503 std::fs::write(
1504 &script,
1505 r#"#!/bin/sh
1506 seen_no_newline=0
1507 seen_text_plain=0
1508 while [ "$#" -gt 0 ]; do
1509 case "$1" in
1510 --no-newline) seen_no_newline=1 ;;
1511 --type)
1512 shift
1513 [ "${1:-}" = "text/plain" ] && seen_text_plain=1
1514 ;;
1515 esac
1516 shift
1517 done
1518 [ "$seen_text_plain" -eq 1 ] || exit 40
1519 if [ "$seen_no_newline" -eq 1 ]; then
1520 printf 'from-wayland'
1521 else
1522 printf 'from-wayland\n'
1523 fi
1524 "#,
1525 )
1526 .unwrap();
1527 let mut perms = std::fs::metadata(&script).unwrap().permissions();
1528 perms.set_mode(0o755);
1529 std::fs::set_permissions(&script, perms).unwrap();
1530
1531 // A freshly written helper script can transiently report ETXTBSY
1532 // ("Text file busy") when a concurrent test in this parallel suite
1533 // forks while the write descriptor is still inherited. Retry the
1534 // exec briefly so this assertion exercises the helper contract
1535 // rather than the fork/exec window; the bound is load tolerance,
1536 // not the behavior under test (same class as #5929).
1537 let mut attempts = 0;
1538 let text = loop {
1539 match read_text_with_wlpaste_using_argv(script.to_str().unwrap()) {
1540 Ok(text) => break text,
1541 Err(error) => {
1542 attempts += 1;
1543 let busy = error.to_string().contains("Text file busy");
1544 assert!(
1545 busy && attempts < 100,
1546 "read text through wl-paste helper: {error:#}"
1547 );
1548 std::thread::sleep(std::time::Duration::from_millis(10));
1549 }
1550 }
1551 };
1552
1553 assert_eq!(text, "from-wayland");
1554 }
1555 }
1556
1556 lines RUST