//! System-clipboard delivery for the `copy` command (ADR-0041). //! //! A copy **always does both** of two writes, because OSC 52 //! acceptance is undetectable (the terminal sends no //! acknowledgement, so a true "fall back when OSC 52 is //! unsupported" cannot be built): //! //! 1. An **OSC 52** terminal escape (`ESC ] 52 ; c ; BEL`), //! which needs no native dependency and reaches the *local* //! clipboard even over SSH. Inside tmux it is wrapped in tmux's //! DCS passthrough so it reaches the outer terminal. //! 2. A best-effort **native** write (`arboard`). A failure (e.g. a //! headless host with no display) is ignored — OSC 52 has already //! carried the payload. //! //! The two carry identical content, so on a local desktop the //! redundant double-write is harmless. //! //! The pure parts ([`osc52_sequence`], [`emit_osc52`], [`deliver`]) //! are unit-tested against in-memory sinks; the native side is //! abstracted behind [`NativeClipboard`] so [`deliver`] can be tested //! without a display or touching the real clipboard. use std::io::{self, Write}; use base64::Engine as _; /// ASCII escape — opens the OSC and (doubled) the tmux passthrough. const ESC: char = '\u{1b}'; /// ASCII bell — terminates the OSC 52 string. const BEL: char = '\u{07}'; /// Build the OSC 52 clipboard-set escape for `text`. /// /// When `tmux` is true the sequence is wrapped in tmux's DCS /// passthrough (`ESC P tmux; … ESC \`), with every `ESC` in the /// inner sequence doubled, so it survives a tmux layer (the common /// SSH-into-tmux case). The selection parameter is `c` (clipboard). #[must_use] pub fn osc52_sequence(text: &str, tmux: bool) -> String { let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); let inner = format!("{ESC}]52;c;{b64}{BEL}"); if tmux { let doubled = inner.replace(ESC, &format!("{ESC}{ESC}")); format!("{ESC}Ptmux;{doubled}{ESC}\\") } else { inner } } /// Emit the OSC 52 sequence for `text` to `w` and flush. pub fn emit_osc52(w: &mut W, text: &str, tmux: bool) -> io::Result<()> { w.write_all(osc52_sequence(text, tmux).as_bytes())?; w.flush() } /// True when running inside tmux (`$TMUX` is set). #[must_use] pub fn in_tmux() -> bool { std::env::var_os("TMUX").is_some() } /// The native (OS) clipboard sink, abstracted so [`deliver`] is /// testable without a real display. pub trait NativeClipboard { /// Write `text` to the OS clipboard, best-effort. Implementations /// must swallow their own errors (a headless host is expected). fn set_text(&mut self, text: &str); } /// Deliver `text` to the clipboard via **both** paths (ADR-0041): /// the OSC 52 escape to `w`, then the best-effort native write. /// /// The native write is attempted **regardless** of the OSC 52 result /// (a stdout write error must not suppress the native path). The /// returned `io::Result` is the OSC 52 outcome, for the caller to log. pub fn deliver( w: &mut W, native: &mut N, text: &str, tmux: bool, ) -> io::Result<()> { let osc = emit_osc52(w, text, tmux); native.set_text(text); osc } /// The production [`NativeClipboard`] backed by `arboard`. /// /// The `arboard::Clipboard` is created **lazily on first use** (so a /// session that only ever relies on OSC 52 never opens an X11 /// connection) and then **kept alive** — arboard's X11 backend serves /// the selection from a background thread owned by the `Clipboard`, so /// dropping it after each write would lose the contents. If /// construction fails (no display), it stays `None` and every write is /// a silent no-op. #[derive(Default)] pub struct SystemClipboard { inner: Option, } impl SystemClipboard { #[must_use] pub const fn new() -> Self { Self { inner: None } } } impl NativeClipboard for SystemClipboard { fn set_text(&mut self, text: &str) { if self.inner.is_none() { match arboard::Clipboard::new() { Ok(cb) => self.inner = Some(cb), Err(e) => { tracing::debug!( error = %e, "native clipboard unavailable; relying on OSC 52" ); return; } } } if let Some(cb) = &mut self.inner && let Err(e) = cb.set_text(text) { tracing::debug!(error = %e, "native clipboard write failed; OSC 52 carried it"); } } } #[cfg(test)] mod tests { use super::*; /// A test double recording the last native write. #[derive(Default)] struct SpyClipboard { last: Option, calls: usize, } impl NativeClipboard for SpyClipboard { fn set_text(&mut self, text: &str) { self.last = Some(text.to_owned()); self.calls += 1; } } #[test] fn osc52_sequence_is_the_canonical_escape() { // ESC ] 52 ; c ; BEL. base64("hi") = "aGk=". let seq = osc52_sequence("hi", false); assert_eq!(seq, "\u{1b}]52;c;aGk=\u{07}"); } #[test] fn osc52_base64_round_trips_the_payload() { // Pull the base64 field out and decode it back to the input, // proving we encode the raw UTF-8 bytes (incl. non-ASCII). let text = "┌─┐ ✓ café"; let seq = osc52_sequence(text, false); let b64 = seq .strip_prefix("\u{1b}]52;c;") .and_then(|s| s.strip_suffix('\u{07}')) .expect("canonical OSC 52 shape"); let decoded = base64::engine::general_purpose::STANDARD .decode(b64) .expect("valid base64"); assert_eq!(String::from_utf8(decoded).unwrap(), text); } #[test] fn tmux_wraps_in_passthrough_and_doubles_the_esc() { let plain = osc52_sequence("hi", false); let wrapped = osc52_sequence("hi", true); // The inner sequence has exactly one ESC (the OSC opener); the // BEL terminator is not an ESC, so doubling touches only it. let doubled_inner = plain.replace('\u{1b}', "\u{1b}\u{1b}"); assert_eq!(wrapped, format!("\u{1b}Ptmux;{doubled_inner}\u{1b}\\")); // Concretely: leading `ESC ESC ]` and the trailing `ESC \`. assert!(wrapped.starts_with("\u{1b}Ptmux;\u{1b}\u{1b}]52;c;")); assert!(wrapped.ends_with("\u{07}\u{1b}\\")); } #[test] fn emit_osc52_writes_the_sequence_to_the_sink() { let mut buf: Vec = Vec::new(); emit_osc52(&mut buf, "hi", false).unwrap(); assert_eq!(String::from_utf8(buf).unwrap(), osc52_sequence("hi", false)); } #[test] fn deliver_writes_osc52_and_calls_native_once() { let mut buf: Vec = Vec::new(); let mut spy = SpyClipboard::default(); deliver(&mut buf, &mut spy, "payload", false).unwrap(); assert_eq!( String::from_utf8(buf).unwrap(), osc52_sequence("payload", false) ); assert_eq!(spy.last.as_deref(), Some("payload")); assert_eq!(spy.calls, 1, "native is attempted exactly once"); } #[test] fn deliver_still_calls_native_when_the_writer_errors() { // A writer that always fails: the native path must still fire // (OSC 52 failure must not suppress the native fallback). struct FailWriter; impl Write for FailWriter { fn write(&mut self, _: &[u8]) -> io::Result { Err(io::Error::other("nope")) } fn flush(&mut self) -> io::Result<()> { Err(io::Error::other("nope")) } } let mut spy = SpyClipboard::default(); let res = deliver(&mut FailWriter, &mut spy, "payload", false); assert!(res.is_err(), "the OSC 52 error is returned for logging"); assert_eq!(spy.last.as_deref(), Some("payload"), "native still fired"); } }