d0c8f9d5d2
New app-level `copy` / `copy all` / `copy last` command (ADR-0041). Delivery is OSC 52 *and* a best-effort native write (arboard), always both — OSC 52 acceptance is undetectable, so a true fallback can't be built. Payload is the panel's plain text exactly as rendered (tags, ✓/✗, box-drawing), drift-locked to render_output_line. arboard added --no-default-features (X11-only; OSC 52 covers Wayland). Amends ADR-0003's command registry; requirements V6.
224 lines
7.9 KiB
Rust
224 lines
7.9 KiB
Rust
//! 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 ; <base64> 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: Write>(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: Write, N: NativeClipboard>(
|
|
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<arboard::Clipboard>,
|
|
}
|
|
|
|
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<String>,
|
|
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 ; <base64-of-"hi"> 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<u8> = 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<u8> = 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<usize> {
|
|
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");
|
|
}
|
|
}
|